Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx
iris 8917ba27b6 swarm-ui: one polled-resource hook for the refreshing pages
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.
2026-10-21 23:05:35 +02:00

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