diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index d0eb77a6..7e7ee3e7 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -24,6 +24,10 @@ domain, covers host-level detail for one hive. | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). +Pages that refresh on a timer share one polling rule: a tick is skipped while +the last request is still running, so a slow answer is never overtaken by an +older one, and a failed refresh keeps the rows already on screen under an +error. An agent created here or with `swarmctl agent create` starts `paused`; set it `up` from its card. A card shows **login needed** when the agent's own turn-state header says its claude has no session, and a **log in** button diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts index 6810f3f7..a514fbe3 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts @@ -1,10 +1,9 @@ // An agent's subagents, refetched on a timer: `GET /api/agents/{name}/subagents`. // Subagents are spawned on demand, so a one-off fetch would go stale. `null` // until the first answer; a failed fetch sets `error` and keeps the last list. -// Mount per agent name: the timer is armed once per mount. -import { useState } from "preact/hooks"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; -import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js"; +// Mount per agent name: the poll belongs to one agent. +import type { ProblemDetails } from "@hive/shared/api-error.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { byRecency, type Subagent } from "./subagent-list.js"; const REFRESH_MS = 10_000; @@ -13,24 +12,13 @@ export function useSubagentList(agentName: string): { subagents: Subagent[] | null; error: ProblemDetails | null; } { - const [subagents, setSubagents] = useState(null); - const [error, setError] = useState(null); - - async function refresh() { - const res = await fetch( - `/api/agents/${encodeURIComponent(agentName)}/subagents`, - ); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setSubagents(((await res.json()) as Subagent[]).sort(byRecency)); - setError(null); - } - - useRefreshInterval(REFRESH_MS, () => { - refresh().catch((e: unknown) => setError({ detail: String(e) })); - }); - - return { subagents, error }; + const { data, error } = usePolled(REFRESH_MS, async (signal) => + ( + await fetchJson( + `/api/agents/${encodeURIComponent(agentName)}/subagents`, + signal, + ) + ).sort(byRecency), + ); + return { subagents: data, error }; } diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index a0c23add..42bd0b1e 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -5,16 +5,15 @@ // rendered as a `Table`. 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 { Button } from "../ui/button/Button.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 { fetchJson, usePolled } from "../ui/polled/usePolled.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js"; @@ -73,26 +72,15 @@ const COLUMNS: TableColumn[] = [ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { - const [alerts, setAlerts] = useState(null); - const [error, setError] = useState(null); const [showAll, setShowAll] = useState(false); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { data: alerts, error } = usePolled(intervalMs, (signal) => + fetchJson("/api/alerts", signal), + ); const listed = alerts ? listedAlerts(alerts, showAll) : null; const hidden = alerts && listed ? alerts.length - listed.length : 0; - useRefreshInterval(intervalMs, () => { - (async () => { - const r = await fetch("/api/alerts"); - if (!r.ok) { - setError(await readApiError(r)); - return; - } - setAlerts((await r.json()) as Alert[]); - setError(null); - })().catch((e: unknown) => setError({ detail: String(e) })); - }); - return ( [] = [ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function HivesPage() { - const [hives, setHives] = useState(null); - const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(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) })); - }); + const { data: hives, error } = usePolled(intervalMs, (signal) => + fetchJson("/api/hives/status", signal), + ); return ( (null); - const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { + data: rows, + error, + setData: setRows, + } = usePolled(intervalMs, (signal) => + fetchJson("/api/agents/status", signal), + ); // Lazy initializer (not a mount effect) so the very first render // already reflects the stored choice — no default-then-flip flash. const [viewMode, setViewModeState] = useState(() => { @@ -116,23 +120,6 @@ export function AgentsPage() { const [detailTargetName, setDetailTargetName] = useState(null); const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null; - async function refresh() { - const res = await fetch("/api/agents/status"); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRows((await res.json()) as AgentRow[]); - // 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); - } - - useRefreshInterval(intervalMs, () => { - refresh().catch((e: unknown) => setError({ detail: String(e) })); - }); - // `FilterableView`'s `columns` — the full field set (including // per-column sort/filter, which drives both the table's own popovers // and the card view's filter bar now — see that component's own diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx index 1db66ea3..1f39e37e 100644 --- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx @@ -19,9 +19,9 @@ import { Button } from "../../ui/button/Button.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { failedAgents, @@ -58,10 +58,11 @@ function storedFilter(): AgentFilter { } export function BroadcastPage() { - const [agents, setAgents] = useState(null); - const [loadError, setLoadError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => + fetchJson("/api/agents/status", signal), + ); const [filter, setFilterState] = useState(storedFilter); const [ticked, setTicked] = useState([]); const [body, setBody] = useState(""); @@ -76,20 +77,6 @@ export function BroadcastPage() { } } - async function refresh() { - const res = await fetch("/api/agents/status"); - if (!res.ok) { - setLoadError(await readApiError(res)); - return; - } - setAgents((await res.json()) as BroadcastAgent[]); - setLoadError(null); - } - - useRefreshInterval(intervalMs, () => { - refresh().catch((e: unknown) => setLoadError({ detail: String(e) })); - }); - const all = agents ?? []; const shown = visibleAgents(all, filter); const targets = selectedVisible(ticked, shown); diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index 161c663f..49263b6a 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -6,15 +6,14 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { useSearch } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { Card } from "../../ui/card/Card.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { durationLabel } from "../review-runs/RunsTypes.js"; import { @@ -96,8 +95,9 @@ export function BuildsPage() { const wanted = wantedRun(useSearch()); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [runs, setRuns] = useState(null); - const [error, setError] = useState(null); + const { data: runs, error } = usePolled(intervalMs, (signal) => + fetchJson(RUNS, signal), + ); // Which cards are open. Survives a refresh, so a list that updates under // you does not fold the card you are reading. A linked build starts open. const [open, setOpen] = useState>( @@ -105,22 +105,6 @@ export function BuildsPage() { ); const scrolledTo = useRef(null); - async function load() { - try { - const res = await fetch(RUNS); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRuns((await res.json()) as BuildRun[]); - setError(null); - } catch (e: unknown) { - setError({ detail: String(e) }); - } - } - - useRefreshInterval(intervalMs, () => void load()); - // Scroll to the linked build once its card exists, not on every refresh. useEffect(() => { if (!wanted || !runs || scrolledTo.current === wanted) return; diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx index 7e14d594..28205914 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx @@ -6,15 +6,14 @@ // posts nothing, and shows no token counts, cache counts or cost. 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 } from "@hive/shared/badge.js"; import { Card } from "../../ui/card/Card.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { TryOutResult } from "../review-tryout/TryOutResult.js"; import { @@ -94,28 +93,13 @@ function RunCard({ export function ReviewRuns() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [runs, setRuns] = useState(null); - const [error, setError] = useState(null); + const { data: runs, error } = usePolled(intervalMs, (signal) => + fetchJson(RUNS, signal), + ); // Which cards are expanded. Survives a refresh, so a list that updates // under you does not fold the card you are reading. const [open, setOpen] = useState>(new Set()); - async function load() { - try { - const res = await fetch(RUNS); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRuns((await res.json()) as ClassifierRun[]); - setError(null); - } catch (e: unknown) { - setError({ detail: String(e) }); - } - } - - useRefreshInterval(intervalMs, () => void load()); - function toggle(id: string) { setOpen((cur) => { const next = new Set(cur); diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx index 247a53c1..bdde1fa8 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -9,7 +9,7 @@ // multiselects). A card being edited keeps its form state across the // periodic refetch because `FilterableView` keys cards by schedule id and // the open edit is held here by id, not by position. -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; @@ -18,9 +18,9 @@ import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import type { TableColumn } from "../../ui/table/Table.js"; import type { AgentRow } from "../../agent/AgentTypes.js"; import { ScheduleCard } from "./ScheduleCard.js"; @@ -69,8 +69,13 @@ async function loadAgentNames(): Promise { export function SchedulesPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [schedules, setSchedules] = useState(null); - const [error, setError] = useState(null); + const { + data: schedules, + error, + refresh, + } = usePolled(intervalMs, (signal) => + fetchJson("/api/schedules", signal), + ); const [agentNames, setAgentNames] = useState([]); // The schedule whose card is currently its edit form, and whether the // "new schedule" form is open above the list. @@ -80,22 +85,6 @@ export function SchedulesPage() { const [removeTarget, setRemoveTarget] = useState(null); const [actionError, setActionError] = useState(null); - const load = useCallback(async () => { - try { - const r = await fetch("/api/schedules"); - if (!r.ok) { - setError(await readApiError(r)); - return; - } - setSchedules((await r.json()) as Schedule[]); - setError(null); - } catch (err) { - setError({ detail: String(err) }); - } - }, []); - - useRefreshInterval(intervalMs, load); - useEffect(() => { loadAgentNames().then(setAgentNames, () => setAgentNames([])); }, []); @@ -121,7 +110,7 @@ export function SchedulesPage() { setActionError(await readApiError(r)); return; } - await load(); + refresh(); } catch (err) { setActionError({ detail: String(err) }); } finally { @@ -158,10 +147,10 @@ export function SchedulesPage() { [agentNames], ); - async function saved() { + function saved() { setCreating(false); setEditingId(null); - await load(); + refresh(); } return ( diff --git a/frontend/packages/swarm-ui/src/ui/polled/poller.ts b/frontend/packages/swarm-ui/src/ui/polled/poller.ts new file mode 100644 index 00000000..8d32fe26 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/polled/poller.ts @@ -0,0 +1,53 @@ +// The request discipline behind a polled page, kept free of Preact so it can +// be tested on its own. `tick` fetches unless a request is already in flight, +// so a slow answer is not overtaken by the next tick. `refetch` drops the +// request in flight and asks again, for a refresh that follows a change. +// `stop` drops the request in flight. An answer from a dropped request never +// reaches `onData` or `onError`, so an older response cannot overwrite a +// newer one or land on a page that is gone. +export interface Poller { + tick(): void; + refetch(): void; + stop(): void; +} + +export function createPoller(opts: { + fetch: (signal: AbortSignal) => Promise; + onData: (data: T) => void; + onError: (error: unknown) => void; +}): Poller { + let current: { controller: AbortController } | null = null; + + function start() { + const run = { controller: new AbortController() }; + current = run; + opts.fetch(run.controller.signal).then( + (data) => { + if (current !== run) return; + current = null; + opts.onData(data); + }, + (error: unknown) => { + if (current !== run) return; + current = null; + opts.onError(error); + }, + ); + } + + function stop() { + current?.controller.abort(); + current = null; + } + + return { + tick() { + if (current === null) start(); + }, + refetch() { + stop(); + start(); + }, + stop, + }; +} diff --git a/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts new file mode 100644 index 00000000..c12fb462 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts @@ -0,0 +1,71 @@ +// A resource refetched on a timer: `{ data, error, refresh, setData }`. +// `data` is `null` until the first answer; a failed fetch sets `error` and +// keeps the last good `data`; the next good answer clears `error`. The +// request discipline (skip a tick while one is in flight, drop answers that +// are out of date or arrive after unmount) lives in `createPoller`. +// +// `fetcher` gets an `AbortSignal` and returns the data or throws; `fetchJson` +// does that for a GET. It may change between renders: the latest one runs. +// `refresh` refetches now, dropping a request in flight, for after a change +// the page made. `setData` patches what is shown without a request. +import { useEffect, useRef, useState } from "preact/hooks"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { + useRefreshInterval, + type RefreshIntervalMs, +} from "../refresh-interval/RefreshInterval.js"; +import { createPoller, type Poller } from "./poller.js"; + +/** A failed request, carrying the problem the API answered with. */ +export class ProblemError extends Error { + constructor(readonly problem: ProblemDetails) { + super(problem.detail ?? problem.title ?? "request failed"); + } +} + +/** GET `url` as JSON; a non-2xx answer throws a `ProblemError`. */ +export async function fetchJson( + url: string, + signal: AbortSignal, +): Promise { + const res = await fetch(url, { signal }); + if (!res.ok) throw new ProblemError(await readApiError(res)); + return (await res.json()) as T; +} + +function toProblem(e: unknown): ProblemDetails { + return e instanceof ProblemError ? e.problem : { detail: String(e) }; +} + +export function usePolled( + intervalMs: RefreshIntervalMs, + fetcher: (signal: AbortSignal) => Promise, +): { + data: T | null; + error: ProblemDetails | null; + refresh: () => void; + setData: (update: (prev: T | null) => T | null) => void; +} { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + const pollerRef = useRef(null); + if (pollerRef.current === null) { + pollerRef.current = createPoller({ + fetch: (signal) => fetcherRef.current(signal), + onData: (next) => { + setData(next); + setError(null); + }, + onError: (e) => setError(toProblem(e)), + }); + } + const poller = pollerRef.current; + + useEffect(() => () => poller.stop(), [poller]); + useRefreshInterval(intervalMs, () => poller.tick()); + + return { data, error, refresh: () => poller.refetch(), setData }; +} diff --git a/frontend/packages/swarm-ui/test/poller.test.mjs b/frontend/packages/swarm-ui/test/poller.test.mjs new file mode 100644 index 00000000..78e08214 --- /dev/null +++ b/frontend/packages/swarm-ui/test/poller.test.mjs @@ -0,0 +1,94 @@ +// The request discipline of a polled page. Run by `npm test`, which Node runs +// with its built-in test runner and type stripping, so the `.ts` module is +// imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createPoller } from "../src/ui/polled/poller.ts"; + +// A fetch whose answers the test settles by hand, in any order. +function manualFetch() { + const calls = []; + const fetch = (signal) => + new Promise((resolve, reject) => { + calls.push({ signal, resolve, reject }); + }); + return { calls, fetch }; +} + +// Lets the promise callbacks of a settled request run. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function collect() { + const seen = { data: [], error: [] }; + return { + seen, + onData: (d) => seen.data.push(d), + onError: (e) => seen.error.push(e), + }; +} + +test("a tick while a request is in flight asks for nothing", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.tick(); + assert.equal(calls.length, 1); + calls[0].resolve("one"); + await flush(); + assert.deepEqual(sink.seen.data, ["one"]); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("a refetch drops the request in flight, so an older answer never lands", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.refetch(); + assert.equal(calls.length, 2); + assert.equal(calls[0].signal.aborted, true); + calls[1].resolve("new"); + calls[0].resolve("old"); + await flush(); + assert.deepEqual(sink.seen.data, ["new"]); +}); + +test("a stopped poller aborts the request and ignores its answer", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.stop(); + assert.equal(calls[0].signal.aborted, true); + calls[0].resolve("late"); + await flush(); + assert.deepEqual(sink.seen.data, []); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("a failed request reports the error and frees the next tick", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + const boom = new Error("boom"); + calls[0].reject(boom); + await flush(); + assert.deepEqual(sink.seen.error, [boom]); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("the answer of a dropped request that fails is not reported", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.refetch(); + calls[0].reject(new Error("aborted")); + await flush(); + assert.deepEqual(sink.seen.error, []); +});