159 lines
5.2 KiB
TypeScript
159 lines
5.2 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 { Badge, type BadgeTone } from "@hive/shared/badge.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 { 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: BadgeTone; 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,
|
|
sortBy: (h) => h.name,
|
|
filterValue: (h) => h.name,
|
|
},
|
|
{
|
|
key: "domain",
|
|
header: "domain",
|
|
render: (h) =>
|
|
h.domain ? (
|
|
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
|
|
{h.domain}
|
|
</a>
|
|
) : (
|
|
"—"
|
|
),
|
|
sortBy: (h) => h.domain ?? "",
|
|
filterValue: (h) => h.domain ?? "",
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "status",
|
|
render: (h) => {
|
|
const { tone, label } = FRESHNESS[h.freshness];
|
|
return (
|
|
<Badge
|
|
tone={tone}
|
|
value={
|
|
h.last_seen_unix !== null ? (
|
|
<>
|
|
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
|
</>
|
|
) : (
|
|
label
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
sortBy: (h) => FRESHNESS[h.freshness].label,
|
|
// Multiselect (not the single-value `"select"` mode) so the operator
|
|
// can filter for e.g. "stale or offline" in one pass rather than one
|
|
// freshness state at a time.
|
|
filterValues: (h) => [FRESHNESS[h.freshness].label],
|
|
filterMode: "multiselect",
|
|
},
|
|
];
|
|
|
|
// 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"
|
|
icon="🐝"
|
|
actions={
|
|
<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}
|
|
storageKey="swarm-ui:hives:table-filters"
|
|
/>
|
|
) : null}
|
|
</Panel>
|
|
);
|
|
}
|