Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/HivesPage.tsx

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