Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export default function DashboardPage() {
return (
<WalletGate>
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-4"><ServiceAlert /></div>
<header className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Analytics</h1>
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/service-alert.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ServiceAlert />)
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(<ServiceAlert />)
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(<ServiceAlert />)
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(<ServiceAlert />)
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(<ServiceAlert />)
const alert = screen.getByRole('alert')
expect(alert.textContent).not.toMatch(/Error:|stack|at Object|at Module/)
})
})
60 changes: 60 additions & 0 deletions apps/web/src/components/service-alert.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
aria-live="polite"
className={`flex items-start gap-3 rounded-xl border p-4 text-sm ${
isFullOutage
? 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300'
: 'border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950/40 dark:text-yellow-300'
}`}
>
{isFullOutage ? (
<ServerCrash className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
)}
<div className="flex-1">
<p className="font-semibold">
{isFullOutage
? 'One or more services are currently unavailable'
: 'Some services are experiencing degraded performance'}
</p>
<p className="mt-0.5 text-xs opacity-80">
Affected: {degradedServices.join(', ')}
</p>
<p className="mt-1 text-xs opacity-70">
{isFullOutage
? 'Some features may not work. Please try again shortly or contact support if the problem persists.'
: 'Operations may be slower than usual. No action required — we are monitoring the situation.'}
</p>
</div>
<button
type="button"
onClick={() => refetch()}
aria-label="Retry health check"
className="shrink-0 rounded-md p-1.5 opacity-70 transition hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-current focus:ring-offset-1"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</button>
</div>
)
}
65 changes: 65 additions & 0 deletions apps/web/src/hooks/use-service-health.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<HealthData>({
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,
}
}
Loading