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
|
// <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,
|
// config-PR status and swarm-wide health status in the same table. Three
|
||||||
// and a per-agent config-PR indicator) that turned out to be one page: a
|
// separate asks (a roster listing, a per-agent config-PR indicator, and
|
||||||
// roster with no per-row detail is thin, and a config-PR panel with no
|
// per-agent status/liveness) that turned out to be one page: a roster with
|
||||||
// roster to embed it in has nothing to render against.
|
// 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
|
// /api/agents` (just names — the identity store is the roster, and
|
||||||
// deliberately says nothing about health) and `GET /api/config-prs`
|
// deliberately says nothing about health), `GET /api/config-prs` (agent
|
||||||
// (agent name -> open PR, only agents with one present). Merged
|
// name -> open PR, only agents with one present), and `GET
|
||||||
// client-side into one row per agent rather than N per-agent config-PR
|
// /api/agents/status` (one row per roster agent, freshness derived at read
|
||||||
// calls — exactly why the bulk endpoint exists instead of looping the
|
// time — same shape and same roster-not-bucket rule as HivesPage's
|
||||||
// single-agent one.
|
// `/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
|
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||||
// natural home for the action that populates it; a separate top-level
|
// natural home for the action that populates it; a separate top-level
|
||||||
|
|
@ -20,10 +24,11 @@
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.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 { Button } from '../ui/button/Button.js';
|
||||||
import { Dialog } from '../ui/dialog/Dialog.js';
|
import { Dialog } from '../ui/dialog/Dialog.js';
|
||||||
import { Panel } from '../ui/panel/Panel.js';
|
import { Panel } from '../ui/panel/Panel.js';
|
||||||
|
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
useRefreshInterval,
|
useRefreshInterval,
|
||||||
|
|
@ -37,13 +42,73 @@ interface ConfigPrStatus {
|
||||||
html_url: string | null;
|
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 {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
configPr: ConfigPrStatus | null;
|
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>[] = [
|
const COLUMNS: TableColumn<AgentRow>[] = [
|
||||||
{ key: 'name', header: 'name', render: (a) => a.name },
|
{ 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',
|
key: 'config-pr',
|
||||||
header: 'config PR',
|
header: 'config PR',
|
||||||
|
|
@ -80,7 +145,11 @@ export function AgentsPage() {
|
||||||
|
|
||||||
useRefreshInterval(intervalMs, () => {
|
useRefreshInterval(intervalMs, () => {
|
||||||
(async () => {
|
(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) {
|
if (!namesRes.ok) {
|
||||||
setError(await readApiError(namesRes));
|
setError(await readApiError(namesRes));
|
||||||
return;
|
return;
|
||||||
|
|
@ -89,9 +158,21 @@ export function AgentsPage() {
|
||||||
setError(await readApiError(prsRes));
|
setError(await readApiError(prsRes));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!statusRes.ok) {
|
||||||
|
setError(await readApiError(statusRes));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const names = (await namesRes.json()) as string[];
|
const names = (await namesRes.json()) as string[];
|
||||||
const prs = (await prsRes.json()) as Record<string, ConfigPrStatus>;
|
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
|
// A refresh that succeeds clears a previous failure — otherwise a
|
||||||
// transient error would sit on screen forever after the data itself
|
// transient error would sit on screen forever after the data itself
|
||||||
// has recovered.
|
// has recovered.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue