diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index ae82075b..8f850528 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -8,6 +8,7 @@ import { useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Panel } from '../ui/panel/Panel.js'; +import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; import { TextField } from '../ui/text-field/TextField.js'; @@ -115,6 +116,17 @@ export function ComponentsPage() { +
+
+ + + + + + +
+
+
diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index 6fb8f6bd..f2f5550c 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -11,9 +11,9 @@ import { useEffect, useState } from 'preact/hooks'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js'; import { Panel } from '../ui/panel/Panel.js'; +import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; -import { fmtAgo } from '../util.js'; type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown'; @@ -22,7 +22,10 @@ interface HiveStatus { domain: string | null; freshness: Freshness; last_seen_unix: number | null; - age_seconds: number | null; + // `age_seconds` (a once-computed-at-fetch age) is part of the wire + // shape but deliberately unused here — rendering it directly is the + // exact bug `RelativeTime` exists to fix, so this page recomputes the + // age client-side from `last_seen_unix` instead. } // One tone + label per freshness value. `unknown` (reporting but absent @@ -54,8 +57,20 @@ const COLUMNS: TableColumn[] = [ header: 'status', render: (h) => { const { tone, label } = FRESHNESS[h.freshness]; - const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : ''; - return ; + return ( + + {label} () + + ) : ( + label + ) + } + /> + ); }, }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx b/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx new file mode 100644 index 00000000..7f021dc8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx @@ -0,0 +1,47 @@ +// — a self-updating relative-age label ("5m ago"), so a +// tab left open doesn't silently drift out of sync with reality the way +// a value computed once at fetch time would. Takes the actual UTC +// instant (`epochMs`), not a precomputed age: the caller doing its own +// age math ahead of time is exactly the shape that goes stale. +// +// Pauses its own ticking while the document is hidden rather than +// running for no visible benefit, same instinct as the dashboard +// package's matrix-rain effect — and resyncs immediately on becoming +// visible again instead of waiting out the rest of a stale interval. +import { useEffect, useState } from 'preact/hooks'; +import { fmtAgo } from '../../util.js'; + +// Cheap at the handful of rows/chips this renders for today, and coarse +// enough to be correct without visibly ticking every frame — the same +// choice a wall clock makes. +const TICK_MS = 1000; + +export function RelativeTime({ epochMs }: { epochMs: number }) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + let id: ReturnType | undefined; + + const start = () => { + setNow(Date.now()); + id = setInterval(() => setNow(Date.now()), TICK_MS); + }; + const stop = () => { + if (id !== undefined) clearInterval(id); + id = undefined; + }; + + if (document.visibilityState === 'visible') start(); + const onVisibility = () => { + if (document.visibilityState === 'visible') start(); + else stop(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + stop(); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, []); + + return <>{fmtAgo((now - epochMs) / 1000)}; +} diff --git a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx index 354c9459..0db5ef24 100644 --- a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx +++ b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx @@ -4,10 +4,22 @@ // single static "configured" tone and grows real online/stale/offline // states once a status rollup lands server-side — same component, // richer data later, no rebuild. +import type { ComponentChildren } from 'preact'; import './StatusChip.css'; export type ChipTone = 'neutral' | 'positive' | 'warning' | 'negative'; -export function StatusChip({ tone = 'neutral', label }: { tone?: ChipTone; label: string }) { +// `label` takes renderable children, not just a string — a freshness +// chip embeds a live `` alongside its static text (e.g. +// "fresh (5s ago)" where the age keeps ticking), and a plain string +// couldn't carry that without the chip reaching back into a caller's +// formatting choices. +export function StatusChip({ + tone = 'neutral', + label, +}: { + tone?: ChipTone; + label: ComponentChildren; +}) { return {label}; }