Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: show the notification router's flows and switch built-ins off

This commit is contained in:
atlas 2026-10-24 03:51:33 +02:00 • committed by mara
commit 0512862b3d
6 changed files with 368 additions and 0 deletions

View file

@ -8,6 +8,7 @@ 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 { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
import { MessagesPage } from "./pages/messages/MessagesPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js";
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js"; import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js"; import { HivesPage } from "./pages/HivesPage.js";
@ -43,6 +44,7 @@ export function App() {
<Route path="/builds" component={BuildsPage} /> <Route path="/builds" component={BuildsPage} />
<Route path="/broadcast" component={BroadcastPage} /> <Route path="/broadcast" component={BroadcastPage} />
<Route path="/messages" component={MessagesPage} /> <Route path="/messages" component={MessagesPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path="/issues" component={IssueReportPage} /> <Route path="/issues" component={IssueReportPage} />
<Route path="/review" component={ReviewPage} /> <Route path="/review" component={ReviewPage} />
<Route path="/components" component={ComponentsPage} /> <Route path="/components" component={ComponentsPage} />

View file

@ -0,0 +1,95 @@
// Wire types of swarm-controller's `GET/PUT /api/notify/flows` and the small
// pure helpers the notifications page draws a flow with.
export type Recipients = { field: string } | { agent: string };
export interface Condition {
field: string;
equals: string;
}
// A node as the API sends it: `kind` sits beside `id`.
export type FlowNode =
| { id: string; kind: "source"; events: string[] }
| { id: string; kind: "match"; when: Condition[] }
| {
id: string;
kind: "todo";
to: Recipients;
op: "upsert" | "clear";
key: string;
prefix: boolean;
text: string;
};
export interface Flow {
id: string;
name: string;
enabled: boolean;
nodes: FlowNode[];
edges: { from: string; to: string }[];
}
export interface FlowsView {
revision: number | null;
disabled_builtins: string[];
builtins: Flow[];
flows: Flow[];
}
// One node of a flow with its depth below the source, in the order a reader
// walks the tree: a parent, then each child's subtree in edge order.
export interface TreeRow {
node: FlowNode;
depth: number;
}
export function treeRows(flow: Flow): TreeRow[] {
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
const root = flow.nodes.find((n) => n.kind === "source");
if (!root) return [];
const rows: TreeRow[] = [];
const seen = new Set<string>();
const walk = (id: string, depth: number) => {
const node = byId.get(id);
// The controller refuses a flow that is not a tree; a repeat here would
// mean a cycle, which `seen` stops from looping.
if (!node || seen.has(id)) return;
seen.add(id);
rows.push({ node, depth });
for (const e of flow.edges) if (e.from === id) walk(e.to, depth + 1);
};
walk(root.id, 0);
return rows;
}
export function describeRecipients(to: Recipients): string {
return "field" in to ? `{${to.field}}` : to.agent;
}
// One line saying what a node is and does.
export function describeNode(node: FlowNode): string {
switch (node.kind) {
case "source":
return `forge ${node.events.join(", ")}`;
case "match":
return node.when.length === 0
? "match anything"
: node.when.map((c) => `${c.field} = ${c.equals}`).join(" and ");
case "todo":
return node.op === "clear"
? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}`
: `todo to ${describeRecipients(node.to)}, key ${node.key}: ${node.text}`;
}
}
// `disabled` with built-in `id` switched on or off, without repeats and in a
// stable order.
export function withBuiltin(
disabled: string[],
id: string,
enabled: boolean,
): string[] {
const rest = disabled.filter((d) => d !== id);
return (enabled ? rest : [...rest, id]).sort();
}

View file

@ -0,0 +1,46 @@
/* NotificationsPage — the flows drawn as indented trees. */
.notify-flows {
display: flex;
flex-direction: column;
gap: 0.75em;
}
.notify-flow-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5em;
}
.notify-flow-name {
font-weight: 600;
}
.notify-flow-tree {
margin: 0.4em 0 0;
padding: 0;
list-style: none;
font-size: 0.9em;
}
.notify-node {
display: flex;
gap: 0.5em;
padding: 0.1em 0;
}
.notify-node-kind {
flex: none;
min-width: 3.5em;
color: var(--muted);
}
.notify-node-text {
overflow-wrap: anywhere;
}
.notify-off {
opacity: 0.6;
}

View file

@ -0,0 +1,144 @@
// <NotificationsPage> — the notification router's flows. Backed by
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
// writer of the stored document and the router reads it every few seconds.
//
// Read-only except for one switch per built-in flow. A flow draws as an
// indented tree, the shape the API validates it to. Custom flows are not
// editable here: they arrive through the API.
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 { Card } from "../../ui/card/Card.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.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 {
describeNode,
treeRows,
withBuiltin,
type Flow,
type FlowsView,
} from "./FlowTypes.js";
import "./NotificationsPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
function FlowTree({ flow }: { flow: Flow }) {
return (
<ul class="notify-flow-tree">
{treeRows(flow).map(({ node, depth }) => (
<li
class="notify-node"
key={node.id}
style={{ paddingLeft: `${depth * 1.5}em` }}
>
<span class="notify-node-kind">{node.kind}</span>
<span class="notify-node-text">{describeNode(node)}</span>
</li>
))}
</ul>
);
}
export function NotificationsPage() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const {
data: view,
error,
refresh,
} = usePolled(intervalMs, (signal) =>
fetchJson<FlowsView>("/api/notify/flows", signal),
);
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
// Switch one built-in on or off: the whole document goes back with the
// revision this page read, so a change made elsewhere since is refused
// rather than overwritten.
async function setBuiltin(id: string, enabled: boolean) {
if (!view) return;
setPending(true);
setActionError(null);
try {
const r = await fetch("/api/notify/flows", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
revision: view.revision,
disabled_builtins: withBuiltin(view.disabled_builtins, id, enabled),
flows: view.flows,
}),
});
if (!r.ok) setActionError(await readApiError(r));
} catch (err) {
setActionError({ detail: String(err) });
} finally {
setPending(false);
refresh();
}
}
return (
<Panel
title="notifications"
icon="🔔"
actions={
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{error ? (
<ApiErrorPanel context="failed to load the flows" problem={error} />
) : null}
{actionError ? (
<ApiErrorPanel
context="the flows were not changed"
problem={actionError}
/>
) : null}
{!error && view === null ? <p>loading…</p> : null}
{view ? (
<div class="notify-flows">
<h3>built-in flows</h3>
{view.builtins.map((flow) => {
const on = !view.disabled_builtins.includes(flow.id);
return (
<Card key={flow.id} class={on ? "" : "notify-off"}>
<div class="notify-flow-head">
<span class="notify-flow-name">{flow.name}</span>
<CheckboxField
id={`builtin-${flow.id}`}
label={on ? "on" : "off"}
checked={on}
disabled={pending}
onChange={(checked) => setBuiltin(flow.id, checked)}
/>
</div>
<FlowTree flow={flow} />
</Card>
);
})}
<h3>custom flows</h3>
{view.flows.length === 0 ? <p>no custom flows</p> : null}
{view.flows.map((flow) => (
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
<div class="notify-flow-head">
<span class="notify-flow-name">{flow.name}</span>
<span>{flow.enabled ? "on" : "off"}</span>
</div>
<FlowTree flow={flow} />
</Card>
))}
</div>
) : null}
</Panel>
);
}

View file

@ -56,6 +56,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" }, { 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: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" },
{ href: "/review", label: "review", accent: "var(--amber)" }, { href: "/review", label: "review", accent: "var(--amber)" },
{ href: "/builds", label: "builds", accent: "var(--green)" }, { href: "/builds", label: "builds", accent: "var(--green)" },

View file

@ -0,0 +1,80 @@
// The notifications page's tree and wording helpers. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
describeNode,
treeRows,
withBuiltin,
} from "../src/pages/notifications/FlowTypes.ts";
const flow = {
id: "f",
name: "f",
enabled: true,
nodes: [
{ id: "s", kind: "source", events: ["issues", "issue_assign"] },
{ id: "m", kind: "match", when: [{ field: "action", equals: "closed" }] },
{ id: "m2", kind: "match", when: [] },
{
id: "t",
kind: "todo",
to: { field: "todo_holders" },
op: "clear",
key: "forge:{repo}#{number}:",
prefix: true,
text: "",
},
{
id: "u",
kind: "todo",
to: { agent: "triage" },
op: "upsert",
key: "k",
prefix: false,
text: "look",
},
],
edges: [
{ from: "s", to: "m" },
{ from: "m", to: "t" },
{ from: "s", to: "m2" },
{ from: "m2", to: "u" },
],
};
test("treeRows: each subtree follows its parent, with its depth", () => {
assert.deepEqual(
treeRows(flow).map((r) => [r.node.id, r.depth]),
[
["s", 0],
["m", 1],
["t", 2],
["m2", 1],
["u", 2],
],
);
});
test("treeRows: a cycle ends the walk and a flow with no source is empty", () => {
const cyclic = { ...flow, edges: [...flow.edges, { from: "t", to: "s" }] };
assert.equal(treeRows(cyclic).length, 5);
assert.deepEqual(treeRows({ ...flow, nodes: [] }), []);
});
test("describeNode: says what each kind does", () => {
assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign");
assert.equal(describeNode(flow.nodes[1]), "action = closed");
assert.equal(describeNode(flow.nodes[2]), "match anything");
assert.equal(
describeNode(flow.nodes[3]),
"clear todos forge:{repo}#{number}:* of {todo_holders}",
);
assert.equal(describeNode(flow.nodes[4]), "todo to triage, key k: look");
});
test("withBuiltin: switching off adds once, switching on removes", () => {
assert.deepEqual(withBuiltin([], "b", false), ["b"]);
assert.deepEqual(withBuiltin(["b"], "b", false), ["b"]);
assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]);
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
});