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:
parent
a429b30448
commit
89b20a5a50
1 changed files with 695 additions and 0 deletions
310
frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx
Normal file
310
frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue