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:
parent
a639a1ab43
commit
00cca0c903
1 changed files with 94 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue