swarm-controller+swarm-ui: merge config-PR into GET /api/agents/status
Per mara's review call on this PR: "the view should be filled by a single backend call." AgentsPage.tsx was doing three fetches (/api/agents, /api/config-prs, /api/agents/status) and joining them client-side by name. Moves the config-PR join server-side instead: AgentStatusRow gains a config_pr field, populated by get_agents_status's handler from AppState::config_prs after agent_status::AgentStatusReader::view() returns - not inside that module, which has no forge client and stays that way (see the field's doc comment for why the handler is the right layer for this merge, not the reader). AgentsPage.tsx now does exactly one fetch and no client-side joining at all - the wire row is the table row. Dropped the separate AgentStatusRow TS interface (folded into AgentRow, which now mirrors the backend type field-for-field) and the /api/agents + /api/config-prs fetches entirely; neither is needed once /api/agents/status already returns every roster agent with its config PR attached. ConfigPrStatus gained Deserialize (previously Serialize-only) since AgentStatusRow derives both and a struct's derive requires every field to support it.
This commit is contained in:
parent
00cca0c903
commit
50650476ad
4 changed files with 69 additions and 61 deletions
|
|
@ -5,16 +5,18 @@
|
|||
// 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.
|
||||
//
|
||||
// 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), `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.
|
||||
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
||||
// /api/agents/status` — one row per roster agent (identity store is the
|
||||
// roster, so a never-reported agent still gets a row), each already
|
||||
// carrying its freshness/snapshot *and* its open config PR if any. That
|
||||
// merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
||||
// `/api/agents/status`) joined client-side by name; per operator review
|
||||
// feedback ("the view should be filled by a single backend call") the
|
||||
// join moved server-side instead — see `swarm-controller/src/main.rs`'s
|
||||
// `get_agents_status` handler for where `config_pr` gets merged in and why
|
||||
// that's the handler's job rather than `agent_status::AgentStatusReader`'s.
|
||||
// No more per-page joining left to do here: the wire row *is* the table
|
||||
// row.
|
||||
//
|
||||
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||
// natural home for the action that populates it; a separate top-level
|
||||
|
|
@ -50,7 +52,10 @@ interface AgentStatusSnapshot {
|
|||
running: boolean;
|
||||
}
|
||||
|
||||
interface AgentStatusRow {
|
||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||
// table row now, not a shape assembled from it, so there's no separate
|
||||
// join-result type to keep in sync with the wire contract by hand.
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
hive: string | null;
|
||||
freshness: Freshness;
|
||||
|
|
@ -59,12 +64,7 @@ interface AgentStatusRow {
|
|||
// `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;
|
||||
config_pr: ConfigPrStatus | null;
|
||||
}
|
||||
|
||||
// Same tone/label pairing as HivesPage — one freshness enum shared by both
|
||||
|
|
@ -78,14 +78,13 @@ const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
|||
|
||||
const COLUMNS: TableColumn<AgentRow>[] = [
|
||||
{ key: 'name', header: 'name', render: (a) => a.name },
|
||||
{ key: 'hive', header: 'hive', render: (a) => a.status?.hive ?? '—' },
|
||||
{ key: 'hive', header: 'hive', render: (a) => a.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;
|
||||
const { tone, label } = FRESHNESS[a.freshness];
|
||||
const text = a.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
|
||||
|
|
@ -97,10 +96,10 @@ const COLUMNS: TableColumn<AgentRow>[] = [
|
|||
<>
|
||||
{text ? `${text} — ` : ''}
|
||||
{label}
|
||||
{a.status.last_seen_unix !== null ? (
|
||||
{a.last_seen_unix !== null ? (
|
||||
<>
|
||||
{' '}
|
||||
(<RelativeTime epochMs={a.status.last_seen_unix * 1000} />)
|
||||
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -113,16 +112,16 @@ const COLUMNS: TableColumn<AgentRow>[] = [
|
|||
key: 'config-pr',
|
||||
header: 'config PR',
|
||||
render: (a) =>
|
||||
a.configPr ? (
|
||||
a.config_pr ? (
|
||||
<Badge
|
||||
tone="warning"
|
||||
value={
|
||||
a.configPr.html_url ? (
|
||||
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
|
||||
#{a.configPr.pr_number}
|
||||
a.config_pr.html_url ? (
|
||||
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
||||
#{a.config_pr.pr_number}
|
||||
</a>
|
||||
) : (
|
||||
`#${a.configPr.pr_number}`
|
||||
`#${a.config_pr.pr_number}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
@ -145,34 +144,12 @@ export function AgentsPage() {
|
|||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
(async () => {
|
||||
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));
|
||||
const res = await fetch('/api/agents/status');
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
if (!prsRes.ok) {
|
||||
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>;
|
||||
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,
|
||||
})),
|
||||
);
|
||||
setRows((await res.json()) as AgentRow[]);
|
||||
// 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