From 0f52cbe40a733f4f30916f194b183e9416c826cd Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 23 Aug 2026 22:51:26 +0200 Subject: [PATCH] swarm-ui: agent roster page with per-agent config-PR status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AgentsPage at /agents: fetches GET /api/agents (roster names) and GET /api/config-prs (bulk config-PR status) and merges them into one table, one row per agent. Reuses the existing Panel/Table/StatusChip/ RefreshIntervalPicker components exactly as HivesPage does — the roster page and the config-PR panel turned out to be the same page rather than two separate pieces of UI. Adds a nav entry (green accent, the next unused base16 chromatic slot) between hives and new agent. --- frontend/packages/swarm-ui/src/App.tsx | 2 + .../swarm-ui/src/pages/AgentsPage.tsx | 103 ++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 1 + 3 files changed, 106 insertions(+) create mode 100644 frontend/packages/swarm-ui/src/pages/AgentsPage.tsx diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index ec28e0c0..b97634a3 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -2,6 +2,7 @@ // page component under `./pages/`; this file just maps paths to them. import { Route, Switch } from 'wouter-preact'; import { Shell } from './shell/Shell.js'; +import { AgentsPage } from './pages/AgentsPage.js'; import { ComponentsPage } from './pages/ComponentsPage.js'; import { CreateAgentPage } from './pages/CreateAgentPage.js'; import { JobsPage } from './pages/JobsPage.js'; @@ -21,6 +22,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx new file mode 100644 index 00000000..08fee821 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -0,0 +1,103 @@ +// — the swarm's agent roster, merged with each agent's open +// config-PR status in the same table. Two separate asks (a roster listing, +// and a per-agent config-PR indicator) that turned out to be one page: a +// roster with no per-row detail is thin, and a config-PR panel with no +// roster to embed it in has nothing to render against. +// +// Two fetches, both on a refresh-interval cadence like HivesPage: `GET +// /api/agents` (just names — the identity store is the roster, and +// deliberately says nothing about health) and `GET /api/config-prs` +// (agent name -> open PR, only agents with one present). Merged +// client-side into one row per agent rather than N per-agent config-PR +// calls — exactly why the bulk endpoint exists instead of looping the +// single-agent one. +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 { + RefreshIntervalPicker, + useRefreshInterval, + type RefreshIntervalMs, +} from '../ui/refresh-interval/RefreshInterval.js'; +import { StatusChip } from '../ui/status-chip/StatusChip.js'; +import { Table, type TableColumn } from '../ui/table/Table.js'; + +interface ConfigPrStatus { + pr_number: number; + html_url: string | null; +} + +interface AgentRow { + name: string; + configPr: ConfigPrStatus | null; +} + +const COLUMNS: TableColumn[] = [ + { key: 'name', header: 'name', render: (a) => a.name }, + { + key: 'config-pr', + header: 'config PR', + render: (a) => + a.configPr ? ( + + #{a.configPr.pr_number} + + ) : ( + `#${a.configPr.pr_number}` + ) + } + /> + ) : ( + '—' + ), + }, +]; + +// Same 30s default + same reasoning as HivesPage: no inputs on this page +// for a refresh to clobber, so the out-of-the-box behaviour should just +// solve staleness rather than require an opt-in every visit. +const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; + +export function AgentsPage() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + + useRefreshInterval(intervalMs, () => { + (async () => { + const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]); + if (!namesRes.ok) { + setError(await readApiError(namesRes)); + return; + } + if (!prsRes.ok) { + setError(await readApiError(prsRes)); + return; + } + const names = (await namesRes.json()) as string[]; + const prs = (await prsRes.json()) as Record; + setRows(names.map((name) => ({ name, configPr: prs[name] ?? null }))); + // 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 ( + } + > + {error ? : null} + {!error && rows === null ?

loading…

: null} + {rows ? a.name} /> : null} + + ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index eb6ac85a..a87fab36 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -37,6 +37,7 @@ import './Shell.css'; const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: '/', label: 'hives', accent: 'var(--purple)' }, + { href: '/agents', label: 'agents', accent: 'var(--green)' }, { href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' }, { href: '/jobs', label: 'jobs', accent: 'var(--pink)' }, { href: '/components', label: 'components', accent: 'var(--blue)' },