Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-18 00:03:36 +02:00 • committed by mara
commit 89b20a5a50
7 changed files with 695 additions and 0 deletions

View file

@ -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() {
<Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} />
<Route path="/builds" component={BuildsPage} />
<Route path="/broadcast" component={BroadcastPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/review" component={ReviewPage} />
<Route path="/components" component={ComponentsPage} />

View file

@ -0,0 +1,166 @@
/* <BroadcastPage>: 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);
}

View file

@ -0,0 +1,310 @@
// <BroadcastPage> — 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<BroadcastAgent[] | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
const [ticked, setTicked] = useState<string[]>([]);
const [body, setBody] = useState("");
const [send, setSend] = useState<SendState>({ 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 (
<Panel
title="broadcast"
icon="📣"
actions={
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{loadError && (
<ApiErrorPanel context="failed to load agents" problem={loadError} />
)}
<form class="ui-broadcast" onSubmit={submit}>
<div class="ui-broadcast-bar">
<div class="ui-broadcast-filter" role="group" aria-label="show">
<button
type="button"
aria-pressed={filter === "running"}
class={filter === "running" ? "active" : undefined}
onClick={() => setFilter("running")}
>
running {agents ? runningCount : ""}
</button>
<button
type="button"
aria-pressed={filter === "all"}
class={filter === "all" ? "active" : undefined}
onClick={() => setFilter("all")}
>
all {agents ? all.length : ""}
</button>
</div>
<button
type="button"
class="ui-broadcast-link"
disabled={shown.length === 0}
onClick={() => setTicked((prev) => tickAll(prev, shown))}
>
select all
</button>
<button
type="button"
class="ui-broadcast-link"
disabled={targets.length === 0}
onClick={() => setTicked([])}
>
clear
</button>
<span class="ui-broadcast-count" role="status">
{targets.length} selected
</span>
</div>
{agents !== null && shown.length === 0 ? (
<p class="ui-broadcast-empty">
{filter === "running" && all.length > 0
? "no agent is running right now. Show all to pick a stopped one."
: "no agents."}
</p>
) : (
<ul class="ui-broadcast-grid" aria-label="agents">
{shown.map((agent) => {
const on = targets.includes(agent.name);
return (
<li key={agent.name}>
<label
class={
"ui-broadcast-agent" +
(on ? " ui-broadcast-agent-on" : "")
}
>
<input
type="checkbox"
class="ui-broadcast-check"
checked={on}
onChange={() =>
setTicked((prev) => toggled(prev, agent.name))
}
/>
<AgentIcon name={agent.name} />
<span class="ui-broadcast-name">{agent.name}</span>
<span
class={
"ui-broadcast-state" +
(isRunning(agent) ? " ui-broadcast-state-up" : "")
}
>
{isRunning(agent) ? "running" : "not running"}
</span>
</label>
</li>
);
})}
</ul>
)}
<TextAreaField
id="broadcast-body"
label="message"
value={body}
rows={5}
placeholder="sent exactly as typed, from you, to each selected agent"
onInput={setBody}
/>
<div class="ui-broadcast-send">
<Button variant="primary" type="submit" disabled={!canSend}>
{sending ? "sending…" : sendLabel(targets.length)}
</Button>
{overCap && (
<span class="ui-broadcast-cap" role="status">
at most {MAX_BROADCAST} agents at once, untick{" "}
{targets.length - MAX_BROADCAST}
</span>
)}
</div>
</form>
<div class="ui-broadcast-result" aria-live="polite">
{send.status === "error" && (
<ApiErrorPanel context="failed to send" problem={send.problem} />
)}
{send.status === "done" && (
<SendResults results={send.results} onRetry={retryFailed} />
)}
</div>
</Panel>
);
}
function SendResults({
results,
onRetry,
}: {
results: BroadcastResult[];
onRetry: (results: BroadcastResult[]) => void;
}) {
const failed = failedAgents(results);
const sent = results.length - failed.length;
return (
<section class="ui-broadcast-results" aria-label="send result">
<p class="ui-broadcast-summary">
sent to {sent} of {results.length}
{failed.length > 0 && (
<button
type="button"
class="ui-broadcast-link"
onClick={() => onRetry(results)}
>
select the {failed.length} that failed
</button>
)}
</p>
<ul class="ui-broadcast-outcomes">
{results.map((r) => (
<li
key={r.agent}
class={r.sent ? "ui-broadcast-ok" : "ui-broadcast-fail"}
>
<AgentIcon name={r.agent} small />
<span class="ui-broadcast-outcome-name">{r.agent}</span>
<span>
{r.sent
? "✓ sent"
: `✗ not sent${r.reason ? `: ${r.reason}` : ""}`}
</span>
</li>
))}
</ul>
</section>
);
}

View file

@ -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<BroadcastAgent, "snapshot">): boolean {
return agent.snapshot?.running === true;
}
/** The agents the filter shows, by name. */
export function visibleAgents<T extends BroadcastAgent>(
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<string, unknown>;
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);
}

View file

@ -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)" },

View file

@ -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);
});