Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/HivesPage.tsx
iris 8917ba27b6 swarm-ui: one polled-resource hook for the refreshing pages
Each polling page repeated its own fetch-and-set code on a fixed timer,
so a slow answer could be overtaken by the next tick, an older response
could overwrite a newer one, and nothing aborted a request when the page
went away.

usePolled skips a tick while a request is in flight, aborts on unmount
or refresh, ignores answers from dropped requests, and keeps the last
good data under an error. The alerts, hives, agents, schedules, builds,
review runs and broadcast pages and the subagent list use it.
2026-10-21 23:05:35 +02:00

145 lines
4.7 KiB
TypeScript

// <HivesPage> — the hive roster, `/hives`' 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 { 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,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface HiveStatus {
name: string;
domain: string | null;
dashboard: 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 && h.dashboard ? (
<a href={`https://${h.dashboard}/`} 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 [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: hives, error } = usePolled(intervalMs, (signal) =>
fetchJson<HiveStatus[]>("/api/hives/status", signal),
);
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>
);
}