From 6b8423051b9e3109fc0db39c2634a9311409ae07 Mon Sep 17 00:00:00 2001 From: Hellenjoseph Date: Mon, 29 Jun 2026 12:20:36 +0000 Subject: [PATCH] fix(#479): add ServiceAlert component and useServiceHealth hook for disconnected backend UI - Add useServiceHealth hook that polls /api/health every 30s via react-query - Add ServiceAlert component: shows degraded/error banner with affected service names and retry guidance; hides when all services are healthy; never exposes stack traces - Render ServiceAlert at top of dashboard page - Add unit tests covering healthy/loading/degraded/error states --- apps/web/src/app/dashboard/page.tsx | 2 + .../web/src/components/service-alert.test.tsx | 73 +++++++++++++++++++ apps/web/src/components/service-alert.tsx | 60 +++++++++++++++ apps/web/src/hooks/use-service-health.ts | 65 +++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 apps/web/src/components/service-alert.test.tsx create mode 100644 apps/web/src/components/service-alert.tsx create mode 100644 apps/web/src/hooks/use-service-health.ts diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index ef96a4d..3d31746 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -19,6 +19,7 @@ import { Zap, Award, Leaf, TrendingUp, Download } from 'lucide-react' import { StatCardSkeleton, ChartSkeleton, TableRowSkeleton } from '@/components/skeleton' import { useState, useMemo } from 'react' import { useRealtimeReadings } from '@/hooks/use-realtime-readings' +import { ServiceAlert } from '@/components/service-alert' // --------------------------------------------------------------------------- // Types @@ -208,6 +209,7 @@ export default function DashboardPage() { return (
+

Analytics

diff --git a/apps/web/src/components/service-alert.test.tsx b/apps/web/src/components/service-alert.test.tsx new file mode 100644 index 0000000..66adaa9 --- /dev/null +++ b/apps/web/src/components/service-alert.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ServiceAlert } from '@/components/service-alert' + +vi.mock('@/hooks/use-service-health', () => ({ + useServiceHealth: vi.fn(), +})) +import { useServiceHealth } from '@/hooks/use-service-health' + +describe('ServiceAlert', () => { + it('renders nothing when all services are healthy', () => { + vi.mocked(useServiceHealth).mockReturnValue({ + hasIssues: false, + degradedServices: [], + data: { status: 'ok', ts: Date.now(), checks: {} as never }, + isLoading: false, + refetch: vi.fn(), + }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing while loading', () => { + vi.mocked(useServiceHealth).mockReturnValue({ + hasIssues: false, + degradedServices: [], + data: undefined, + isLoading: true, + refetch: vi.fn(), + }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('shows degraded banner with affected service names', () => { + vi.mocked(useServiceHealth).mockReturnValue({ + hasIssues: true, + degradedServices: ['Database (Supabase)', 'Stellar RPC'], + data: { status: 'degraded', ts: Date.now(), checks: {} as never }, + isLoading: false, + refetch: vi.fn(), + }) + render() + expect(screen.getByRole('alert')).toBeTruthy() + expect(screen.getByText(/degraded performance/i)).toBeTruthy() + expect(screen.getByText(/Database \(Supabase\), Stellar RPC/)).toBeTruthy() + }) + + it('shows error banner for full outage', () => { + vi.mocked(useServiceHealth).mockReturnValue({ + hasIssues: true, + degradedServices: ['Database (Supabase)'], + data: { status: 'error', ts: Date.now(), checks: {} as never }, + isLoading: false, + refetch: vi.fn(), + }) + render() + expect(screen.getByText(/currently unavailable/i)).toBeTruthy() + }) + + it('does not expose raw error details to the user', () => { + vi.mocked(useServiceHealth).mockReturnValue({ + hasIssues: true, + degradedServices: ['Database (Supabase)'], + data: { status: 'error', ts: Date.now(), checks: {} as never }, + isLoading: false, + refetch: vi.fn(), + }) + render() + const alert = screen.getByRole('alert') + expect(alert.textContent).not.toMatch(/Error:|stack|at Object|at Module/) + }) +}) diff --git a/apps/web/src/components/service-alert.tsx b/apps/web/src/components/service-alert.tsx new file mode 100644 index 0000000..7bb8b8f --- /dev/null +++ b/apps/web/src/components/service-alert.tsx @@ -0,0 +1,60 @@ +'use client' + +import { AlertTriangle, RefreshCw, ServerCrash } from 'lucide-react' +import { useServiceHealth } from '@/hooks/use-service-health' + +/** + * ServiceAlert — displays a non-blocking banner when one or more backend + * services (Supabase, Stellar, Redis) are unavailable or degraded. + * + * Shows nothing when all services are healthy. + * Never exposes raw stack traces or internal error details to the user. + */ +export function ServiceAlert() { + const { hasIssues, degradedServices, data, isLoading, refetch } = useServiceHealth() + + if (isLoading || !hasIssues) return null + + const isFullOutage = data?.status === 'error' + + return ( +
+ {isFullOutage ? ( +
+ ) +} diff --git a/apps/web/src/hooks/use-service-health.ts b/apps/web/src/hooks/use-service-health.ts new file mode 100644 index 0000000..a3ebae9 --- /dev/null +++ b/apps/web/src/hooks/use-service-health.ts @@ -0,0 +1,65 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' + +export type ServiceStatus = 'ok' | 'degraded' | 'error' + +export interface ServiceCheckResult { + status: ServiceStatus + latency_ms: number + error?: string +} + +export interface HealthData { + status: ServiceStatus + ts: number + checks: { + database: ServiceCheckResult + stellar_rpc: ServiceCheckResult + redis_upstash: ServiceCheckResult + redis_bull: ServiceCheckResult + } +} + +export interface ServiceHealthResult { + data: HealthData | undefined + isLoading: boolean + /** True when at least one service is in error or degraded state */ + hasIssues: boolean + /** Services currently in error or degraded state */ + degradedServices: string[] + refetch: () => void +} + +const SERVICE_LABELS: Record = { + database: 'Database (Supabase)', + stellar_rpc: 'Stellar RPC', + redis_upstash: 'Redis (Upstash)', + redis_bull: 'Redis (Bull queue)', +} + +export function useServiceHealth(refetchIntervalMs = 30_000): ServiceHealthResult { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['service-health'], + queryFn: async () => { + const res = await fetch('/api/health') + return res.json() + }, + refetchInterval: refetchIntervalMs, + staleTime: 10_000, + }) + + const degradedServices = data + ? Object.entries(data.checks) + .filter(([, check]) => check.status !== 'ok') + .map(([key]) => SERVICE_LABELS[key] ?? key) + : [] + + return { + data, + isLoading, + hasIssues: degradedServices.length > 0, + degradedServices, + refetch, + } +}