diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 381f127d..220f3e0e 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,6 +8,7 @@ import { AlertsPage } from "./pages/AlertsPage.js"; import { BuildsPage } from "./pages/builds/BuildsPage.js"; import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js"; +import { NotificationsPage } from "./pages/notifications/NotificationsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; @@ -43,6 +44,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts new file mode 100644 index 00000000..8256ffee --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -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(); + 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(); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css new file mode 100644 index 00000000..1d3287ca --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css @@ -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; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx new file mode 100644 index 00000000..cf7510b8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -0,0 +1,144 @@ +// — 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 ( +
    + {treeRows(flow).map(({ node, depth }) => ( +
  • + {node.kind} + {describeNode(node)} +
  • + ))} +
+ ); +} + +export function NotificationsPage() { + const [intervalMs, setIntervalMs] = + useState(DEFAULT_INTERVAL_MS); + const { + data: view, + error, + refresh, + } = usePolled(intervalMs, (signal) => + fetchJson("/api/notify/flows", signal), + ); + const [pending, setPending] = useState(false); + const [actionError, setActionError] = useState(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 ( + + } + > + {error ? ( + + ) : null} + {actionError ? ( + + ) : null} + {!error && view === null ?

loading…

: null} + {view ? ( +
+

built-in flows

+ {view.builtins.map((flow) => { + const on = !view.disabled_builtins.includes(flow.id); + return ( + +
+ {flow.name} + setBuiltin(flow.id, checked)} + /> +
+ +
+ ); + })} +

custom flows

+ {view.flows.length === 0 ?

no custom flows

: null} + {view.flows.map((flow) => ( + +
+ {flow.name} + {flow.enabled ? "on" : "off"} +
+ +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 77a989aa..16076647 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -56,6 +56,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/broadcast", label: "broadcast", accent: "var(--blue)" }, { href: "/messages", label: "messages", accent: "var(--purple)" }, + { href: "/notifications", label: "notifications", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/review", label: "review", accent: "var(--amber)" }, { href: "/builds", label: "builds", accent: "var(--green)" }, diff --git a/frontend/packages/swarm-ui/test/flow-types.test.mjs b/frontend/packages/swarm-ui/test/flow-types.test.mjs new file mode 100644 index 00000000..0a8319fe --- /dev/null +++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs @@ -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"]); +});