Skip to content

Commit e35b1b1

Browse files
NateIsernclaude
andcommitted
feat(ui): warn visitors when the chain data on screen is not current
Completes the health work: /api/health knew the node had fallen behind, but every page still rendered a plausible height with no hint that it was an hour stale. The banner is what makes that visible to someone who is not watching the API. Two judgement calls are deliberate. A one- or two-block lag stays silent — that is ordinary propagation, and a banner that appears every few minutes teaches people to ignore it, which leaves us exactly where we started. And a 503 from /api/health is parsed rather than treated as a failed request: that status IS the answer when the node is behind, so discarding it would swallow the one case the endpoint exists to report. Strings are added in English and Spanish; the other six locales fall back to English until translated, which the i18n layer already handles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ac4ee32 commit e35b1b1

7 files changed

Lines changed: 204 additions & 0 deletions

File tree

src/components/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom'
22
import { AppSidebar } from './app-sidebar'
33
import { SidebarInset, SidebarProvider } from './ui/sidebar'
44
import { SiteHeader } from './site/header'
5+
import { NodeHealthBanner } from './node-health-banner'
56
import { PWAInstallPrompt } from './pwa-install-prompt'
67
import { Toaster } from './ui/sonner'
78

@@ -12,6 +13,7 @@ export function Layout() {
1213
<AppSidebar />
1314
<SidebarInset>
1415
<SiteHeader />
16+
<NodeHealthBanner />
1517
<div className="flex flex-1 flex-col gap-4 p-3 pt-4 sm:p-4 md:p-6 lg:p-8">
1618
<Outlet />
1719
</div>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { AlertTriangle } from 'lucide-react'
2+
import { useNodeHealth } from '@/hooks/use-node-health'
3+
import { decideHealthBanner } from '@/lib/node-health-banner'
4+
import { useTranslations } from '@/lib/i18n'
5+
import { cn } from '@/lib/utils'
6+
7+
/**
8+
* Tells the visitor when the chain data on screen may not be current.
9+
*
10+
* Without this, a node that stopped following the chain looks identical to a
11+
* healthy one: every page renders a plausible height and nothing indicates it
12+
* is an hour stale. Renders nothing at all while the node is on the tip — see
13+
* `decideHealthBanner` for when it is considered worth interrupting for.
14+
*/
15+
export function NodeHealthBanner() {
16+
const t = useTranslations('nodeHealth')
17+
const { data, isLoading } = useNodeHealth()
18+
const decision = decideHealthBanner(isLoading ? undefined : data)
19+
20+
if (!decision) {
21+
return null
22+
}
23+
24+
return (
25+
<div
26+
role="status"
27+
aria-live="polite"
28+
className={cn(
29+
'flex items-center gap-2 border-b px-3 py-2 text-sm sm:px-4 md:px-6 lg:px-8',
30+
decision.tone === 'danger'
31+
? 'border-destructive/30 bg-destructive/10 text-destructive'
32+
: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
33+
)}
34+
>
35+
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
36+
<span>
37+
{t(decision.messageKey)}
38+
{decision.lagBlocks > 0 ? ` ${t('lagSuffix').replace('{blocks}', String(decision.lagBlocks))}` : ''}
39+
</span>
40+
</div>
41+
)
42+
}

src/hooks/use-node-health.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
2+
import { useNetwork } from '@/contexts/network-context'
3+
import type { NodeHealthSummary } from '@/lib/node-health-banner'
4+
5+
/**
6+
* Reads `/api/health` — whether the tip the explorer is serving is still the
7+
* chain's tip.
8+
*
9+
* Note the deliberate handling of 503: that status IS the answer when the node
10+
* has fallen behind, and its body carries the detail. Treating a non-2xx as a
11+
* failed request (the pattern used elsewhere for optional data) would swallow
12+
* exactly the case this endpoint exists to report. Only a genuinely unreachable
13+
* endpoint resolves to `null`.
14+
*/
15+
export function useNodeHealth(): UseQueryResult<NodeHealthSummary | null> {
16+
const { currentNetwork } = useNetwork()
17+
18+
return useQuery<NodeHealthSummary | null>({
19+
queryKey: ['node-health', currentNetwork],
20+
queryFn: async (): Promise<NodeHealthSummary | null> => {
21+
try {
22+
const response = await fetch(`/api/health?network=${currentNetwork}`, {
23+
headers: { Accept: 'application/json' },
24+
})
25+
const body = (await response.json()) as Partial<NodeHealthSummary> | null
26+
if (!body || typeof body.status !== 'string') {
27+
return null
28+
}
29+
return { status: body.status, lagBlocks: Number(body.lagBlocks ?? 0) }
30+
} catch {
31+
// Network error / unparseable body: health is not established.
32+
return null
33+
}
34+
},
35+
// Fixed cadence: this is an availability probe, not live chain data, and it
36+
// must keep polling at the same rate when the chain has gone quiet.
37+
refetchInterval: 60_000,
38+
staleTime: 30_000,
39+
retry: 1,
40+
})
41+
}

src/lib/node-health-banner.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { decideHealthBanner, LAGGING_BANNER_THRESHOLD } from './node-health-banner'
3+
4+
/**
5+
* The banner is the user-facing half of /api/health: it exists so a visitor is
6+
* never shown a frozen chain that looks current. It must be loud when we are
7+
* demonstrably behind and silent otherwise — a banner that cries wolf during
8+
* ordinary block propagation gets ignored, which is the same failure as not
9+
* having one.
10+
*/
11+
12+
describe('decideHealthBanner', () => {
13+
it('says nothing when the node is on the chain tip', () => {
14+
expect(decideHealthBanner({ status: 'ok', lagBlocks: 0 })).toBeNull()
15+
})
16+
17+
it('says nothing while the health of the node is still unknown to the page', () => {
18+
expect(decideHealthBanner(undefined)).toBeNull()
19+
})
20+
21+
it('stays silent for a one-block lag, which is ordinary propagation', () => {
22+
expect(decideHealthBanner({ status: 'lagging', lagBlocks: 1 })).toBeNull()
23+
})
24+
25+
it('warns once the lag grows past the threshold', () => {
26+
const decision = decideHealthBanner({ status: 'lagging', lagBlocks: LAGGING_BANNER_THRESHOLD })
27+
expect(decision).not.toBeNull()
28+
expect(decision?.tone).toBe('warning')
29+
expect(decision?.messageKey).toBe('lagging')
30+
})
31+
32+
it('raises a danger banner when the node has stopped following the chain', () => {
33+
const decision = decideHealthBanner({ status: 'stalled', lagBlocks: 69 })
34+
expect(decision?.tone).toBe('danger')
35+
expect(decision?.messageKey).toBe('stalled')
36+
expect(decision?.lagBlocks).toBe(69)
37+
})
38+
39+
it('warns when health cannot be established rather than staying silent', () => {
40+
const decision = decideHealthBanner({ status: 'unknown', lagBlocks: 0 })
41+
expect(decision?.tone).toBe('warning')
42+
expect(decision?.messageKey).toBe('unknown')
43+
})
44+
45+
it('warns when the health endpoint itself cannot be reached', () => {
46+
const decision = decideHealthBanner(null)
47+
expect(decision?.tone).toBe('warning')
48+
expect(decision?.messageKey).toBe('unreachable')
49+
})
50+
})

src/lib/node-health-banner.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Should the page tell the visitor that the chain data may not be current?
3+
*
4+
* The server decides WHETHER the node is healthy (`/api/health`); this decides
5+
* whether that verdict is worth interrupting the visitor for. The distinction
6+
* matters: a node one block behind during normal propagation is not an outage,
7+
* and a banner that appears every few minutes teaches people to ignore it —
8+
* which leaves us exactly where we started, serving a frozen chain that looks
9+
* current.
10+
*/
11+
12+
export type NodeHealthStatus = 'ok' | 'lagging' | 'stalled' | 'unknown'
13+
14+
export interface NodeHealthSummary {
15+
status: NodeHealthStatus
16+
lagBlocks: number
17+
}
18+
19+
export interface HealthBannerDecision {
20+
tone: 'warning' | 'danger'
21+
/** Suffix of the `nodeHealth.*` translation key to render. */
22+
messageKey: 'lagging' | 'stalled' | 'unknown' | 'unreachable'
23+
lagBlocks: number
24+
}
25+
26+
/** Blocks behind before an otherwise-healthy lag is worth mentioning. */
27+
export const LAGGING_BANNER_THRESHOLD = 3
28+
29+
/**
30+
* @param health the parsed `/api/health` body, `null` when the endpoint could
31+
* not be reached, or `undefined` while the first request is still in flight.
32+
*/
33+
export function decideHealthBanner(
34+
health: NodeHealthSummary | null | undefined,
35+
): HealthBannerDecision | null {
36+
// Nothing known yet — say nothing rather than flash a warning on every load.
37+
if (health === undefined) {
38+
return null
39+
}
40+
41+
// The health endpoint is the one thing that reports staleness; if it cannot be
42+
// reached we cannot claim the data is current.
43+
if (health === null) {
44+
return { tone: 'warning', messageKey: 'unreachable', lagBlocks: 0 }
45+
}
46+
47+
switch (health.status) {
48+
case 'stalled':
49+
return { tone: 'danger', messageKey: 'stalled', lagBlocks: health.lagBlocks }
50+
case 'unknown':
51+
return { tone: 'warning', messageKey: 'unknown', lagBlocks: health.lagBlocks }
52+
case 'lagging':
53+
return health.lagBlocks >= LAGGING_BANNER_THRESHOLD
54+
? { tone: 'warning', messageKey: 'lagging', lagBlocks: health.lagBlocks }
55+
: null
56+
default:
57+
return null
58+
}
59+
}

src/messages/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{
2+
"nodeHealth.stalled": "This explorer's node has stopped following the chain. The data shown is out of date.",
3+
"nodeHealth.lagging": "This explorer's node is catching up. Recent blocks may be missing.",
4+
"nodeHealth.unknown": "Cannot confirm this explorer's node is on the chain tip. The data shown may be out of date.",
5+
"nodeHealth.unreachable": "Cannot check whether this explorer's data is current.",
6+
"nodeHealth.lagSuffix": "({blocks} blocks behind)",
27
"nav.home": "Home",
38
"nav.search": "Search",
49
"nav.blocks": "Blocks",

src/messages/es.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{
2+
"nodeHealth.stalled": "El nodo de este explorador ha dejado de seguir la cadena. Los datos mostrados están desactualizados.",
3+
"nodeHealth.lagging": "El nodo de este explorador se está poniendo al día. Puede que falten bloques recientes.",
4+
"nodeHealth.unknown": "No se puede confirmar que el nodo de este explorador esté en la punta de la cadena. Los datos pueden estar desactualizados.",
5+
"nodeHealth.unreachable": "No se puede comprobar si los datos de este explorador están actualizados.",
6+
"nodeHealth.lagSuffix": "({blocks} bloques por detrás)",
27
"nav.home": "Inicio",
38
"nav.search": "Buscar",
49
"nav.blocks": "Bloques",

0 commit comments

Comments
 (0)