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