diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index ea07eaea..916ffc28 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -19,7 +19,7 @@ domain, covers host-level detail for one hive. | `/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 | -| `/notifications` | the notification router's flows: a switch for each built-in, and every flow drawn as a tree | +| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a tree, and an editor for custom flows | | `/issues` | a cross-repo issue report | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | @@ -226,13 +226,41 @@ restores it. Each **custom** flow shows its name and whether it's on. Every flow draws as an indented tree: the forge events it starts from, the conditions under it, and the todo each branch ends in. -The page edits nothing else. Add custom flows with `PUT /api/notify/flows`. +A built-in flow can be **duplicated** into a custom one, and **new flow** starts a +custom flow with just a source; either opens the flow in the editor. A custom +flow has an **edit** link and a **duplicate** button. A switch sends the whole flows document with the revision the page read, `0` if it read no document. The controller refuses the switch when the stored revision is another, including when someone wrote a document after the page read none, and the page shows why. Reload to switch against the current document. +#### Editing a flow + +`/notifications/` edits one custom flow. The canvas draws the flow left +to right: the source on the left, each match to its right, and the todos at the +ends. Pick a node to edit it in the panel beside the canvas: + +- a **source** picks the forge events the flow starts from; +- a **match** lists conditions, each an event field and the value it must equal; +- a **todo** says who gets it (the agents an event field names, or one agent), + whether it sets or clears a todo, the todo's key, and its text. The key and the + text can use `{field}` placeholders. + +Beside the fields, **add a match below** and **add a todo below** grow the +branch, **move under** moves a node and what is below it to another match, and +**remove** takes a node and its branch out. The editor turns away what could +never be saved, with the reason: a todo with children, a second parent, a node +under its own branch, or more nodes or levels than the controller allows. + +Edits stay in a draft until **save**, which sends the whole flows document with +the revision the editor read. **discard edits** goes back to the saved flow and +**delete flow** removes it, after a confirmation. If the flows were changed +somewhere else in the meantime the save is refused as stale, and the editor +offers to reload and drop your edits, or to keep them on top of the latest. A +document the controller refuses for any other reason is shown as the controller +says. The router runs a saved flow from its next event. + ### Sending a message to an agent Under an agent's terminal, in the preview and in its own tab, is a **message** diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 220f3e0e..80786818 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 { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js"; import { NotificationsPage } from "./pages/notifications/NotificationsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; @@ -45,6 +46,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx new file mode 100644 index 00000000..0905a107 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx @@ -0,0 +1,74 @@ +// β€” a flow drawn as boxes and lines: the source on the left, its +// branches to the right. Pick a node to edit it; the inspector beside the +// canvas does the editing. +import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js"; +import { describeNode, ellipsize, type Flow } from "./FlowTypes.js"; + +const TEXT_CHARS = 30; + +export function FlowCanvas({ + flow, + selected, + onSelect, +}: { + flow: Flow; + selected: string | null; + onSelect: (id: string) => void; +}) { + const layout = layoutFlow(flow); + const byId = new Map(flow.nodes.map((n) => [n.id, n])); + return ( +
+ + {layout.edges.map((e) => { + const mid = (e.x1 + e.x2) / 2; + return ( + ${e.to}`} + class="notify-edge" + d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`} + /> + ); + })} + {layout.nodes.map((p) => { + const node = byId.get(p.id); + if (!node) return null; + const text = describeNode(node); + return ( + onSelect(p.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(p.id); + } + }} + > + {text} + + + {node.kind} + + + {ellipsize(text, TEXT_CHARS)} + + + ); + })} + +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index 5918f401..d7f0c45c 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -5,7 +5,7 @@ // these edits only refuse what could never be saved, so the canvas can // turn a bad drop away with a reason instead of drawing a flow the save // would reject. The tree rules mirror `Flow::validate` in swarm-wire. -import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js"; +import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js"; export interface Limits { maxNodes: number; @@ -21,6 +21,14 @@ export const DEFAULT_LIMITS: Limits = { maxFlows: 100, }; +// The caps the controller reports, or today's when it reports none. +export function limitsOf(schema?: FlowSchema): Limits { + const l = schema?.limits; + return l + ? { maxNodes: l.max_nodes, maxDepth: l.max_depth, maxFlows: l.max_flows } + : DEFAULT_LIMITS; +} + export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string }; const ID = /^[A-Za-z0-9_-]{1,64}$/; diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx new file mode 100644 index 00000000..92b36ecf --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -0,0 +1,263 @@ +// β€” edit one custom notification flow: a canvas of its +// nodes, an inspector for the picked one, and a Save that sends the whole +// document back at the revision it was read. Edits stay in a draft until +// then, so a half-built flow never reaches the router. +// +// The controller is the authority on what may be saved; its refusal is shown +// as it comes. A save at an old revision is told apart from that: the flows +// were changed elsewhere, and the editor offers to reload or to keep the draft +// on top of the latest. +import { useCallback, useEffect, useState } from "preact/hooks"; +import { Link, useLocation, type RouteComponentProps } from "wouter-preact"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Button } from "../../ui/button/Button.js"; +import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; +import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js"; +import { FlowCanvas } from "./FlowCanvas.js"; +import { + isDirty, + limitsOf, + saveOutcome, + saveRequest, + withFlow, + withoutFlow, + type Edit, +} from "./FlowDraft.js"; +import { NodeInspector } from "./NodeInspector.js"; +import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js"; +import "./NotificationsPage.css"; + +const FLOWS_URL = "/api/notify/flows"; + +function rootOf(flow: Flow): string | null { + return flow.nodes.find((n) => n.kind === "source")?.id ?? null; +} + +export function FlowEditorPage({ + params, +}: RouteComponentProps<{ flowId: string }>) { + const flowId = decodeURIComponent(params.flowId); + const [, navigate] = useLocation(); + const [view, setView] = useState(null); + const [draft, setDraft] = useState(null); + const [selected, setSelected] = useState(null); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [stale, setStale] = useState(false); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + // Read the flows. With `keepDraft` the edits stay and only the revision + // they will be saved at moves on. + const load = useCallback( + async (keepDraft: boolean) => { + try { + const next = await fetchJson( + FLOWS_URL, + new AbortController().signal, + ); + setView(next); + setLoadError(null); + setStale(false); + setSaveError(null); + if (!keepDraft) { + const flow = next.flows.find((f) => f.id === flowId); + setDraft(flow ? structuredClone(flow) : null); + setSelected(flow ? rootOf(flow) : null); + } + } catch (err) { + setLoadError( + err instanceof ProblemError ? err.problem : { detail: String(err) }, + ); + } + }, + [flowId], + ); + useEffect(() => { + void load(false); + }, [load]); + + const dirty = + view !== null && + draft !== null && + isDirty(view, withFlow(view.flows, draft), view.disabled_builtins); + + // Leaving the page with edits not saved asks first. + useEffect(() => { + if (!dirty) return; + const warn = (e: BeforeUnloadEvent) => e.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + + // Send `flows` as the document; true when the controller took it. + async function put(flows: Flow[]): Promise { + if (!view) return false; + setBusy(true); + setSaveError(null); + try { + const r = await fetch( + FLOWS_URL, + saveRequest(view, flows, view.disabled_builtins), + ); + const outcome = saveOutcome(r.status); + if (outcome === "saved") { + const next = (await r.json()) as FlowsView; + setView({ ...next, schema: next.schema ?? view.schema }); + return true; + } + if (outcome === "conflict") setStale(true); + else setSaveError(await readApiError(r)); + } catch (err) { + setSaveError({ detail: String(err) }); + } finally { + setBusy(false); + } + return false; + } + + function onEdit(result: Edit, select?: string) { + if (!result.ok) { + setNotice(result.reason); + return; + } + setNotice(null); + setDraft(result.flow); + if (select !== undefined) setSelected(select); + else if (!result.flow.nodes.some((n) => n.id === selected)) { + setSelected(rootOf(result.flow)); + } + } + + async function onDelete() { + if (!view) return; + setConfirmDelete(false); + if (await put(withoutFlow(view.flows, flowId))) navigate("/notifications"); + } + + const back = all flows; + + if (loadError) { + return ( + + + + ); + } + if (!view) { + return ( + +

loading…

+
+ ); + } + if (!draft) { + const builtin = view.builtins.some((b) => b.id === flowId); + return ( + +

+ {builtin + ? "A built-in flow cannot be edited: duplicate it from the list and edit the copy." + : `There is no flow ${flowId}; it may have been deleted.`} +

+
+ ); + } + + const schema = view.schema ?? FALLBACK_SCHEMA; + const limits = limitsOf(view.schema); + const node = draft.nodes.find((n) => n.id === selected) ?? null; + + return ( + + {stale ? ( + + ) : null} + {saveError ? ( + + ) : null} + {notice ? ( +

+ {notice} +

+ ) : null} + +
+
+ setDraft({ ...draft, name })} + /> +
+ setDraft({ ...draft, enabled })} + /> + + + +
+ {dirty ?

Not saved yet.

: null} + +
+ + {node ? ( + + ) : null} +
+ + setConfirmDelete(false)} + onConfirm={() => void onDelete()} + > +

Delete the flow {draft.name}? The router stops running it.

+
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts new file mode 100644 index 00000000..523057c6 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts @@ -0,0 +1,95 @@ +// Where the flow editor's canvas puts each node: the tree runs left to +// right, one column per level, a leaf per row, and a parent sits at the +// middle of its children. Computed from the flow on every edit, so nothing +// about the layout is stored. +import type { Flow } from "./FlowTypes.js"; + +export const NODE_WIDTH = 190; +export const NODE_HEIGHT = 46; +const GAP_X = 56; +const GAP_Y = 14; +const PAD = 12; + +export interface PlacedNode { + id: string; + depth: number; + // Top-left corner. + x: number; + y: number; +} + +export interface PlacedEdge { + from: string; + to: string; + // Right-middle of the parent, left-middle of the child. + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface Layout { + nodes: PlacedNode[]; + edges: PlacedEdge[]; + width: number; + height: number; +} + +export function layoutFlow(flow: Flow): Layout { + const children = new Map(); + for (const e of flow.edges) { + children.set(e.from, [...(children.get(e.from) ?? []), e.to]); + } + const known = new Set(flow.nodes.map((n) => n.id)); + const placed = new Map(); + let row = 0; + + // Depth first, so a leaf takes the next row and a parent the middle of its + // first and last child. `placed` also stops a cycle from looping. + const place = (id: string, depth: number): number => { + const kids = (children.get(id) ?? []).filter( + (k) => known.has(k) && !placed.has(k), + ); + const at = (r: number): number => PAD + r * (NODE_HEIGHT + GAP_Y); + const x = PAD + depth * (NODE_WIDTH + GAP_X); + // Reserve the spot first so a node cannot be reached twice. + placed.set(id, { id, depth, x, y: 0 }); + // A node with two parents (a draft the save would refuse) is placed under + // the first that reaches it. + const ys: number[] = []; + for (const k of kids) if (!placed.has(k)) ys.push(place(k, depth + 1)); + const y = ys.length === 0 ? at(row++) : (ys[0] + ys[ys.length - 1]) / 2; + placed.set(id, { id, depth, x, y }); + return y; + }; + + const root = flow.nodes.find((n) => n.kind === "source"); + if (root) place(root.id, 0); + // A draft being rewired can hold a node nothing reaches yet: show it below + // rather than lose it, until it is attached. + for (const n of flow.nodes) { + if (!placed.has(n.id)) place(n.id, 0); + } + + const nodes = flow.nodes.map((n) => placed.get(n.id) as PlacedNode); + const edges = flow.edges + .filter((e) => placed.has(e.from) && placed.has(e.to)) + .map((e) => { + const a = placed.get(e.from) as PlacedNode; + const b = placed.get(e.to) as PlacedNode; + return { + from: e.from, + to: e.to, + x1: a.x + NODE_WIDTH, + y1: a.y + NODE_HEIGHT / 2, + x2: b.x, + y2: b.y + NODE_HEIGHT / 2, + }; + }); + return { + nodes, + edges, + width: Math.max(0, ...nodes.map((n) => n.x + NODE_WIDTH)) + PAD, + height: Math.max(0, ...nodes.map((n) => n.y + NODE_HEIGHT)) + PAD, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts index 2cb58d34..cbe8cfa4 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -35,6 +35,8 @@ export interface FlowsView { disabled_builtins: string[]; builtins: Flow[]; flows: Flow[]; + // Absent from a controller that predates it. + schema?: FlowSchema; } // One node of a flow with its depth below the source, in the order a reader @@ -93,3 +95,73 @@ export function withBuiltin( const rest = disabled.filter((d) => d !== id); return (enabled ? rest : [...rest, id]).sort(); } + +// What the controller's `schema` says a flow may hold, so the editor offers +// only what a save accepts. Controllers that predate the schema send none. +export interface Described { + name: string; + description: string; +} + +export interface KindField extends Described { + required: boolean; + values: Described[]; +} + +export interface KindSchema { + kind: string; + description: string; + fields: KindField[]; +} + +export interface FlowSchema { + source_events: Described[]; + fields: Described[]; + node_kinds: KindSchema[]; + limits: { max_nodes: number; max_depth: number; max_flows: number }; +} + +// The same names as the controller's today, for when it sends no schema. The +// controller still refuses anything wrong here on save. +export const FALLBACK_SCHEMA: FlowSchema = { + source_events: [ + "issues", + "issue_assign", + "pull_request", + "pull_request_assign", + ].map((name) => ({ name, description: "" })), + fields: [ + "event", + "action", + "kind", + "repo", + "number", + "title", + "url", + "sender", + "assignees", + "assignees_added", + "assignees_removed", + "todo_holders", + ].map((name) => ({ name, description: "" })), + node_kinds: [], + limits: { max_nodes: 64, max_depth: 16, max_flows: 100 }, +}; + +// `text` cut to `max` characters, with an ellipsis if it was longer. +export function ellipsize(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} + +// The values the controller allows for `field` of node kind `kind`, if its +// schema lists them (the todo `op` does). +export function kindValues( + schema: FlowSchema, + kind: string, + field: string, +): string[] { + const values = schema.node_kinds + .find((k) => k.kind === kind) + ?.fields.find((f) => f.name === field)?.values; + return values && values.length > 0 ? values.map((v) => v.name) : []; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx new file mode 100644 index 00000000..2a1864df --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -0,0 +1,305 @@ +// β€” the fields of the picked node, and what can be done to +// it: add a child, move it under another node, remove it. Every change goes +// out as an `Edit` of the flow; the editor shows the reason when one is +// refused. +import { Button } from "../../ui/button/Button.js"; +import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; +import { SelectField } from "../../ui/select-field/SelectField.js"; +import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { + addChild, + blankNode, + parentOf, + removeNode, + reparent, + replaceNode, + type Edit, + type Limits, +} from "./FlowDraft.js"; +import { + kindValues, + type Condition, + type Described, + type Flow, + type FlowNode, + type FlowSchema, +} from "./FlowTypes.js"; + +type TodoNode = Extract; + +const OPS_FALLBACK = ["upsert", "clear"]; + +function options(list: Described[]) { + return list.map((d) => ({ + value: d.name, + label: d.description ? `${d.name} β€” ${d.description}` : d.name, + })); +} + +export function NodeInspector({ + flow, + node, + schema, + limits, + onEdit, +}: { + flow: Flow; + node: FlowNode; + schema: FlowSchema; + limits: Limits; + // `select` is the node to pick once the edit is applied. + onEdit: (edit: Edit, select?: string) => void; +}) { + const set = (next: FlowNode) => onEdit(replaceNode(flow, next)); + const fieldOptions = options(schema.fields); + const firstField = schema.fields[0]?.name ?? ""; + const key = `ne-${node.id}`; + + const add = (kind: "match" | "todo") => { + const added = blankNode(flow, kind); + onEdit(addChild(flow, node.id, added, limits), added.id); + }; + + const parent = parentOf(flow, node.id); + const parents = flow.nodes + .filter((n) => n.id !== node.id && reparent(flow, node.id, n.id, limits).ok) + .map((n) => ({ value: n.id, label: `${n.kind} ${n.id}` })); + + return ( +
+

+ {node.kind}{" "} + {node.id !== node.kind ? ( + {node.id} + ) : null} +

+ + {node.kind === "source" ? ( +
+ forge events + {schema.source_events.map((ev) => ( + + set({ + ...node, + events: on + ? [...node.events, ev.name] + : node.events.filter((e) => e !== ev.name), + }) + } + /> + ))} +
+ ) : null} + + {node.kind === "match" ? ( + + ) : null} + + {node.kind === "todo" ? ( + + ) : null} + +
+ {node.kind !== "todo" ? ( + <> + + + + ) : null} + {node.kind !== "source" ? ( + + ) : null} +
+ + {node.kind !== "source" && parent !== undefined ? ( + onEdit(reparent(flow, node.id, p, limits))} + /> + ) : null} +
+ ); +} + +function MatchFields({ + node, + keyPrefix, + fieldOptions, + firstField, + onChange, +}: { + node: Extract; + keyPrefix: string; + fieldOptions: { value: string; label: string }[]; + firstField: string; + onChange: (next: FlowNode) => void; +}) { + const setWhen = (when: Condition[]) => onChange({ ...node, when }); + return ( +
+ passes when all of these hold + {node.when.length === 0 ?

no condition: passes everything

: null} + {node.when.map((c, i) => ( +
+ + setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x))) + } + /> + + setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x))) + } + /> + +
+ ))} + +
+ ); +} + +function TodoFields({ + node, + keyPrefix, + schema, + fieldOptions, + firstField, + onChange, +}: { + node: TodoNode; + keyPrefix: string; + schema: FlowSchema; + fieldOptions: { value: string; label: string }[]; + firstField: string; + onChange: (next: FlowNode) => void; +}) { + const ops = kindValues(schema, "todo", "op"); + const opList = (ops.length > 0 ? ops : OPS_FALLBACK).map((o) => ({ + value: o, + label: o, + })); + const byField = "field" in node.to; + const hint = schema.fields.map((f) => `{${f.name}}`).join(" "); + return ( +
+ emits a todo + + onChange({ + ...node, + op: op as TodoNode["op"], + // A clear carries no text. + text: op === "clear" ? "" : node.text, + }) + } + /> + + onChange({ + ...node, + to: kind === "field" ? { field: firstField } : { agent: "" }, + }) + } + /> + {"field" in node.to ? ( + onChange({ ...node, to: { field } })} + /> + ) : ( + onChange({ ...node, to: { agent } })} + /> + )} + onChange({ ...node, key: k })} + /> + {node.op === "clear" ? ( + onChange({ ...node, prefix })} + /> + ) : ( + onChange({ ...node, text })} + /> + )} +

The key and text can use {hint}

+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css index 1d3287ca..c31f31fc 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css @@ -44,3 +44,132 @@ .notify-off { opacity: 0.6; } + +/* The flow list's new-flow row and each flow's actions. */ +.notify-new { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5em; +} + +.notify-flow-head > * + * { + margin-left: 0.5em; +} + +/* FlowEditorPage β€” the head row, the canvas and the inspector beside it. */ +.notify-editor-head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75em; + margin-bottom: 0.5em; +} + +.notify-editor-body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1em; +} + +.notify-canvas { + flex: 1 1 24em; + min-width: 0; + max-height: 70vh; + overflow: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} + +.notify-edge { + fill: none; + stroke: var(--muted); + stroke-width: 1.5; +} + +.notify-box { + cursor: pointer; +} + +.notify-box rect { + fill: var(--bg-elev); + stroke: var(--border); + stroke-width: 1.5; +} + +.notify-box-source rect { + stroke: var(--blue); +} + +.notify-box-match rect { + stroke: var(--purple); +} + +.notify-box-todo rect { + stroke: var(--green); +} + +.notify-box:focus { + outline: none; +} + +.notify-box:focus rect, +.notify-box-selected rect { + stroke: var(--fg); + stroke-width: 2.5; +} + +.notify-box text { + fill: var(--fg); + font-size: 12px; + pointer-events: none; +} + +.notify-box .notify-box-kind { + fill: var(--muted); + font-size: 10px; +} + +.notify-inspector { + flex: 0 1 22em; + min-width: 16em; +} + +.notify-inspector fieldset { + margin: 0 0 0.75em; +} + +.notify-condition { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5em; + margin-bottom: 0.4em; +} + +.notify-inspector-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.75em; +} + +.notify-node-id, +.notify-hint { + color: var(--muted); + font-size: 0.85em; +} + +.notify-stale, +.notify-notice { + margin-bottom: 0.5em; + padding: 0.4em 0.6em; + border: 1px solid var(--red); + border-radius: 6px; +} + +.notify-name { + flex: 0 1 24em; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index aae28195..13774104 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -2,20 +2,29 @@ // 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. +// A flow draws as an indented tree, the shape the API validates it to. A +// built-in flow has a switch and can be duplicated; a custom flow is edited +// on its own page (), and a new one starts here. import { useState } from "preact/hooks"; +import { Link, useLocation } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { Panel } from "../../ui/panel/Panel.js"; +import { TextField } from "../../ui/text-field/TextField.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; +import { + duplicateFlow, + newFlow, + saveOutcome, + saveRequest, +} from "./FlowDraft.js"; import { describeNode, treeRows, @@ -56,6 +65,8 @@ export function NotificationsPage() { ); const [pending, setPending] = useState(false); const [actionError, setActionError] = useState(null); + const [newName, setNewName] = useState(""); + const [, navigate] = useLocation(); // Switch one built-in on or off: the whole document goes back with the // revision this page read (0 for none), so a change made elsewhere since is refused @@ -83,6 +94,37 @@ export function NotificationsPage() { } } + // Add `flow` to the custom flows and open it in the editor. Its id is + // made free of every flow's, the built-ins' too: the controller refuses a + // custom flow that shares a built-in's. + async function addFlow(make: (taken: string[]) => Flow) { + if (!view) return; + const taken = [...view.builtins, ...view.flows].map((f) => f.id); + const flow = make(taken); + setPending(true); + setActionError(null); + try { + const r = await fetch( + "/api/notify/flows", + saveRequest(view, [...view.flows, flow], view.disabled_builtins), + ); + if (saveOutcome(r.status) === "saved") { + setNewName(""); + navigate(`/notifications/${encodeURIComponent(flow.id)}`); + return; + } + setActionError(await readApiError(r)); + } catch (err) { + setActionError({ detail: String(err) }); + } finally { + setPending(false); + refresh(); + } + } + + const duplicate = (flow: Flow) => + addFlow((taken) => duplicateFlow(flow, `${flow.name} (copy)`, taken)); + return ( + ); })}

custom flows

{view.flows.length === 0 ?

no custom flows

: null} +
+ + +
{view.flows.map((flow) => (
{flow.name} {flow.enabled ? "on" : "off"} + + edit + +
diff --git a/frontend/packages/swarm-ui/test/flow-draft.test.mjs b/frontend/packages/swarm-ui/test/flow-draft.test.mjs index 9dcf985a..0e9682b1 100644 --- a/frontend/packages/swarm-ui/test/flow-draft.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-draft.test.mjs @@ -4,10 +4,12 @@ import { test } from "node:test"; import { addChild, blankNode, + DEFAULT_LIMITS, depthOf, duplicateFlow, heightBelow, isDirty, + limitsOf, newFlow, newNodeId, removeNode, @@ -233,3 +235,11 @@ test("saveOutcome tells a stale revision from a refused document", () => { assert.equal(saveOutcome(503), "error"); assert.equal(saveOutcome(500), "error"); }); + +test("limitsOf takes the controller's caps and falls back to today's", () => { + assert.deepEqual( + limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }), + { maxNodes: 5, maxDepth: 3, maxFlows: 9 }, + ); + assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS); +}); diff --git a/frontend/packages/swarm-ui/test/flow-layout.test.mjs b/frontend/packages/swarm-ui/test/flow-layout.test.mjs new file mode 100644 index 00000000..5a5e364b --- /dev/null +++ b/frontend/packages/swarm-ui/test/flow-layout.test.mjs @@ -0,0 +1,122 @@ +// Where the flow editor's canvas puts each node. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + layoutFlow, + NODE_HEIGHT, + NODE_WIDTH, +} from "../src/pages/notifications/FlowLayout.ts"; + +const match = (id) => ({ id, kind: "match", when: [] }); +const source = { id: "s", kind: "source", events: ["issues"] }; +const flow = (nodes, edges) => ({ + id: "f", + name: "f", + enabled: true, + nodes, + edges: edges.map(([from, to]) => ({ from, to })), +}); +const at = (layout, id) => layout.nodes.find((n) => n.id === id); + +test("a lone source sits top left", () => { + const l = layoutFlow(flow([source], [])); + assert.equal(l.nodes.length, 1); + assert.equal(at(l, "s").depth, 0); + assert.equal(l.edges.length, 0); + assert.ok(l.width > NODE_WIDTH && l.height > NODE_HEIGHT); +}); + +test("leaves take rows in edge order and a parent sits between its children", () => { + const l = layoutFlow( + flow( + [source, match("a"), match("b"), match("c")], + [ + ["s", "a"], + ["s", "b"], + ["a", "c"], + ], + ), + ); + // c (under a) is the first leaf, b the second; a follows c, s the middle. + assert.ok(at(l, "c").y < at(l, "b").y); + assert.equal(at(l, "a").y, at(l, "c").y); + assert.equal(at(l, "s").y, (at(l, "a").y + at(l, "b").y) / 2); + assert.equal(at(l, "c").depth, 2); + assert.ok(at(l, "c").x > at(l, "a").x && at(l, "a").x > at(l, "s").x); +}); + +test("no two nodes overlap", () => { + const nodes = [source]; + const edges = []; + for (let i = 0; i < 6; i += 1) { + nodes.push(match(`m${i}`), match(`n${i}`)); + edges.push(["s", `m${i}`], [`m${i}`, `n${i}`]); + } + const l = layoutFlow(flow(nodes, edges)); + for (const a of l.nodes) { + for (const b of l.nodes) { + if (a === b) continue; + const apart = + a.x + NODE_WIDTH <= b.x || + b.x + NODE_WIDTH <= a.x || + a.y + NODE_HEIGHT <= b.y || + b.y + NODE_HEIGHT <= a.y; + assert.ok(apart, `${a.id} overlaps ${b.id}`); + } + } +}); + +test("an edge runs from the parent's right side to the child's left", () => { + const l = layoutFlow(flow([source, match("a")], [["s", "a"]])); + const [e] = l.edges; + assert.equal(e.x1, at(l, "s").x + NODE_WIDTH); + assert.equal(e.x2, at(l, "a").x); + assert.equal(e.y1, at(l, "s").y + NODE_HEIGHT / 2); + assert.equal(e.y2, at(l, "a").y + NODE_HEIGHT / 2); +}); + +test("a node nothing reaches yet still gets a place, below the tree", () => { + const l = layoutFlow(flow([source, match("a"), match("lost")], [["s", "a"]])); + assert.equal(l.nodes.length, 3); + assert.ok(at(l, "lost").y > at(l, "a").y); + assert.equal(l.edges.length, 1); +}); + +test("edges to unknown nodes are left out, and a cycle terminates", () => { + const l = layoutFlow( + flow( + [source, match("a"), match("b")], + [ + ["s", "a"], + ["a", "b"], + ["b", "a"], + ["s", "ghost"], + ], + ), + ); + assert.equal(l.nodes.length, 3); + assert.ok(l.edges.every((e) => e.to !== "ghost")); +}); + +test("a node with two parents is placed once", () => { + const l = layoutFlow( + flow( + [source, match("a"), match("b"), match("x")], + [ + ["s", "a"], + ["s", "b"], + ["a", "x"], + ["b", "x"], + ], + ), + ); + assert.equal(l.nodes.length, 4); + const spots = new Set(l.nodes.map((n) => `${n.x},${n.y}`)); + assert.equal(spots.size, 4, "no two nodes share a spot"); +}); + +test("an empty flow has an empty layout", () => { + const l = layoutFlow(flow([], [])); + assert.deepEqual(l.nodes, []); + assert.ok(l.width >= 0 && l.height >= 0); +}); diff --git a/frontend/packages/swarm-ui/test/flow-types.test.mjs b/frontend/packages/swarm-ui/test/flow-types.test.mjs index 0a8319fe..8c4eea32 100644 --- a/frontend/packages/swarm-ui/test/flow-types.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { describeNode, + ellipsize, + kindValues, treeRows, withBuiltin, } from "../src/pages/notifications/FlowTypes.ts"; @@ -78,3 +80,28 @@ test("withBuiltin: switching off adds once, switching on removes", () => { assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]); assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]); }); + +test("ellipsize cuts long text and leaves short text alone", () => { + assert.equal(ellipsize("short", 10), "short"); + assert.equal(ellipsize("exactly10!", 10), "exactly10!"); + assert.equal(ellipsize("a rather long line", 10), "a rather …"); + assert.equal(ellipsize("a rather long line", 10).length, 10); +}); + +test("kindValues lists the values a schema allows for a field", () => { + const schema = { + node_kinds: [ + { + kind: "todo", + fields: [ + { name: "op", values: [{ name: "upsert" }, { name: "clear" }] }, + { name: "key", values: [] }, + ], + }, + ], + }; + assert.deepEqual(kindValues(schema, "todo", "op"), ["upsert", "clear"]); + assert.deepEqual(kindValues(schema, "todo", "key"), []); + assert.deepEqual(kindValues(schema, "match", "when"), []); + assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []); +});