diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 33eec06f..79d88353 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -17,7 +17,6 @@ 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 | @@ -292,25 +291,6 @@ 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, switching to **all** if the -filter hid any, 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 9002d98b..bf7e6ea9 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -6,7 +6,6 @@ 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"; @@ -40,7 +39,6 @@ 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 deleted file mode 100644 index a0cb7ab3..00000000 --- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css +++ /dev/null @@ -1,166 +0,0 @@ -/* : 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 deleted file mode 100644 index 1db66ea3..00000000 --- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx +++ /dev/null @@ -1,314 +0,0 @@ -// — 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, - filterShowing, - 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[]) { - const failed = failedAgents(results); - // Only this page's view changes; the saved choice stays as it was. - setFilterState(filterShowing(filter, all, failed)); - setTicked(failed); - 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 deleted file mode 100644 index 99b083b7..00000000 --- a/frontend/packages/swarm-ui/src/pages/broadcast/broadcast-types.ts +++ /dev/null @@ -1,114 +0,0 @@ -// 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)); -} - -/** - * The filter to show `names` under: `filter` itself when it shows them all, - * otherwise `all`. A resend to agents that failed must show them ticked, and - * the failures are often the agents the `running` filter hides. - */ -export function filterShowing( - filter: AgentFilter, - agents: BroadcastAgent[], - names: string[], -): AgentFilter { - const shown = new Set(visibleAgents(agents, filter).map((a) => a.name)); - return names.every((n) => shown.has(n)) ? filter : "all"; -} - -/** `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 d79b771d..8d2736c7 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -54,7 +54,6 @@ 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 deleted file mode 100644 index c2a93cca..00000000 --- a/frontend/packages/swarm-ui/test/broadcast-types.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -// 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, - filterShowing, - 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); -}); - -test("a retry shows the failed agents even when the filter hides them", () => { - const agents = [ - { name: "a", snapshot: { running: true } }, - { name: "b", snapshot: { running: false } }, - ]; - assert.equal(filterShowing("running", agents, ["a"]), "running"); - assert.equal(filterShowing("running", agents, ["a", "b"]), "all"); - assert.equal(filterShowing("running", agents, ["b"]), "all"); - assert.equal(filterShowing("all", agents, ["b"]), "all"); -});