swarm-ui: self-updating RelativeTime component

Closes #3445.

New ui/relative-time/RelativeTime — takes a UTC epoch-ms instant, not
a precomputed age, and re-renders itself on a 1s interval so a tab
left open doesn't silently show a frozen 'fresh (5s ago)' hours
later. Pauses while the document is hidden, resyncs immediately on
becoming visible again.

StatusChip's label widened from string to ComponentChildren so a
chip can embed the live ticker (a plain string couldn't carry it).
HivesPage's freshness column now derives the age from last_seen_unix
client-side via RelativeTime instead of rendering the once-computed
age_seconds from the API response.

Verified: tsc clean, build succeeds, screenshotted the components
page (ticking observed advancing within one virtual-time-budget
window) and the hives page (fresh/stale/never-reported all render
correctly).
This commit is contained in:
iris 2026-08-18 22:07:17 +02:00 committed by mara
commit a5ef31ed8b
4 changed files with 91 additions and 5 deletions

View file

@ -0,0 +1,47 @@
// <RelativeTime> — 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<typeof setInterval> | 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)}</>;
}