Closes #3446. New ui/refresh-interval/RefreshInterval — a RefreshIntervalPicker (off/10s/30s/1m preset select) plus a useRefreshInterval hook that polls on that cadence, pausing while the document is hidden and resyncing immediately on becoming visible again (same pattern RelativeTime already uses). The hook keeps the caller's onTick fresh via a ref rather than an effect dependency, so a new closure each render doesn't re-arm the timer. HivesPage wires it in: defaults to 30s (no inputs on this page to interrupt, and the point of the feature is not needing a manual reload), replacing the old fetch-once-at-mount effect. A successful refresh also clears any previous fetch error instead of leaving a stale failure on screen after the data's recovered. Verified: tsc clean, build succeeds, screenshotted the hives page (auto-loads on mount, picker defaults to 30s) and the components page demo.
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
// <HivesPage> — the hive roster, `/`'s content. Fetches
|
|
// swarm-controller's `GET /api/hives/status`, the swarm-wide status
|
|
// aggregate: one row per roster hive, freshness derived at read time
|
|
// from the status bucket. Supersedes the earlier `GET /api/hives` +
|
|
// static "configured" chip placeholder now that the real rollup
|
|
// exists — same component, richer data, no rebuild, exactly the plan
|
|
// that placeholder's comment laid out. Its own component/file rather
|
|
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
|
|
// routing, a page owns its own fetch + render.
|
|
//
|
|
// Polls on a `RefreshIntervalPicker` cadence rather than fetching once
|
|
// at mount — no inputs on this page, so a re-fetch clobbering an
|
|
// in-progress edit (the hook's stated caller obligation) isn't a live
|
|
// concern here.
|
|
import { 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 {
|
|
RefreshIntervalPicker,
|
|
useRefreshInterval,
|
|
type RefreshIntervalMs,
|
|
} from '../ui/refresh-interval/RefreshInterval.js';
|
|
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
|
import { Table, type TableColumn } from '../ui/table/Table.js';
|
|
|
|
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
|
|
|
interface HiveStatus {
|
|
name: string;
|
|
domain: string | null;
|
|
freshness: Freshness;
|
|
last_seen_unix: 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
|
|
// from the roster) reads `negative` — it's the one case this endpoint
|
|
// cannot vouch for at all, per `status.rs`'s doc comment.
|
|
const FRESHNESS: Record<Freshness, { tone: ChipTone; label: string }> = {
|
|
fresh: { tone: 'positive', label: 'fresh' },
|
|
stale: { tone: 'warning', label: 'stale' },
|
|
never_reported: { tone: 'neutral', label: 'never reported' },
|
|
unknown: { tone: 'negative', label: 'unknown' },
|
|
};
|
|
|
|
const COLUMNS: TableColumn<HiveStatus>[] = [
|
|
{ key: 'name', header: 'name', render: (h) => h.name },
|
|
{
|
|
key: 'domain',
|
|
header: 'domain',
|
|
render: (h) =>
|
|
h.domain ? (
|
|
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
|
|
{h.domain}
|
|
</a>
|
|
) : (
|
|
'—'
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'status',
|
|
render: (h) => {
|
|
const { tone, label } = FRESHNESS[h.freshness];
|
|
return (
|
|
<StatusChip
|
|
tone={tone}
|
|
label={
|
|
h.last_seen_unix !== null ? (
|
|
<>
|
|
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
|
</>
|
|
) : (
|
|
label
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
// 30s default: this page has no inputs to interrupt, and the whole
|
|
// point of a refresh-interval control is "no manual reload needed" — an
|
|
// operator who wants it off still can, but the out-of-the-box behaviour
|
|
// should actually solve the staleness problem rather than require an
|
|
// opt-in every visit.
|
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
|
|
|
export function HivesPage() {
|
|
const [hives, setHives] = useState<HiveStatus[] | null>(null);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
|
|
|
useRefreshInterval(intervalMs, () => {
|
|
(async () => {
|
|
const r = await fetch('/api/hives/status');
|
|
if (!r.ok) {
|
|
setError(await readApiError(r));
|
|
return;
|
|
}
|
|
setHives((await r.json()) as HiveStatus[]);
|
|
// A refresh that succeeds clears a previous failure — otherwise a
|
|
// transient error would sit on screen forever after the data
|
|
// itself has recovered.
|
|
setError(null);
|
|
})().catch((e: unknown) => setError({ detail: String(e) }));
|
|
});
|
|
|
|
return (
|
|
<Panel title="hives">
|
|
<RefreshIntervalPicker id="hives-refresh" value={intervalMs} onChange={setIntervalMs} />
|
|
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
|
|
{!error && hives === null ? <p>loading…</p> : null}
|
|
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
|
</Panel>
|
|
);
|
|
}
|