swarm-ui: merge per-agent status into AgentsPage

Continues #3341 item 3, unblocked now that #3568/#3569 (items 1/2)
are merged and GET /api/agents/status is live.

Third fetch alongside the existing roster + config-PR ones, joined
client-side by name same as the config-PR merge. Adds a hive column
and a status column (freshness badge + status_text + relative-time,
same rendering AgentsPage's sibling HivesPage already uses for the
hive-level status endpoint).
This commit is contained in:
iris 2026-09-02 10:28:38 +02:00 committed by mara
commit 00cca0c903

View file

@ -1,16 +1,20 @@
// <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.
// config-PR status and swarm-wide health status in the same table. Three
// separate asks (a roster listing, a per-agent config-PR indicator, and
// per-agent status/liveness) that turned out to be one page: a roster with
// no per-row detail is thin, and neither a config-PR panel nor a status
// panel has anything to render against without a roster to embed in.
//
// Two fetches, both on a refresh-interval cadence like HivesPage: `GET
// Three fetches, all 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.
// deliberately says nothing about health), `GET /api/config-prs` (agent
// name -> open PR, only agents with one present), and `GET
// /api/agents/status` (one row per roster agent, freshness derived at read
// time — same shape and same roster-not-bucket rule as HivesPage's
// `/api/hives/status`, see `swarm-controller/src/agent_status.rs`). Merged
// client-side into one row per agent rather than N per-agent calls —
// exactly why the bulk endpoints exist instead of looping a single-agent
// one.
//
// Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it; a separate top-level
@ -20,10 +24,11 @@
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 { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Button } from '../ui/button/Button.js';
import { Dialog } from '../ui/dialog/Dialog.js';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import {
RefreshIntervalPicker,
useRefreshInterval,
@ -37,13 +42,73 @@ interface ConfigPrStatus {
html_url: string | null;
}
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
interface AgentStatusRow {
name: string;
hive: string | null;
freshness: Freshness;
last_seen_unix: number | null;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
snapshot: AgentStatusSnapshot | null;
}
interface AgentRow {
name: string;
configPr: ConfigPrStatus | null;
status: AgentStatusRow | null;
}
// Same tone/label pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same rendering rule applies to both pages.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: 'positive', label: 'fresh' },
stale: { tone: 'warning', label: 'stale' },
never_reported: { tone: 'neutral', label: 'never reported' },
unknown: { tone: 'negative', label: 'unknown' },
};
const COLUMNS: TableColumn<AgentRow>[] = [
{ key: 'name', header: 'name', render: (a) => a.name },
{ key: 'hive', header: 'hive', render: (a) => a.status?.hive ?? '—' },
{
key: 'status',
header: 'status',
render: (a) => {
if (!a.status) return '—';
const { tone, label } = FRESHNESS[a.status.freshness];
const text = a.status.snapshot?.status_text;
// A `running: false` snapshot always carries `status_text: null`
// (the wire contract's own rule, not something this page derives),
// so an agent that reported recently but isn't running still shows
// a bare freshness badge rather than a stale status string.
return (
<Badge
tone={tone}
value={
<>
{text ? `${text}` : ''}
{label}
{a.status.last_seen_unix !== null ? (
<>
{' '}
(<RelativeTime epochMs={a.status.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
);
},
},
{
key: 'config-pr',
header: 'config PR',
@ -80,7 +145,11 @@ export function AgentsPage() {
useRefreshInterval(intervalMs, () => {
(async () => {
const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]);
const [namesRes, prsRes, statusRes] = await Promise.all([
fetch('/api/agents'),
fetch('/api/config-prs'),
fetch('/api/agents/status'),
]);
if (!namesRes.ok) {
setError(await readApiError(namesRes));
return;
@ -89,9 +158,21 @@ export function AgentsPage() {
setError(await readApiError(prsRes));
return;
}
if (!statusRes.ok) {
setError(await readApiError(statusRes));
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 })));
const statuses = (await statusRes.json()) as AgentStatusRow[];
const statusByName = new Map(statuses.map((s) => [s.name, s]));
setRows(
names.map((name) => ({
name,
configPr: prs[name] ?? null,
status: statusByName.get(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.