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:
parent
8ffc22eaea
commit
a5ef31ed8b
4 changed files with 91 additions and 5 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { Panel } from '../ui/panel/Panel.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 { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
||||||
import { Table, type TableColumn } from '../ui/table/Table.js';
|
import { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
import { TextField } from '../ui/text-field/TextField.js';
|
import { TextField } from '../ui/text-field/TextField.js';
|
||||||
|
|
@ -115,6 +116,17 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="RelativeTime">
|
||||||
|
<div class="components-chip-row">
|
||||||
|
<Sample label="5s ago, ticking">
|
||||||
|
<RelativeTime epochMs={Date.now() - 5_000} />
|
||||||
|
</Sample>
|
||||||
|
<Sample label="2h ago, ticking">
|
||||||
|
<RelativeTime epochMs={Date.now() - 2 * 3_600_000} />
|
||||||
|
</Sample>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="TextField">
|
<Section title="TextField">
|
||||||
<Sample label="editable">
|
<Sample label="editable">
|
||||||
<TextFieldSample />
|
<TextFieldSample />
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import { useEffect, useState } from 'preact/hooks';
|
||||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
||||||
import { Panel } from '../ui/panel/Panel.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 { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
||||||
import { Table, type TableColumn } from '../ui/table/Table.js';
|
import { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
import { fmtAgo } from '../util.js';
|
|
||||||
|
|
||||||
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
||||||
|
|
||||||
|
|
@ -22,7 +22,10 @@ interface HiveStatus {
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
freshness: Freshness;
|
freshness: Freshness;
|
||||||
last_seen_unix: number | null;
|
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
|
// One tone + label per freshness value. `unknown` (reporting but absent
|
||||||
|
|
@ -54,8 +57,20 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
header: 'status',
|
header: 'status',
|
||||||
render: (h) => {
|
render: (h) => {
|
||||||
const { tone, label } = FRESHNESS[h.freshness];
|
const { tone, label } = FRESHNESS[h.freshness];
|
||||||
const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : '';
|
return (
|
||||||
return <StatusChip tone={tone} label={label + age} />;
|
<StatusChip
|
||||||
|
tone={tone}
|
||||||
|
label={
|
||||||
|
h.last_seen_unix !== null ? (
|
||||||
|
<>
|
||||||
|
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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)}</>;
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,22 @@
|
||||||
// single static "configured" tone and grows real online/stale/offline
|
// single static "configured" tone and grows real online/stale/offline
|
||||||
// states once a status rollup lands server-side — same component,
|
// states once a status rollup lands server-side — same component,
|
||||||
// richer data later, no rebuild.
|
// richer data later, no rebuild.
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
import './StatusChip.css';
|
import './StatusChip.css';
|
||||||
|
|
||||||
export type ChipTone = 'neutral' | 'positive' | 'warning' | 'negative';
|
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>;
|
return <span class={'ui-chip ui-chip-' + tone}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue