Each polling page repeated its own fetch-and-set code on a fixed timer, so a slow answer could be overtaken by the next tick, an older response could overwrite a newer one, and nothing aborted a request when the page went away. usePolled skips a tick while a request is in flight, aborts on unmount or refresh, ignores answers from dropped requests, and keeps the last good data under an error. The alerts, hives, agents, schedules, builds, review runs and broadcast pages and the subagent list use it.
301 lines
9.8 KiB
TypeScript
301 lines
9.8 KiB
TypeScript
// <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,
|
|
type RefreshIntervalMs,
|
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.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 [intervalMs, setIntervalMs] =
|
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
|
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) =>
|
|
fetchJson<BroadcastAgent[]>("/api/agents/status", signal),
|
|
);
|
|
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 */
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|