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:
parent
0ddb504a98
commit
0f52cbe40a
3 changed files with 106 additions and 0 deletions
|
|
@ -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() {
|
|||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={HivesPage} />
|
||||
<Route path="/agents" component={AgentsPage} />
|
||||
<Route path="/create-agent" component={CreateAgentPage} />
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
|
|
|
|||
103
frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
Normal file
103
frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue