From 89b20a5a507e78ddad0559adbb05b82284a7c2d8 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 00:03:36 +0200 Subject: [PATCH] swarm-ui: a broadcast page that sends one message to several agents Pick agents from a grid of their icons (running or all), type the text, send. The page posts the names and the text to the controller and shows what happened to each agent, with a retry that selects just the ones that failed. The selection rules and the controller's answer are read by plain functions with tests. --- docs/swarm/ui.md | 20 ++ frontend/packages/swarm-ui/src/App.tsx | 2 + .../src/pages/broadcast/BroadcastPage.css | 166 ++++++++++ .../src/pages/broadcast/BroadcastPage.tsx | 310 ++++++++++++++++++ .../src/pages/broadcast/broadcast-types.ts | 100 ++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 1 + .../swarm-ui/test/broadcast-types.test.mjs | 96 ++++++ 7 files changed, 695 insertions(+) create mode 100644 frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/broadcast/broadcast-types.ts create mode 100644 frontend/packages/swarm-ui/test/broadcast-types.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 79d88353..0b4a77de 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -17,6 +17,7 @@ domain, covers host-level detail for one hive. | `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | +| `/broadcast` | send one message to several agents at once, picked from a grid of their icons | | `/issues` | a cross-repo issue report | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | @@ -291,6 +292,25 @@ The **Details** link on a `swarm-builder` commit status opens this page as `/builds?run=`, with that build's card open and in view. The builder keeps its newest 100 builds, so a link to an older one shows a note instead. +### Broadcasting a message + +The **broadcast** page sends one message to several agents at once. Pick the +agents from a grid of their icons. **running** shows the agents that report +themselves running and **all** shows every agent; the browser keeps the choice. +**select all** ticks every agent the filter shows, and **clear** unticks them. +The page skips an agent you ticked and then hid with the filter. Type the +message and press **send to N agents**. The text goes exactly as typed, as an +inbox message from your authelia username, the same as the box under an agent's +terminal. One broadcast may name at most 64 agents, and the button stays off +until you untick the extra ones. A broadcast to many agents can take a few +seconds to answer, and the button reads **sending…** meanwhile. + +The page then lists what happened to each agent. An agent that wasn't +listening shows as not sent, with the reason, and the others still got the +message. **select the N that failed** ticks just those so you can send the same +message again. When every agent got the message the box clears; otherwise the +text stays. + ### Inference settings The agent's detail panel shows its model and effort under **inference**, each diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index bf7e6ea9..9002d98b 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -6,6 +6,7 @@ import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AlertsPage } from "./pages/AlertsPage.js"; import { BuildsPage } from "./pages/builds/BuildsPage.js"; +import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; @@ -39,6 +40,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css new file mode 100644 index 00000000..a0cb7ab3 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css @@ -0,0 +1,166 @@ +/* : the agent picker over the message box, then the per-agent + result. One column; the grid wraps to however many icons fit. */ +.ui-broadcast { + display: flex; + flex-direction: column; + gap: 1em; +} +.ui-broadcast-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em 1em; +} +/* Same segmented pair as the agents page's view switcher. */ +.ui-broadcast-filter { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 0.4em; + overflow: hidden; +} +.ui-broadcast-filter button { + border: none; + background: none; + color: var(--muted); + padding: 0.45em 0.75em; + font: inherit; + font-size: 0.9em; + cursor: pointer; +} +.ui-broadcast-filter button + button { + border-left: 1px solid var(--border); +} +.ui-broadcast-filter button.active { + background: var(--bg-elev); + color: var(--fg); +} +.ui-broadcast-filter button:not(.active):hover { + color: var(--fg); +} +.ui-broadcast-link { + border: none; + background: none; + padding: 0.2em 0; + color: var(--blue); + font: inherit; + font-size: 0.9em; + cursor: pointer; + text-decoration: underline; +} +.ui-broadcast-link:disabled { + color: var(--muted); + text-decoration: none; + cursor: default; +} +.ui-broadcast-count { + margin-left: auto; + color: var(--muted); + font-size: 0.9em; +} +.ui-broadcast-empty { + color: var(--muted); + text-align: center; + padding: 1.25em 0.75em; + margin: 0; +} + +/* The picker: each agent a labelled checkbox card, icon over name. */ +.ui-broadcast-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8em, 1fr)); + gap: 0.6em; + list-style: none; + margin: 0; + padding: 0; +} +.ui-broadcast-agent { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35em; + padding: 0.7em 0.5em 0.6em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg); + cursor: pointer; +} +.ui-broadcast-agent:hover { + border-color: var(--muted); +} +.ui-broadcast-agent-on { + border-color: var(--purple); + background: var(--bg-elev); +} +.ui-broadcast-agent:focus-within { + outline: 2px solid var(--purple); + outline-offset: 2px; +} +.ui-broadcast-check { + position: absolute; + top: 0.5em; + left: 0.5em; + margin: 0; +} +.ui-broadcast-name { + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; +} +.ui-broadcast-state { + color: var(--muted); + font-size: 0.8em; +} +.ui-broadcast-state-up { + color: var(--green); +} + +.ui-broadcast-send { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75em; +} + +.ui-broadcast-cap { + color: var(--red); + font-size: 0.9em; +} + +/* What came back, one line per agent. */ +.ui-broadcast-result { + margin-top: 1em; +} +.ui-broadcast-summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5em 1em; + margin: 0 0 0.5em; +} +.ui-broadcast-outcomes { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35em; +} +.ui-broadcast-outcomes li { + display: flex; + align-items: center; + gap: 0.6em; +} +.ui-broadcast-outcome-name { + font-weight: 600; +} +.ui-broadcast-ok { + color: var(--green); +} +.ui-broadcast-fail { + color: var(--red); +} +.ui-broadcast-ok .ui-broadcast-outcome-name, +.ui-broadcast-fail .ui-broadcast-outcome-name { + color: var(--fg); +} diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx new file mode 100644 index 00000000..fb8f3443 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx @@ -0,0 +1,310 @@ +// — one message to several agents at once. Pick agents from a +// grid of their icons (every agent, or just the ones running), type the +// message, send. The controller does the fan-out: `POST /api/agents/broadcast` +// answers one outcome per agent, so one that is down does not stop the rest and +// the page can say which ones did not get it and offer a retry to just those. +// +// The roster is `GET /api/agents/status` on a refresh interval like the agents +// page. The ticks and the message are this page's own state, so a refresh only +// replaces the agent list: what is typed and picked stays. A ticked agent that +// the filter hides is kept but not sent to (`selectedVisible`). +// +// The message goes exactly as typed, as the signed-in operator (the +// controller takes the sender from the gateway, as for the single-agent box). +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 { AgentIcon } from "../../agent/AgentIcon.js"; +import { Button } from "../../ui/button/Button.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { + RefreshIntervalPicker, + useRefreshInterval, + type RefreshIntervalMs, +} from "../../ui/refresh-interval/RefreshInterval.js"; +import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; +import { + failedAgents, + isRunning, + parseResults, + selectedVisible, + MAX_BROADCAST, + sendLabel, + tickAll, + toggled, + visibleAgents, + type AgentFilter, + type BroadcastAgent, + type BroadcastResult, +} from "./broadcast-types.js"; +import "./BroadcastPage.css"; + +const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; +const FILTER_KEY = "swarm-ui:broadcast:filter"; + +type SendState = + | { status: "idle" } + | { status: "sending" } + | { status: "done"; results: BroadcastResult[] } + | { status: "error"; problem: ProblemDetails }; + +function storedFilter(): AgentFilter { + try { + return localStorage.getItem(FILTER_KEY) === "all" ? "all" : "running"; + } catch { + return "running"; + } +} + +export function BroadcastPage() { + const [agents, setAgents] = useState(null); + const [loadError, setLoadError] = useState(null); + const [intervalMs, setIntervalMs] = + useState(DEFAULT_INTERVAL_MS); + const [filter, setFilterState] = useState(storedFilter); + const [ticked, setTicked] = useState([]); + const [body, setBody] = useState(""); + const [send, setSend] = useState({ status: "idle" }); + + function setFilter(next: AgentFilter) { + setFilterState(next); + try { + localStorage.setItem(FILTER_KEY, next); + } catch { + /* the choice lasts for the visit only */ + } + } + + async function refresh() { + const res = await fetch("/api/agents/status"); + if (!res.ok) { + setLoadError(await readApiError(res)); + return; + } + setAgents((await res.json()) as BroadcastAgent[]); + setLoadError(null); + } + + useRefreshInterval(intervalMs, () => { + refresh().catch((e: unknown) => setLoadError({ detail: String(e) })); + }); + + const all = agents ?? []; + const shown = visibleAgents(all, filter); + const targets = selectedVisible(ticked, shown); + const runningCount = all.filter(isRunning).length; + const sending = send.status === "sending"; + const overCap = targets.length > MAX_BROADCAST; + const canSend = + targets.length > 0 && !overCap && body.trim() !== "" && !sending; + + async function submit(e: Event) { + e.preventDefault(); + if (!canSend) return; + setSend({ status: "sending" }); + try { + const res = await fetch("/api/agents/broadcast", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agents: targets, body }), + }); + if (!res.ok) { + setSend({ status: "error", problem: await readApiError(res) }); + return; + } + const results = parseResults(await res.json()); + if (results === null) { + setSend({ + status: "error", + problem: { detail: "the controller's answer was not understood" }, + }); + return; + } + setSend({ status: "done", results }); + // Everyone got it: the text has served its purpose. A partial send keeps + // it so the retry to the rest sends the same words. + if (failedAgents(results).length === 0) setBody(""); + } catch (err) { + setSend({ status: "error", problem: { detail: String(err) } }); + } + } + + function retryFailed(results: BroadcastResult[]) { + setTicked(failedAgents(results)); + setSend({ status: "idle" }); + } + + return ( + + } + > + {loadError && ( + + )} +
+
+
+ + +
+ + + + {targets.length} selected + +
+ + {agents !== null && shown.length === 0 ? ( +

+ {filter === "running" && all.length > 0 + ? "no agent is running right now. Show all to pick a stopped one." + : "no agents."} +

+ ) : ( +
    + {shown.map((agent) => { + const on = targets.includes(agent.name); + return ( +
  • + +
  • + ); + })} +
+ )} + + +
+ + {overCap && ( + + at most {MAX_BROADCAST} agents at once, untick{" "} + {targets.length - MAX_BROADCAST} + + )} +
+ + +
+ {send.status === "error" && ( + + )} + {send.status === "done" && ( + + )} +
+
+ ); +} + +function SendResults({ + results, + onRetry, +}: { + results: BroadcastResult[]; + onRetry: (results: BroadcastResult[]) => void; +}) { + const failed = failedAgents(results); + const sent = results.length - failed.length; + return ( +
+

+ sent to {sent} of {results.length} + {failed.length > 0 && ( + + )} +

+
    + {results.map((r) => ( +
  • + + {r.agent} + + {r.sent + ? "✓ sent" + : `✗ not sent${r.reason ? `: ${r.reason}` : ""}`} + +
  • + ))} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/broadcast-types.ts b/frontend/packages/swarm-ui/src/pages/broadcast/broadcast-types.ts new file mode 100644 index 00000000..3d8b503b --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/broadcast/broadcast-types.ts @@ -0,0 +1,100 @@ +// What the broadcast page decides without a DOM: which agents the filter +// shows, which of the ticked ones a send really goes to, and how the +// controller's per-agent answer is read. Kept out of `BroadcastPage.tsx` so it +// is plain functions of its arguments and can be tested. + +/** The part of an `/api/agents/status` row the page reads. */ +export interface BroadcastAgent { + name: string; + snapshot: { running: boolean } | null; +} + +export type AgentFilter = "all" | "running"; + +/** An agent that has not reported a snapshot is not known to be running. */ +export function isRunning(agent: Pick): boolean { + return agent.snapshot?.running === true; +} + +/** The agents the filter shows, by name. */ +export function visibleAgents( + agents: T[], + filter: AgentFilter, +): T[] { + const shown = filter === "running" ? agents.filter(isRunning) : agents; + return [...shown].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The ticked agents a send goes to: only the ones the filter shows. An agent + * ticked and then hidden by the filter is kept in `ticked`, so switching back + * brings its tick back, but it is never sent to while hidden. + */ +export function selectedVisible( + ticked: string[], + visible: BroadcastAgent[], +): string[] { + const shown = new Set(visible.map((a) => a.name)); + return ticked.filter((name) => shown.has(name)); +} + +/** `ticked` with `name` flipped. */ +export function toggled(ticked: string[], name: string): string[] { + return ticked.includes(name) + ? ticked.filter((n) => n !== name) + : [...ticked, name]; +} + +/** `ticked` plus every shown agent, without repeating a name. */ +export function tickAll(ticked: string[], visible: BroadcastAgent[]): string[] { + const out = [...ticked]; + for (const { name } of visible) { + if (!out.includes(name)) out.push(name); + } + return out; +} + +/** Most agents one broadcast may name; the controller refuses more. */ +export const MAX_BROADCAST = 64; + +/** The send button's text. */ +export function sendLabel(count: number): string { + if (count === 0) return "send"; + return count === 1 ? "send to 1 agent" : `send to ${count} agents`; +} + +/** One agent's outcome in the controller's answer. */ +export interface BroadcastResult { + agent: string; + sent: boolean; + /** Why it was not sent, when the controller said. */ + reason: string | null; +} + +/** + * The controller's answer, `{ results: [{ agent, sent, reason? }] }`, or + * `null` when it is not that shape, so the page can say so rather than show + * nothing. + */ +export function parseResults(json: unknown): BroadcastResult[] | null { + if (typeof json !== "object" || json === null) return null; + const results = (json as { results?: unknown }).results; + if (!Array.isArray(results)) return null; + const out: BroadcastResult[] = []; + for (const entry of results) { + if (typeof entry !== "object" || entry === null) return null; + const { agent, sent, reason } = entry as Record; + if (typeof agent !== "string" || typeof sent !== "boolean") return null; + out.push({ + agent, + sent, + reason: typeof reason === "string" && reason !== "" ? reason : null, + }); + } + return out; +} + +/** The agents a retry should go to: the ones that did not get it. */ +export function failedAgents(results: BroadcastResult[]): string[] { + return results.filter((r) => !r.sent).map((r) => r.agent); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 8d2736c7..d79b771d 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -54,6 +54,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/alerts", label: "alerts", accent: "var(--red)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" }, + { href: "/broadcast", label: "broadcast", accent: "var(--blue)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/review", label: "review", accent: "var(--amber)" }, { href: "/builds", label: "builds", accent: "var(--green)" }, diff --git a/frontend/packages/swarm-ui/test/broadcast-types.test.mjs b/frontend/packages/swarm-ui/test/broadcast-types.test.mjs new file mode 100644 index 00000000..82e01526 --- /dev/null +++ b/frontend/packages/swarm-ui/test/broadcast-types.test.mjs @@ -0,0 +1,96 @@ +// The broadcast page's selection and answer rules. Run by `npm test`, which +// Node runs with its built-in test runner and type stripping, so the `.ts` +// module is imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + failedAgents, + MAX_BROADCAST, + isRunning, + parseResults, + selectedVisible, + sendLabel, + tickAll, + toggled, + visibleAgents, +} from "../src/pages/broadcast/broadcast-types.ts"; + +const up = { running: true }; +const down = { running: false }; +const agents = [ + { name: "zed", snapshot: up }, + { name: "bob", snapshot: down }, + { name: "ann", snapshot: null }, + { name: "cy", snapshot: up }, +]; + +test("only an agent that reported running counts as running", () => { + assert.equal(isRunning({ snapshot: up }), true); + assert.equal(isRunning({ snapshot: down }), false); + assert.equal(isRunning({ snapshot: null }), false); +}); + +test("the filter shows every agent or just the running ones, by name", () => { + assert.deepEqual( + visibleAgents(agents, "all").map((a) => a.name), + ["ann", "bob", "cy", "zed"], + ); + assert.deepEqual( + visibleAgents(agents, "running").map((a) => a.name), + ["cy", "zed"], + ); +}); + +test("a ticked agent the filter hides is not sent to, and comes back with the filter", () => { + const ticked = ["bob", "cy"]; + const running = visibleAgents(agents, "running"); + assert.deepEqual(selectedVisible(ticked, running), ["cy"]); + assert.deepEqual(selectedVisible(ticked, visibleAgents(agents, "all")), [ + "bob", + "cy", + ]); +}); + +test("toggling flips one name and keeps the rest", () => { + assert.deepEqual(toggled(["a", "b"], "c"), ["a", "b", "c"]); + assert.deepEqual(toggled(["a", "b"], "a"), ["b"]); +}); + +test("tick all adds the shown agents once", () => { + const shown = visibleAgents(agents, "running"); + assert.deepEqual(tickAll(["cy", "bob"], shown), ["cy", "bob", "zed"]); +}); + +test("the send button says how many it goes to", () => { + assert.equal(sendLabel(0), "send"); + assert.equal(sendLabel(1), "send to 1 agent"); + assert.equal(sendLabel(3), "send to 3 agents"); +}); + +test("the controller's answer is read per agent", () => { + const results = parseResults({ + results: [ + { agent: "a", sent: true }, + { agent: "b", sent: false, reason: "no subscriber" }, + { agent: "c", sent: false, reason: "" }, + ], + }); + assert.deepEqual(results, [ + { agent: "a", sent: true, reason: null }, + { agent: "b", sent: false, reason: "no subscriber" }, + { agent: "c", sent: false, reason: null }, + ]); + assert.deepEqual(failedAgents(results ?? []), ["b", "c"]); +}); + +test("an answer that is not the agreed shape is null, not an empty list", () => { + assert.equal(parseResults(null), null); + assert.equal(parseResults({}), null); + assert.equal(parseResults({ results: "x" }), null); + assert.equal(parseResults({ results: [{ agent: "a" }] }), null); + assert.equal(parseResults({ results: [null] }), null); +}); + +test("one broadcast may name at most 64 agents", () => { + assert.equal(MAX_BROADCAST, 64); +});