swarm-ui: show the notification router's flows and switch built-ins off
This commit is contained in:
parent
e2e124755d
commit
0512862b3d
6 changed files with 368 additions and 0 deletions
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)" },
|
||||||
|
|
|
||||||
80
frontend/packages/swarm-ui/test/flow-types.test.mjs
Normal file
80
frontend/packages/swarm-ui/test/flow-types.test.mjs
Normal 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"]);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue