swarm-ui: agent roster page with per-agent config-PR status

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.
This commit is contained in:
iris 2026-08-23 22:51:26 +02:00 committed by mara
commit 0f52cbe40a
3 changed files with 106 additions and 0 deletions

View file

@ -2,6 +2,7 @@
// page component under `./pages/`; this file just maps paths to them. // page component under `./pages/`; this file just maps paths to them.
import { Route, Switch } from 'wouter-preact'; import { Route, Switch } from 'wouter-preact';
import { Shell } from './shell/Shell.js'; import { Shell } from './shell/Shell.js';
import { AgentsPage } from './pages/AgentsPage.js';
import { ComponentsPage } from './pages/ComponentsPage.js'; import { ComponentsPage } from './pages/ComponentsPage.js';
import { CreateAgentPage } from './pages/CreateAgentPage.js'; import { CreateAgentPage } from './pages/CreateAgentPage.js';
import { JobsPage } from './pages/JobsPage.js'; import { JobsPage } from './pages/JobsPage.js';
@ -21,6 +22,7 @@ export function App() {
<Shell> <Shell>
<Switch> <Switch>
<Route path="/" component={HivesPage} /> <Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/create-agent" component={CreateAgentPage} /> <Route path="/create-agent" component={CreateAgentPage} />
<Route path="/jobs" component={JobsPage} /> <Route path="/jobs" component={JobsPage} />
<Route path="/components" component={ComponentsPage} /> <Route path="/components" component={ComponentsPage} />

View file

@ -0,0 +1,103 @@
// <AgentsPage> — 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<AgentRow>[] = [
{ key: 'name', header: 'name', render: (a) => a.name },
{
key: 'config-pr',
header: 'config PR',
render: (a) =>
a.configPr ? (
<StatusChip
tone="warning"
label={
a.configPr.html_url ? (
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
#{a.configPr.pr_number}
</a>
) : (
`#${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<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(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<string, ConfigPrStatus>;
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 (
<Panel
title="agents"
icon="👥"
actions={<RefreshIntervalPicker id="agents-refresh" value={intervalMs} onChange={setIntervalMs} />}
>
{error ? <ApiErrorPanel context="failed to load the agent roster" problem={error} /> : null}
{!error && rows === null ? <p>loading</p> : null}
{rows ? <Table columns={COLUMNS} rows={rows} rowKey={(a) => a.name} /> : null}
</Panel>
);
}

View file

@ -37,6 +37,7 @@ import './Shell.css';
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: '/', label: 'hives', accent: 'var(--purple)' }, { href: '/', label: 'hives', accent: 'var(--purple)' },
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
{ href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' }, { href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' },
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' }, { href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
{ href: '/components', label: 'components', accent: 'var(--blue)' }, { href: '/components', label: 'components', accent: 'var(--blue)' },