From c9d8628794b88f996b620dcbafd72cbbedd35e61 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 2 Sep 2026 19:41:37 +0200 Subject: [PATCH] swarm-ui: add agent start/stop, backed by the wanted-state route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyperhive#3896. The backend for start/stop (Up/Offline wanted-state declarations) already existed and was merged (#3905's writer, the PUT /api/hives/{hive}/agents/{agent}/state route) — nothing here was waiting on Paused/Destroyed, which I'd mistakenly conflated with this issue in an earlier comment (that's #3803, a different feature). swarm-controller: merges each row's declared wanted state into GET /api/agents/status, same shape as the config_pr merge (one read per distinct hive, not per agent, since a declaration is a hive's whole agent map). swarm-ui: AgentsPage gets a "wanted" column — clicking the current- state badge toggles it (Badge's own chip-plus-control shape, same as its own header comment's pause/resume example), backed by the PUT route above. A row with no declaration yet reads its implied current state off the agent's own last-reported running flag. Stop asks for confirmation (native window.confirm — no confirm-dialog component exists in swarm-ui yet); start doesn't. --- .../swarm-ui/src/pages/AgentsPage.tsx | 288 +++++++++++++----- swarm-controller/src/agent_status.rs | 11 + swarm-controller/src/main.rs | 68 ++++- 3 files changed, 278 insertions(+), 89 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 3702c426..de965942 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -1,28 +1,32 @@ // — the swarm's agent roster, merged with each agent's open -// 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. +// config-PR status, swarm-wide health status, and declared wanted state +// 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. // // 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`, +// carrying its freshness/snapshot/config PR/wanted state. 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. +// `get_agents_status` handler for where `config_pr`/`wanted` get 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 // nav entry would be one click of indirection for no benefit. The form // itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than // its own route. +// +// The "wanted" column is the start/stop control — see its own `render` +// and `toggleWanted` below for how. import { useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; @@ -52,6 +56,12 @@ interface AgentStatusSnapshot { running: boolean; } +// `"up"` / `"offline"`, wire-spelled by `swarm_queue_client::wanted::AgentState::as_str` +// — kept as a bare `string | null` rather than a union, same reason +// `config_pr`'s shape isn't re-derived here: this page renders whatever +// the wire sends, it doesn't validate the enum client-side. +type Wanted = string | null; + // 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. @@ -65,6 +75,7 @@ interface AgentRow { // rather than rendering a once-computed-at-fetch value. snapshot: AgentStatusSnapshot | null; config_pr: ConfigPrStatus | null; + wanted: Wanted; } // Same tone/label pairing as HivesPage — one freshness enum shared by both @@ -76,61 +87,6 @@ const FRESHNESS: Record = { unknown: { tone: "negative", label: "unknown" }, }; -const COLUMNS: TableColumn[] = [ - { key: "name", header: "name", render: (a) => a.name }, - { key: "hive", header: "hive", render: (a) => a.hive ?? "—" }, - { - key: "status", - header: "status", - render: (a) => { - 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 - // a bare freshness badge rather than a stale status string. - return ( - - {text ? `${text} — ` : ""} - {label} - {a.last_seen_unix !== null ? ( - <> - {" "} - () - - ) : null} - - } - /> - ); - }, - }, - { - key: "config-pr", - header: "config PR", - render: (a) => - a.config_pr ? ( - - #{a.config_pr.pr_number} - - ) : ( - `#${a.config_pr.pr_number}` - ) - } - /> - ) : ( - "—" - ), - }, -]; - // Same 30s default + same reasoning as HivesPage: no inputs on this page // for a refresh to clobber, so the out-of-the-box behaviour should just // solve staleness rather than require an opt-in every visit. @@ -142,21 +98,199 @@ export function AgentsPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const [createOpen, setCreateOpen] = useState(false); + // Per-agent, not one page-wide flag: one row's declare-in-flight + // shouldn't disable every other row's button. + const [pendingAgents, setPendingAgents] = useState>( + new Set(), + ); + const [actionErrors, setActionErrors] = useState< + ReadonlyMap + >(new Map()); + + async function refresh() { + const res = await fetch("/api/agents/status"); + if (!res.ok) { + setError(await readApiError(res)); + return; + } + 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. + setError(null); + } useRefreshInterval(intervalMs, () => { - (async () => { - const res = await fetch("/api/agents/status"); + refresh().catch((e: unknown) => setError({ detail: String(e) })); + }); + + // Declares the opposite of `row`'s current state. A row with no + // declaration yet (`wanted === null`) has nothing to flip, so the + // target is read off the agent's own last-reported `running` instead + // — the button's first click always means "make the declaration match + // reality, then flip it", which is the only reading that makes sense + // without a declaration to toggle. + async function toggleWanted(row: AgentRow) { + if (!row.hive) return; + const impliedCurrent = + row.wanted ?? (row.snapshot?.running ? "up" : "offline"); + const target = impliedCurrent === "up" ? "offline" : "up"; + if ( + target === "offline" && + // Native confirm, not a `Dialog` — no confirm-dialog component + // exists in swarm-ui yet, and stop is the one disruptive direction + // here (start isn't gated). A swarm-level agent destroy, unlike + // this reversible declare, is the irreversible case that will + // justify a real one. + !window.confirm( + `Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`, + ) + ) { + return; + } + setPendingAgents((prev) => new Set(prev).add(row.name)); + setActionErrors((prev) => { + const next = new Map(prev); + next.delete(row.name); + return next; + }); + try { + const res = await fetch( + `/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: target }), + }, + ); if (!res.ok) { - setError(await readApiError(res)); + const problem = await readApiError(res); + setActionErrors((prev) => new Map(prev).set(row.name, problem)); return; } - 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. - setError(null); - })().catch((e: unknown) => setError({ detail: String(e) })); - }); + // The PUT response is the hive's whole declaration set (every + // agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted` + // returns — so patch just that field into the rows already on + // screen rather than firing a second, full `/api/agents/status` + // round-trip merely to learn one hive's `wanted` column changed. + // Everything else on the grid (freshness, snapshot, config PR) + // still catches up on the next interval-driven `refresh()`. + const declared = (await res.json()) as { agent: string; state: string }[]; + setRows((prev) => { + if (!prev) return prev; + const byAgent = new Map(declared.map((d) => [d.agent, d.state])); + return prev.map((r) => + r.hive === row.hive && byAgent.has(r.name) + ? { ...r, wanted: byAgent.get(r.name) ?? r.wanted } + : r, + ); + }); + } catch (e: unknown) { + setActionErrors((prev) => + new Map(prev).set(row.name, { detail: String(e) }), + ); + } finally { + setPendingAgents((prev) => { + const next = new Set(prev); + next.delete(row.name); + return next; + }); + } + } + + const columns: TableColumn[] = [ + { key: "name", header: "name", render: (a) => a.name }, + { key: "hive", header: "hive", render: (a) => a.hive ?? "—" }, + { + key: "status", + header: "status", + render: (a) => { + 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 + // a bare freshness badge rather than a stale status string. + return ( + + {text ? `${text} — ` : ""} + {label} + {a.last_seen_unix !== null ? ( + <> + {" "} + () + + ) : null} + + } + /> + ); + }, + }, + { + key: "wanted", + header: "wanted", + // The start/stop control: clicking the current-declared-state + // badge toggles it — `Badge`'s own "chip plus, optionally, the + // control" shape (see its header comment, which names pause/resume + // as the exact motivating case), not a separate status chip next + // to a separate button. + render: (a) => { + const pending = pendingAgents.has(a.name); + const impliedCurrent = + a.wanted ?? (a.snapshot?.running ? "up" : "offline"); + const actionLabel = impliedCurrent === "up" ? "stop" : "start"; + const tone: BadgeTone = a.wanted === "up" ? "positive" : "neutral"; + const err = actionErrors.get(a.name); + return ( + <> + void toggleWanted(a) : undefined} + disabled={pending || !a.hive} + title={ + a.hive + ? `click to ${actionLabel} ${a.name}` + : "no hive on record for this agent — nothing to declare against" + } + /> + {err ? ( + + ) : null} + + ); + }, + }, + { + key: "config-pr", + header: "config PR", + render: (a) => + a.config_pr ? ( + + #{a.config_pr.pr_number} + + ) : ( + `#${a.config_pr.pr_number}` + ) + } + /> + ) : ( + "—" + ), + }, + ]; return ( loading…

: null} {rows ? ( a.name} emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" diff --git a/swarm-controller/src/agent_status.rs b/swarm-controller/src/agent_status.rs index 2997debf..ee355206 100644 --- a/swarm-controller/src/agent_status.rs +++ b/swarm-controller/src/agent_status.rs @@ -56,6 +56,15 @@ pub struct AgentStatusRow { /// here, is what makes this the single call swarm-ui's agent roster /// page needs. pub config_pr: Option, + /// The agent's declared wanted state (`"up"`/`"offline"`), if this + /// hive has one on record. Same rule as `config_pr`: always `None` + /// out of this reader, filled in by the `GET /api/agents/status` + /// handler from `AppState::wanted` — this module has no notion of a + /// *declaration* (a swarm-level intent), only of what an agent last + /// *reported about itself*, and merging a second bucket's read in + /// here would blur that boundary for the same reason `config_pr` + /// doesn't merge forge state in directly. + pub wanted: Option, } /// A snapshot as retained by the bucket, with the hive it was published @@ -176,6 +185,7 @@ fn render( age_seconds: None, snapshot: None, config_pr: None, + wanted: None, }, }) .collect(); @@ -219,6 +229,7 @@ fn row( age_seconds: age.map(|age| age.as_secs()), snapshot: offered.payload.clone(), config_pr: None, + wanted: None, } } diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index ed8dd00a..37e7c743 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -889,22 +889,25 @@ async fn get_hives_status( } } -/// What each agent last said about itself, plus its open config PR if any, -/// read at request time — the single call swarm-ui's agent roster page -/// fills its whole table from (mara, 2026-09-02: "the view should be -/// filled by a single backend call"). +/// What each agent last said about itself, its open config PR if any, and +/// its declared wanted state if any, read at request time — the single +/// call swarm-ui's agent roster page fills its whole table from (mara, +/// 2026-09-02: "the view should be filled by a single backend call"). /// /// Every agent in the roster gets a row whether or not it has ever /// reported — see the `agent_status` module for why, and for why its hive /// comes from its own bucket key rather than a second lookup. `config_pr` -/// is merged in here rather than inside `agent_status::AgentStatusReader`: -/// that module has no forge client and is not the place to add one just -/// for this one field, whereas this handler already holds both a -/// roster-shaped status view and `AppState::config_prs` — the one spot -/// that has both without a second, avoidable dependency. Absent on every -/// row when no forge is configured here, same "absence is the answer" -/// shape `get_config_prs` uses — not a reason to fail the whole response -/// over a field that already knows how to be missing. +/// and `wanted` are both merged in here rather than inside +/// `agent_status::AgentStatusReader`: that module has no forge client or +/// wanted-state writer and is not the place to add one just for a single +/// field, whereas this handler already holds `AppState::config_prs` and +/// `AppState::wanted` alongside the roster-shaped status view — the one +/// spot that has all three without adding an avoidable dependency to a +/// reader that stays deliberately narrow. Both fields are absent on rows +/// where the underlying store isn't configured or has nothing on record, +/// same "absence is the answer" shape `get_config_prs` uses — not a +/// reason to fail the whole response over a field that already knows how +/// to be missing. #[utoipa::path( get, path = "/api/agents/status", @@ -940,6 +943,47 @@ async fn get_agents_status( row.config_pr = config_prs.remove(&row.name); } } + // `wanted` merges in the same way and for the same reason as + // `config_pr` above — one read per distinct hive, not one per + // agent, since a declaration is already a hive's whole agent + // map. + if let Some(writer) = state.wanted.as_ref() { + // Owned `String` keys, not `&str` borrowed from `rows` — + // `declarations` outlives the loop below that needs `rows` + // mutably, so it cannot hold a live borrow into it. + let hives: std::collections::BTreeSet = + rows.iter().filter_map(|r| r.hive.clone()).collect(); + let mut declarations: std::collections::HashMap< + String, + swarm_queue_client::wanted::HiveWanted, + > = std::collections::HashMap::new(); + for hive in hives { + match writer.view(&hive).await { + Ok(Some(declaration)) => { + declarations.insert(hive, declaration); + } + Ok(None) => {} + Err(e) => { + // Missing wanted state for one hive shouldn't + // fail the whole roster — same "absence is + // survivable" rule `config_pr` follows above. + tracing::warn!( + hive, error = %format!("{e:#}"), + "reading the wanted declaration failed; its agents show no wanted state" + ); + } + } + } + for row in &mut rows { + row.wanted = row.hive.as_deref().and_then(|hive| { + declarations + .get(hive)? + .agents + .get(&row.name) + .map(|w| w.state.as_str().to_owned()) + }); + } + } Ok(Json(rows)) } Err(e) => {