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)}</>;
}

View file

@ -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 `<RelativeTime>` 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 <span class={'ui-chip ui-chip-' + tone}>{label}</span>;
}