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
7 changed files with 695 additions and 0 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
166
frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css
Normal file
166
frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.css
Normal 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);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)" },
|
||||
|
|
|
|||
96
frontend/packages/swarm-ui/test/broadcast-types.test.mjs
Normal file
96
frontend/packages/swarm-ui/test/broadcast-types.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue