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.
|
// 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} />
|
||||||
|
|
|
||||||
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 }[] = [
|
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)' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue