diff --git a/frontend/docs/service-health-pulse-widget.md b/frontend/docs/service-health-pulse-widget.md
new file mode 100644
index 0000000..16a22d6
--- /dev/null
+++ b/frontend/docs/service-health-pulse-widget.md
@@ -0,0 +1,233 @@
+# Service Health Pulse Widget
+
+## Overview
+
+The Service Health Pulse widget provides a compact visual indicator of overall platform status with an expandable per-service breakdown. It connects to the external dependencies monitoring system to display real-time health status across all monitored services.
+
+## Components
+
+- `src/components/ServiceHealthPulse.tsx` — Main widget component
+- `src/hooks/useServiceHealth.ts` — Data fetching hook
+
+## Features
+
+- **Compact Mode** (default): Single pulse indicator with overall status and service count
+- **Detailed Mode**: Expandable list showing individual service statuses
+- **Real-time Updates**: Polls service health data every 60 seconds
+- **Theme Support**: Full light and dark mode support via Tailwind CSS
+- **Accessibility**: WCAG 2.1 AA compliant with screen reader support and color-independent status indicators
+
+## Props Interface
+
+```typescript
+interface ServiceHealthPulseProps {
+ compact?: boolean; // Default: true - controls initial collapsed state
+ className?: string; // Additional CSS classes for custom styling
+}
+```
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `compact` | `boolean` | `true` | When `true`, the widget starts in collapsed mode. When `false`, service breakdown is initially visible. |
+| `className` | `string` | `""` | Additional CSS classes applied to the root container. |
+
+## Display Modes
+
+### Compact Mode (Default)
+
+Shows:
+- Animated pulse indicator (color-coded by status)
+- Overall status label
+- Total service count
+- Last updated timestamp
+- Expand/collapse toggle button
+
+### Detailed Mode (Expanded)
+
+Shows all compact mode content plus:
+- Per-service breakdown list
+- Individual service names
+- Status indicator for each service
+- Status label for each service
+
+## Status Values
+
+The widget displays one of five possible statuses:
+
+| Status | Label | Color | Pulse Animation | Priority |
+|--------|-------|-------|-----------------|----------|
+| `healthy` | "All systems operational" | Green | Yes | Lowest |
+| `degraded` | "Degraded performance" | Yellow | Yes | Medium |
+| `down` | "Service disruption" | Red | Yes | Highest |
+| `maintenance` | "Scheduled maintenance" | Blue | No | Medium-High |
+| `unknown` | "Status unknown" | Gray | No | Low |
+
+## Overall Status Aggregation
+
+The overall status is determined using worst-case aggregation:
+
+1. If any service is `down` → overall status is `down`
+2. Else if any service is `degraded` → overall status is `degraded`
+3. Else if any service is `maintenance` → overall status is `maintenance`
+4. Else if any service is `unknown` → overall status is `unknown`
+5. Else → overall status is `healthy`
+
+## Data Source
+
+The widget connects to the `/api/v1/external-dependencies` endpoint via the `useServiceHealth` hook. This endpoint provides:
+
+- List of monitored external services
+- Current status for each service
+- Summary counts by status type
+
+The hook polls this endpoint every 60 seconds by default and can be configured with custom refresh intervals.
+
+## Usage Examples
+
+### Basic Usage (Compact)
+
+```tsx
+import ServiceHealthPulse from './components/ServiceHealthPulse';
+
+function Dashboard() {
+ return (
+
+
+
+ );
+}
+```
+
+### Expanded by Default
+
+```tsx
+import ServiceHealthPulse from './components/ServiceHealthPulse';
+
+function StatusPage() {
+ return (
+
+
+
+ );
+}
+```
+
+### With Custom Styling
+
+```tsx
+import ServiceHealthPulse from './components/ServiceHealthPulse';
+
+function Sidebar() {
+ return (
+
+
+
+ );
+}
+```
+
+### Using the Hook Directly
+
+```tsx
+import { useServiceHealth } from './hooks/useServiceHealth';
+
+function CustomHealthDisplay() {
+ const { data, isLoading, isError } = useServiceHealth();
+
+ if (isLoading) return Loading...
;
+ if (isError) return Error loading health data
;
+
+ return (
+
+
Overall Status: {data.overallStatus}
+
Total Services: {data.totalServices}
+
Healthy: {data.healthyCount}
+
Degraded: {data.degradedCount}
+
Down: {data.downCount}
+
+ );
+}
+```
+
+## Theme Requirements
+
+The widget uses the following Tailwind CSS classes and CSS variables:
+
+### CSS Variables (from `index.css`)
+- `--stellar-bg` — Background color
+- `--stellar-card` — Card background
+- `--stellar-border` — Border color
+- `--stellar-text-primary` — Primary text
+- `--stellar-text-secondary` — Secondary text
+
+### Status Colors
+- Green: `bg-green-500`, `text-green-400`
+- Yellow: `bg-yellow-500`, `text-yellow-400`
+- Red: `bg-red-500`, `text-red-400`
+- Blue: `bg-blue-500`, `text-blue-400`
+- Gray: `bg-gray-500`, `text-gray-400`
+
+All colors automatically adapt to light and dark themes via Tailwind's dark mode.
+
+## Accessibility
+
+The widget follows WCAG 2.1 AA guidelines:
+
+### Screen Reader Support
+- `role="status"` on overall pulse indicator
+- `aria-live="polite"` for status change announcements
+- `role="list"` and `role="listitem"` for service breakdown
+- Descriptive `aria-label` attributes on all interactive elements
+
+### Keyboard Navigation
+- Expand/collapse button is fully keyboard accessible
+- `aria-expanded` attribute indicates current state
+- `aria-controls` links button to content region
+- Visible focus indicators on all interactive elements
+
+### Color Independence
+- Every status indicator includes a text label
+- Status dots are marked `aria-hidden="true"` with adjacent text
+- No information conveyed by color alone
+
+## Testing
+
+The widget includes comprehensive test coverage:
+
+### Component Tests (`ServiceHealthPulse.test.tsx`)
+- Loading state rendering
+- All status values (healthy, degraded, down, maintenance, unknown)
+- Expand/collapse functionality
+- Error state handling
+- Empty service list handling
+- Accessibility compliance (vitest-axe)
+- Custom className application
+
+### Hook Tests (`useServiceHealth.test.ts`)
+- Data fetching and aggregation
+- Status priority logic
+- Error handling
+- Empty data handling
+
+Run tests with:
+```bash
+npm test ServiceHealthPulse
+```
+
+## Related Components
+
+- `ExternalDependencyPanel` — Detailed view of external dependencies with history
+- `ConnectionStatus` — WebSocket connection status indicator
+- `BridgeStatusCard` — Individual bridge health status
+
+## Future Enhancements
+
+Potential improvements for future iterations:
+
+- Click-through to detailed dependency view
+- Historical status timeline
+- Configurable polling interval via props
+- Status change notifications
+- Export status data
diff --git a/frontend/src/components/ServiceHealthPulse.test.tsx b/frontend/src/components/ServiceHealthPulse.test.tsx
new file mode 100644
index 0000000..276e8d4
--- /dev/null
+++ b/frontend/src/components/ServiceHealthPulse.test.tsx
@@ -0,0 +1,372 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { http, HttpResponse } from "msw";
+import { server } from "../test/mocks/server";
+import { axe } from "vitest-axe";
+import ServiceHealthPulse from "./ServiceHealthPulse";
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe("ServiceHealthPulse", () => {
+ it("renders loading state initially", () => {
+ render( , { wrapper: createWrapper() });
+
+ expect(screen.getByRole("article", { name: /loading service health/i })).toBeInTheDocument();
+ });
+
+ it("renders healthy status with all services operational", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "healthy",
+ },
+ ],
+ summary: {
+ healthy: 2,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("All systems operational")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("2 services")).toBeInTheDocument();
+ expect(screen.getByRole("status", { name: /overall status: all systems operational/i })).toBeInTheDocument();
+ });
+
+ it("renders degraded status when any service is degraded", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "degraded",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 1,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Degraded performance")).toBeInTheDocument();
+ });
+ });
+
+ it("renders down status when any service is down", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "down",
+ },
+ ],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 1,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Service disruption")).toBeInTheDocument();
+ });
+ });
+
+ it("renders maintenance status", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "maintenance",
+ },
+ ],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 0,
+ maintenance: 1,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Scheduled maintenance")).toBeInTheDocument();
+ });
+ });
+
+ it("expands to show service breakdown when toggle is clicked", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "degraded",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 1,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("Degraded performance")).toBeInTheDocument();
+ });
+
+ // Initially collapsed - service names should not be visible
+ expect(screen.queryByText("Horizon API")).not.toBeVisible();
+ expect(screen.queryByText("Circle API")).not.toBeVisible();
+
+ // Click expand button
+ const expandButton = screen.getByRole("button", { name: /expand service details/i });
+ fireEvent.click(expandButton);
+
+ // Services should now be visible
+ await waitFor(() => {
+ expect(screen.getByText("Horizon API")).toBeVisible();
+ expect(screen.getByText("Circle API")).toBeVisible();
+ });
+
+ // Button should update aria-expanded
+ expect(expandButton).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("does not render per-service breakdown in compact mode when collapsed", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("All systems operational")).toBeInTheDocument();
+ });
+
+ // Service name should not be visible when collapsed
+ expect(screen.queryByText("Horizon API")).not.toBeVisible();
+ });
+
+ it("renders error state when fetch fails", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText("Unable to load service health")).toBeInTheDocument();
+ });
+ });
+
+ it("handles empty service list", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByText("All systems operational")).toBeInTheDocument();
+ expect(screen.getByText("0 services")).toBeInTheDocument();
+ });
+
+ // No expand button when there are no services
+ expect(screen.queryByRole("button", { name: /expand service details/i })).not.toBeInTheDocument();
+ });
+
+ it("is accessible with no color-only status indicators", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "degraded",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 1,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { container } = render( , {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Degraded performance")).toBeInTheDocument();
+ });
+
+ // Expand to show services
+ const expandButton = screen.getByRole("button", { name: /collapse service details/i });
+ expect(expandButton).toBeInTheDocument();
+
+ // Check for text labels alongside status indicators
+ expect(screen.getByText("Horizon API")).toBeInTheDocument();
+ expect(screen.getByText("Circle API")).toBeInTheDocument();
+ expect(screen.getByText("healthy")).toBeInTheDocument();
+ expect(screen.getByText("degraded")).toBeInTheDocument();
+
+ // Run axe accessibility tests
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it("applies custom className", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { container } = render( , {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("All systems operational")).toBeInTheDocument();
+ });
+
+ expect(container.querySelector(".custom-class")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ServiceHealthPulse.tsx b/frontend/src/components/ServiceHealthPulse.tsx
new file mode 100644
index 0000000..0626a54
--- /dev/null
+++ b/frontend/src/components/ServiceHealthPulse.tsx
@@ -0,0 +1,233 @@
+import { useState } from "react";
+import { useServiceHealth, type ServiceStatus } from "../hooks/useServiceHealth";
+
+interface ServiceHealthPulseProps {
+ compact?: boolean;
+ className?: string;
+}
+
+interface StatusConfig {
+ label: string;
+ dotColor: string;
+ bgColor: string;
+ textColor: string;
+ pulse: boolean;
+}
+
+const STATUS_CONFIG: Record = {
+ healthy: {
+ label: "All systems operational",
+ dotColor: "bg-green-500",
+ bgColor: "bg-green-500/20",
+ textColor: "text-green-400",
+ pulse: true,
+ },
+ degraded: {
+ label: "Degraded performance",
+ dotColor: "bg-yellow-500",
+ bgColor: "bg-yellow-500/20",
+ textColor: "text-yellow-400",
+ pulse: true,
+ },
+ down: {
+ label: "Service disruption",
+ dotColor: "bg-red-500",
+ bgColor: "bg-red-500/20",
+ textColor: "text-red-400",
+ pulse: true,
+ },
+ maintenance: {
+ label: "Scheduled maintenance",
+ dotColor: "bg-blue-500",
+ bgColor: "bg-blue-500/20",
+ textColor: "text-blue-400",
+ pulse: false,
+ },
+ unknown: {
+ label: "Status unknown",
+ dotColor: "bg-gray-500",
+ bgColor: "bg-gray-500/20",
+ textColor: "text-gray-400",
+ pulse: false,
+ },
+};
+
+function formatRelativeTime(date: Date): string {
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
+
+ if (seconds < 60) return "just now";
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+}
+
+function ServiceHealthPulseSkeleton({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+export default function ServiceHealthPulse({
+ compact = true,
+ className = "",
+}: ServiceHealthPulseProps) {
+ const [isExpanded, setIsExpanded] = useState(!compact);
+ const { data, isLoading, isError, error } = useServiceHealth();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError || !data) {
+ return (
+
+
+
+
+
+
+
+ Unable to load service health
+
+
+ {error instanceof Error ? error.message : "Please try again later"}
+
+
+
+
+ );
+ }
+
+ const config = STATUS_CONFIG[data.overallStatus];
+ const relativeTime = formatRelativeTime(data.lastUpdated);
+
+ return (
+
+ {/* Compact view - always visible */}
+
+ {/* Pulse indicator */}
+
+ {config.pulse && (
+
+ )}
+
+
+
+ {/* Status info */}
+
+
+
+ {config.label}
+
+ {data.totalServices > 0 && (
+
setIsExpanded(!isExpanded)}
+ aria-expanded={isExpanded}
+ aria-controls="service-health-details"
+ className="flex items-center justify-center w-5 h-5 rounded hover:bg-stellar-border focus:outline-none focus:ring-2 focus:ring-stellar-blue transition-colors"
+ aria-label={isExpanded ? "Collapse service details" : "Expand service details"}
+ >
+
+
+
+
+ )}
+
+
+
+ {data.totalServices} service{data.totalServices !== 1 ? "s" : ""}
+ •
+ Updated {relativeTime}
+
+
+
+
+ {/* Detailed view - expandable */}
+ {data.totalServices > 0 && (
+
+
+
+
+ {data.services.map((service) => {
+ const serviceConfig = STATUS_CONFIG[service.status];
+ return (
+
+
+
+ {service.name}
+
+
+ {service.status}
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+ );
+}
+
+export { ServiceHealthPulseSkeleton };
diff --git a/frontend/src/hooks/useServiceHealth.test.tsx b/frontend/src/hooks/useServiceHealth.test.tsx
new file mode 100644
index 0000000..0a6432d
--- /dev/null
+++ b/frontend/src/hooks/useServiceHealth.test.tsx
@@ -0,0 +1,265 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { http, HttpResponse } from "msw";
+import { server } from "../test/mocks/server";
+import { useServiceHealth } from "./useServiceHealth";
+import type { ReactNode } from "react";
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+};
+
+describe("useServiceHealth", () => {
+ it("fetches and aggregates service health data", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "healthy",
+ },
+ ],
+ summary: {
+ healthy: 2,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toMatchObject({
+ overallStatus: "healthy",
+ totalServices: 2,
+ healthyCount: 2,
+ degradedCount: 0,
+ downCount: 0,
+ services: [
+ { name: "Horizon API", status: "healthy", category: "blockchain" },
+ { name: "Circle API", status: "healthy", category: "price" },
+ ],
+ });
+ });
+
+ it("aggregates overall status as degraded when any service is degraded", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "degraded",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 1,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.overallStatus).toBe("degraded");
+ });
+
+ it("aggregates overall status as down when any service is down", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "healthy",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "down",
+ },
+ ],
+ summary: {
+ healthy: 1,
+ degraded: 0,
+ down: 1,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.overallStatus).toBe("down");
+ });
+
+ it("aggregates overall status as maintenance when services are in maintenance", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "maintenance",
+ },
+ ],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 0,
+ maintenance: 1,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.overallStatus).toBe("maintenance");
+ });
+
+ it("prioritizes down over degraded in aggregation", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ status: "degraded",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ status: "down",
+ },
+ ],
+ summary: {
+ healthy: 0,
+ degraded: 1,
+ down: 1,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.overallStatus).toBe("down");
+ });
+
+ it("handles empty service list", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [],
+ summary: {
+ healthy: 0,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toMatchObject({
+ overallStatus: "healthy",
+ totalServices: 0,
+ services: [],
+ });
+ });
+
+ it("handles API errors gracefully", async () => {
+ server.use(
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ })
+ );
+
+ const { result } = renderHook(() => useServiceHealth({ refetchInterval: false }), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(result.current.error).toBeTruthy();
+ });
+});
diff --git a/frontend/src/hooks/useServiceHealth.ts b/frontend/src/hooks/useServiceHealth.ts
new file mode 100644
index 0000000..ba83488
--- /dev/null
+++ b/frontend/src/hooks/useServiceHealth.ts
@@ -0,0 +1,83 @@
+import { useQuery } from "@tanstack/react-query";
+import { getExternalDependencies } from "../services/api";
+
+export type ServiceStatus = "healthy" | "degraded" | "down" | "maintenance" | "unknown";
+
+export interface ServiceHealthData {
+ name: string;
+ status: ServiceStatus;
+ category: string;
+}
+
+export interface ServiceHealthSummary {
+ overallStatus: ServiceStatus;
+ services: ServiceHealthData[];
+ totalServices: number;
+ healthyCount: number;
+ degradedCount: number;
+ downCount: number;
+ maintenanceCount: number;
+ unknownCount: number;
+ lastUpdated: Date;
+}
+
+/**
+ * Determines the overall system status based on worst-case aggregation.
+ * Priority: down > degraded > maintenance > unknown > healthy
+ */
+function aggregateOverallStatus(summary: {
+ healthy: number;
+ degraded: number;
+ down: number;
+ maintenance: number;
+ unknown: number;
+}): ServiceStatus {
+ if (summary.down > 0) return "down";
+ if (summary.degraded > 0) return "degraded";
+ if (summary.maintenance > 0) return "maintenance";
+ if (summary.unknown > 0) return "unknown";
+ return "healthy";
+}
+
+type QueryRefreshOptions = {
+ refetchInterval?: number | false;
+ refetchOnWindowFocus?: boolean;
+};
+
+/**
+ * Hook to fetch and aggregate service health data.
+ * Polls the external dependencies endpoint and derives overall system status.
+ *
+ * @param options - React Query refresh options
+ * @returns Service health summary with loading and error states
+ */
+export function useServiceHealth(options?: QueryRefreshOptions) {
+ return useQuery({
+ queryKey: ["service-health"],
+ queryFn: async (): Promise => {
+ const data = await getExternalDependencies(false, 0);
+
+ const services: ServiceHealthData[] = data.dependencies.map((dep) => ({
+ name: dep.displayName,
+ status: dep.status,
+ category: dep.category,
+ }));
+
+ const overallStatus = aggregateOverallStatus(data.summary);
+
+ return {
+ overallStatus,
+ services,
+ totalServices: data.dependencies.length,
+ healthyCount: data.summary.healthy,
+ degradedCount: data.summary.degraded,
+ downCount: data.summary.down,
+ maintenanceCount: data.summary.maintenance,
+ unknownCount: data.summary.unknown,
+ lastUpdated: new Date(),
+ };
+ },
+ refetchInterval: options?.refetchInterval ?? 60_000,
+ refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
+ });
+}
diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts
index 7843cef..6c43e29 100644
--- a/frontend/src/test/mocks/handlers.ts
+++ b/frontend/src/test/mocks/handlers.ts
@@ -89,4 +89,59 @@ export const handlers = [
data: { results, total: results.length },
});
}),
+
+ // Mock External Dependencies (Service Health)
+ http.get("/api/v1/external-dependencies", () => {
+ return HttpResponse.json({
+ dependencies: [
+ {
+ providerKey: "horizon",
+ displayName: "Horizon API",
+ category: "blockchain",
+ endpoint: "https://horizon.stellar.org",
+ checkType: "http",
+ latencyWarningMs: 1000,
+ latencyCriticalMs: 3000,
+ failureThreshold: 3,
+ maintenanceMode: false,
+ maintenanceNote: null,
+ status: "healthy",
+ lastCheckedAt: new Date().toISOString(),
+ lastLatencyMs: 250,
+ consecutiveFailures: 0,
+ lastSuccessAt: new Date().toISOString(),
+ lastFailureAt: null,
+ lastError: null,
+ alertState: "none",
+ },
+ {
+ providerKey: "circle",
+ displayName: "Circle API",
+ category: "price",
+ endpoint: "https://api.circle.com",
+ checkType: "http",
+ latencyWarningMs: 2000,
+ latencyCriticalMs: 5000,
+ failureThreshold: 3,
+ maintenanceMode: false,
+ maintenanceNote: null,
+ status: "healthy",
+ lastCheckedAt: new Date().toISOString(),
+ lastLatencyMs: 180,
+ consecutiveFailures: 0,
+ lastSuccessAt: new Date().toISOString(),
+ lastFailureAt: null,
+ lastError: null,
+ alertState: "none",
+ },
+ ],
+ summary: {
+ healthy: 2,
+ degraded: 0,
+ down: 0,
+ maintenance: 0,
+ unknown: 0,
+ },
+ });
+ }),
];
diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts
index 340562a..e22dffc 100644
--- a/frontend/vite.config.d.ts
+++ b/frontend/vite.config.d.ts
@@ -1,2 +1,2 @@
-declare const _default: import("vite").UserConfig;
+declare const _default: import("vite").UserConfig & Promise & (import("vitest/config").ViteUserConfigFnObject & (import("vitest/config").ViteUserConfigFnPromise & import("vitest/config").ViteUserConfigExport));
export default _default;