swarm-ui: add agent start/stop, backed by the wanted-state route
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.
This commit is contained in:
parent
d27cf6ce3e
commit
c9d8628794
3 changed files with 278 additions and 89 deletions
|
|
@ -1,28 +1,32 @@
|
||||||
// <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 and swarm-wide health status in the same table. Three
|
// config-PR status, swarm-wide health status, and declared wanted state
|
||||||
// separate asks (a roster listing, a per-agent config-PR indicator, and
|
// in the same table. Three separate asks (a roster listing, a per-agent
|
||||||
// per-agent status/liveness) that turned out to be one page: a roster with
|
// config-PR indicator, and per-agent status/liveness) that turned out to
|
||||||
// no per-row detail is thin, and neither a config-PR panel nor a status
|
// be one page: a roster with no per-row detail is thin, and neither a
|
||||||
// panel has anything to render against without a roster to embed in.
|
// 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
|
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
||||||
// /api/agents/status` — one row per roster agent (identity store is the
|
// /api/agents/status` — one row per roster agent (identity store is the
|
||||||
// roster, so a never-reported agent still gets a row), each already
|
// roster, so a never-reported agent still gets a row), each already
|
||||||
// carrying its freshness/snapshot *and* its open config PR if any. That
|
// carrying its freshness/snapshot/config PR/wanted state. That merge
|
||||||
// merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
// used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
||||||
// `/api/agents/status`) joined client-side by name; per operator review
|
// `/api/agents/status`) joined client-side by name; per operator review
|
||||||
// feedback ("the view should be filled by a single backend call") the
|
// feedback ("the view should be filled by a single backend call") the
|
||||||
// join moved server-side instead — see `swarm-controller/src/main.rs`'s
|
// 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
|
// `get_agents_status` handler for where `config_pr`/`wanted` get merged
|
||||||
// that's the handler's job rather than `agent_status::AgentStatusReader`'s.
|
// in and why that's the handler's job rather than
|
||||||
// No more per-page joining left to do here: the wire row *is* the table
|
// `agent_status::AgentStatusReader`'s. No more per-page joining left to
|
||||||
// row.
|
// do here: the wire row *is* the table row.
|
||||||
//
|
//
|
||||||
// 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
|
||||||
// nav entry would be one click of indirection for no benefit. The form
|
// nav entry would be one click of indirection for no benefit. The form
|
||||||
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
||||||
// its own route.
|
// 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 { 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";
|
||||||
|
|
@ -52,6 +56,12 @@ interface AgentStatusSnapshot {
|
||||||
running: boolean;
|
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
|
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||||
// table row now, not a shape assembled from it, so there's no separate
|
// 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.
|
// 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.
|
// rather than rendering a once-computed-at-fetch value.
|
||||||
snapshot: AgentStatusSnapshot | null;
|
snapshot: AgentStatusSnapshot | null;
|
||||||
config_pr: ConfigPrStatus | null;
|
config_pr: ConfigPrStatus | null;
|
||||||
|
wanted: Wanted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same tone/label pairing as HivesPage — one freshness enum shared by both
|
// Same tone/label pairing as HivesPage — one freshness enum shared by both
|
||||||
|
|
@ -76,61 +87,6 @@ const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||||
unknown: { tone: "negative", label: "unknown" },
|
unknown: { tone: "negative", label: "unknown" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: TableColumn<AgentRow>[] = [
|
|
||||||
{ 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 (
|
|
||||||
<Badge
|
|
||||||
tone={tone}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{text ? `${text} — ` : ""}
|
|
||||||
{label}
|
|
||||||
{a.last_seen_unix !== null ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "config-pr",
|
|
||||||
header: "config PR",
|
|
||||||
render: (a) =>
|
|
||||||
a.config_pr ? (
|
|
||||||
<Badge
|
|
||||||
tone="warning"
|
|
||||||
value={
|
|
||||||
a.config_pr.html_url ? (
|
|
||||||
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
|
||||||
#{a.config_pr.pr_number}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
`#${a.config_pr.pr_number}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
// 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
|
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||||
// solve staleness rather than require an opt-in every visit.
|
// solve staleness rather than require an opt-in every visit.
|
||||||
|
|
@ -142,21 +98,199 @@ export function AgentsPage() {
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
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<ReadonlySet<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [actionErrors, setActionErrors] = useState<
|
||||||
|
ReadonlyMap<string, ProblemDetails>
|
||||||
|
>(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, () => {
|
useRefreshInterval(intervalMs, () => {
|
||||||
(async () => {
|
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||||
const res = await fetch("/api/agents/status");
|
});
|
||||||
|
|
||||||
|
// 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) {
|
if (!res.ok) {
|
||||||
setError(await readApiError(res));
|
const problem = await readApiError(res);
|
||||||
|
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRows((await res.json()) as AgentRow[]);
|
// The PUT response is the hive's whole declaration set (every
|
||||||
// A refresh that succeeds clears a previous failure — otherwise a
|
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
||||||
// transient error would sit on screen forever after the data itself
|
// returns — so patch just that field into the rows already on
|
||||||
// has recovered.
|
// screen rather than firing a second, full `/api/agents/status`
|
||||||
setError(null);
|
// round-trip merely to learn one hive's `wanted` column changed.
|
||||||
})().catch((e: unknown) => setError({ detail: String(e) }));
|
// 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<AgentRow>[] = [
|
||||||
|
{ 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 (
|
||||||
|
<Badge
|
||||||
|
tone={tone}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{text ? `${text} — ` : ""}
|
||||||
|
{label}
|
||||||
|
{a.last_seen_unix !== null ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
||||||
|
</>
|
||||||
|
) : 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 (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
tone={tone}
|
||||||
|
value={pending ? "…" : (a.wanted ?? "no declaration")}
|
||||||
|
onClick={a.hive ? () => 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 ? (
|
||||||
|
<Badge
|
||||||
|
tone="negative"
|
||||||
|
value="failed"
|
||||||
|
title={err.detail ?? "the declaration failed"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "config-pr",
|
||||||
|
header: "config PR",
|
||||||
|
render: (a) =>
|
||||||
|
a.config_pr ? (
|
||||||
|
<Badge
|
||||||
|
tone="warning"
|
||||||
|
value={
|
||||||
|
a.config_pr.html_url ? (
|
||||||
|
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
||||||
|
#{a.config_pr.pr_number}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`#${a.config_pr.pr_number}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -184,7 +318,7 @@ export function AgentsPage() {
|
||||||
{!error && rows === null ? <p>loading…</p> : null}
|
{!error && rows === null ? <p>loading…</p> : null}
|
||||||
{rows ? (
|
{rows ? (
|
||||||
<Table
|
<Table
|
||||||
columns={COLUMNS}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
rowKey={(a) => a.name}
|
rowKey={(a) => a.name}
|
||||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,15 @@ pub struct AgentStatusRow {
|
||||||
/// here, is what makes this the single call swarm-ui's agent roster
|
/// here, is what makes this the single call swarm-ui's agent roster
|
||||||
/// page needs.
|
/// page needs.
|
||||||
pub config_pr: Option<crate::forge::ConfigPrStatus>,
|
pub config_pr: Option<crate::forge::ConfigPrStatus>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A snapshot as retained by the bucket, with the hive it was published
|
/// A snapshot as retained by the bucket, with the hive it was published
|
||||||
|
|
@ -176,6 +185,7 @@ fn render(
|
||||||
age_seconds: None,
|
age_seconds: None,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
config_pr: None,
|
config_pr: None,
|
||||||
|
wanted: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -219,6 +229,7 @@ fn row(
|
||||||
age_seconds: age.map(|age| age.as_secs()),
|
age_seconds: age.map(|age| age.as_secs()),
|
||||||
snapshot: offered.payload.clone(),
|
snapshot: offered.payload.clone(),
|
||||||
config_pr: None,
|
config_pr: None,
|
||||||
|
wanted: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -889,22 +889,25 @@ async fn get_hives_status(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What each agent last said about itself, plus its open config PR if any,
|
/// What each agent last said about itself, its open config PR if any, and
|
||||||
/// read at request time — the single call swarm-ui's agent roster page
|
/// its declared wanted state if any, read at request time — the single
|
||||||
/// fills its whole table from (mara, 2026-09-02: "the view should be
|
/// call swarm-ui's agent roster page fills its whole table from (mara,
|
||||||
/// filled by a single backend call").
|
/// 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
|
/// 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
|
/// 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`
|
/// comes from its own bucket key rather than a second lookup. `config_pr`
|
||||||
/// is merged in here rather than inside `agent_status::AgentStatusReader`:
|
/// and `wanted` are both merged in here rather than inside
|
||||||
/// that module has no forge client and is not the place to add one just
|
/// `agent_status::AgentStatusReader`: that module has no forge client or
|
||||||
/// for this one field, whereas this handler already holds both a
|
/// wanted-state writer and is not the place to add one just for a single
|
||||||
/// roster-shaped status view and `AppState::config_prs` — the one spot
|
/// field, whereas this handler already holds `AppState::config_prs` and
|
||||||
/// that has both without a second, avoidable dependency. Absent on every
|
/// `AppState::wanted` alongside the roster-shaped status view — the one
|
||||||
/// row when no forge is configured here, same "absence is the answer"
|
/// spot that has all three without adding an avoidable dependency to a
|
||||||
/// shape `get_config_prs` uses — not a reason to fail the whole response
|
/// reader that stays deliberately narrow. Both fields are absent on rows
|
||||||
/// over a field that already knows how to be missing.
|
/// 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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/agents/status",
|
path = "/api/agents/status",
|
||||||
|
|
@ -940,6 +943,47 @@ async fn get_agents_status(
|
||||||
row.config_pr = config_prs.remove(&row.name);
|
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<String> =
|
||||||
|
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))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue