swarm-ui: broadcast is a composer on the messages page, not a tab of its own
The messages page gets a broadcast button that opens the agent picker and message box above the stream, so a sent message shows up in the list below it. The broadcast route and nav tab go, with no redirect: the page is now the composer, BroadcastComposer, in the messages page.
This commit is contained in:
parent
711e413901
commit
6cf89755e2
7 changed files with 63 additions and 40 deletions
|
|
@ -12,7 +12,7 @@ It opens a menu with the user's name and links to authelia's settings and
|
||||||
sign-out pages; the hive dashboard and the per-agent page have the same menu.
|
sign-out pages; the hive dashboard and the per-agent page have the same menu.
|
||||||
|
|
||||||
| route | what |
|
| route | what |
|
||||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
||||||
| `/hives` | the hive directory, each hive with its last reported status |
|
| `/hives` | the hive directory, each hive with its last reported status |
|
||||||
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
|
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
|
||||||
|
|
@ -21,8 +21,7 @@ sign-out pages; the hive dashboard and the per-agent page have the same menu.
|
||||||
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
|
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
|
||||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons |
|
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; its **broadcast** button opens a composer that sends one message to several agents at once |
|
||||||
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient |
|
|
||||||
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
|
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/issues` | a cross-repo issue report |
|
||||||
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
|
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
|
||||||
|
|
@ -478,18 +477,21 @@ its newest 100 builds, so a link to an older one shows a note instead.
|
||||||
|
|
||||||
### Broadcasting a message
|
### Broadcasting a message
|
||||||
|
|
||||||
The **broadcast** page sends one message to several agents at once. Pick the
|
The **broadcast** button on the messages page (`/messages`) opens a composer above the
|
||||||
|
message list that sends one message to several agents at once, and the messages
|
||||||
|
show up in the list below it as they go. The button reads **close broadcast**
|
||||||
|
while the composer is open; closing it keeps what you typed and ticked. Pick the
|
||||||
agents from a grid of their icons. **running** shows the agents that report
|
agents from a grid of their icons. **running** shows the agents that report
|
||||||
themselves running and **all** shows every agent; the browser keeps the choice.
|
themselves running and **all** shows every agent; the browser keeps the choice.
|
||||||
**select all** ticks every agent the filter shows, and **clear** unticks them.
|
**select all** ticks every agent the filter shows, and **clear** unticks them.
|
||||||
The page skips an agent you ticked and then hid with the filter. Type the
|
The composer skips an agent you ticked and then hid with the filter. Type the
|
||||||
message and press **send to N agents**. The text goes exactly as typed, as an
|
message and press **send to N agents**. The text goes exactly as typed, as an
|
||||||
inbox message from your authelia username, the same as the box under an agent's
|
inbox message from your authelia username, the same as the box under an agent's
|
||||||
terminal. One broadcast may name at most 64 agents, and the button stays off
|
terminal. One broadcast may name at most 64 agents, and the button stays off
|
||||||
until you untick the extra ones. A broadcast to many agents can take a few
|
until you untick the extra ones. A broadcast to many agents can take a few
|
||||||
seconds to answer, and the button reads **sending…** meanwhile.
|
seconds to answer, and the button reads **sending…** meanwhile.
|
||||||
|
|
||||||
The page then lists what happened to each agent. An agent that wasn't
|
The composer then lists what happened to each agent. An agent that wasn't
|
||||||
listening shows as not sent, with the reason, and the others still got the
|
listening shows as not sent, with the reason, and the others still got the
|
||||||
message. **select the N that failed** ticks just those, switching to **all** if the
|
message. **select the N that failed** ticks just those, switching to **all** if the
|
||||||
filter hid any, so you can send the same message again. When every agent got the message the box clears; otherwise the
|
filter hid any, so you can send the same message again. When every agent got the message the box clears; otherwise the
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||||
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||||
import { AlertsPage } from "./pages/AlertsPage.js";
|
import { AlertsPage } from "./pages/AlertsPage.js";
|
||||||
import { BuildsPage } from "./pages/builds/BuildsPage.js";
|
import { BuildsPage } from "./pages/builds/BuildsPage.js";
|
||||||
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
|
||||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||||
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
||||||
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
||||||
|
|
@ -46,7 +45,6 @@ export function App() {
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/schedules" component={SchedulesPage} />
|
<Route path="/schedules" component={SchedulesPage} />
|
||||||
<Route path="/builds" component={BuildsPage} />
|
<Route path="/builds" component={BuildsPage} />
|
||||||
<Route path="/broadcast" component={BroadcastPage} />
|
|
||||||
<Route path="/messages" component={MessagesPage} />
|
<Route path="/messages" component={MessagesPage} />
|
||||||
<Route path="/notifications" component={NotificationsPage} />
|
<Route path="/notifications" component={NotificationsPage} />
|
||||||
<Route path={NEW_FLOW_PATH}>
|
<Route path={NEW_FLOW_PATH}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/* <BroadcastComposer>: 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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
// <BroadcastComposer> — one message to several agents at once, in a panel on
|
||||||
|
// the messages page above the stream that shows the messages arriving. 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 {
|
||||||
|
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 "./BroadcastComposer.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 BroadcastComposer() {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
<RefreshIntervalPicker
|
||||||
|
id="broadcast-refresh"
|
||||||
|
value={intervalMs}
|
||||||
|
onChange={setIntervalMs}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
/* <MessagesPage>: the filter bar over the terminal-style message list. */
|
/* <MessagesPage>: the filter bar over the terminal-style message list. */
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
|
/* The broadcast composer above the list, set apart from it by a rule. The
|
||||||
|
composer's own rules are BroadcastComposer.css's. */
|
||||||
|
.ui-messages-broadcast {
|
||||||
|
margin-block-end: 1em;
|
||||||
|
padding-block-end: 1em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ui-messages-broadcast[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.ui-messages-bar {
|
.ui-messages-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// <MessagesPage> — `/messages`: every message between agents, and from the
|
// <MessagesPage> — `/messages`: every message between agents, and from the
|
||||||
// operator, reminders and compaction to an agent, as the swarm's controller
|
// operator, reminders and compaction to an agent, as the swarm's controller
|
||||||
// streams them (`GET /api/messages/stream`), across all hives.
|
// streams them (`GET /api/messages/stream`), across all hives. The **broadcast**
|
||||||
|
// button opens a composer above the list to send one message to several agents.
|
||||||
//
|
//
|
||||||
// The stream sends the chosen window of history first, then the live tail. A
|
// The stream sends the chosen window of history first, then the live tail. A
|
||||||
// message an agent drained with an in-turn `recv` is missing, because it starts
|
// message an agent drained with an in-turn `recv` is missing, because it starts
|
||||||
|
|
@ -11,6 +12,8 @@ import { memo } from "preact/compat";
|
||||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { Row } from "@hive/shared/term-row.js";
|
import { Row } from "@hive/shared/term-row.js";
|
||||||
|
import { BroadcastComposer } from "../broadcast/BroadcastComposer.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
|
|
@ -50,6 +53,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
|
||||||
export function MessagesPage() {
|
export function MessagesPage() {
|
||||||
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
|
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [composing, setComposing] = useState(false);
|
||||||
const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
|
const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
|
||||||
const shown = filterMessages(messages, query);
|
const shown = filterMessages(messages, query);
|
||||||
|
|
||||||
|
|
@ -79,6 +83,12 @@ export function MessagesPage() {
|
||||||
icon="💬"
|
icon="💬"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
variant={composing ? "primary" : "default"}
|
||||||
|
onClick={() => setComposing((on) => !on)}
|
||||||
|
>
|
||||||
|
{composing ? "close broadcast" : "📣 broadcast"}
|
||||||
|
</Button>
|
||||||
<Badge
|
<Badge
|
||||||
tone={CONNECTION_TONE[connection]}
|
tone={CONNECTION_TONE[connection]}
|
||||||
value={CONNECTION_LABEL[connection]}
|
value={CONNECTION_LABEL[connection]}
|
||||||
|
|
@ -94,6 +104,15 @@ export function MessagesPage() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Kept mounted while closed, so what was typed and picked stays and the
|
||||||
|
agent list is already loaded when it opens. */}
|
||||||
|
<section
|
||||||
|
class="ui-messages-broadcast"
|
||||||
|
aria-label="broadcast a message"
|
||||||
|
hidden={!composing}
|
||||||
|
>
|
||||||
|
<BroadcastComposer />
|
||||||
|
</section>
|
||||||
<div class="ui-messages-bar">
|
<div class="ui-messages-bar">
|
||||||
<SelectField
|
<SelectField
|
||||||
id="messages-window"
|
id="messages-window"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||||
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
|
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
|
||||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||||
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" },
|
|
||||||
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
||||||
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
|
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
|
||||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue