From 00cca0c903f8aee5070c7c77674073c47cf8815f Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 2 Sep 2026 10:28:38 +0200 Subject: [PATCH] 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). --- .../swarm-ui/src/pages/AgentsPage.tsx | 107 +++++++++++++++--- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 351ab32d..0282de00 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -1,16 +1,20 @@ // — 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 = { + 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[] = [ { 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 ( + + {text ? `${text} — ` : ''} + {label} + {a.status.last_seen_unix !== null ? ( + <> + {' '} + () + + ) : 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; - 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.