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 && ( +
+ + {/* Detailed view - expandable */} + {data.totalServices > 0 && ( +
+
+
+
    + {data.services.map((service) => { + const serviceConfig = STATUS_CONFIG[service.status]; + return ( +
  • +
  • + ); + })} +
+
+
+
+ )} +
+ ); +} + +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;