diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 50067068..ea07eaea 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, every flow drawn as a tree, and an editor for custom flows | +| `/notifications` | the notification router's flows: a switch for each built-in, and every flow drawn as a tree | | `/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,45 +226,13 @@ 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. -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. +The page edits nothing else. Add custom flows with `PUT /api/notify/flows`. 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's below it to another match, and -**remove** takes a node and its branch out. The editor turns away any edit the -controller would refuse to save, and says why: 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 someone changed the flows -elsewhere in the meantime, the controller turns the save down as stale, and the -editor offers to reload and drop your edits, or to keep them and replace this -flow's latest version with them. For any other refusal the editor shows the -controller's own message. The router runs a saved flow from its next event. - -Closing or reloading the tab with unsaved edits asks first. Leaving through a -link in the app, or the browser's back button, drops the draft without asking. - ### 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 80786818..220f3e0e 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,7 +8,6 @@ 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"; @@ -46,7 +45,6 @@ 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 deleted file mode 100644 index 0905a107..00000000 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// β€” 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 1af89858..5918f401 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, FlowSchema, FlowsView } from "./FlowTypes.js"; +import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js"; export interface Limits { maxNodes: number; @@ -21,14 +21,6 @@ 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}$/; @@ -200,20 +192,6 @@ export function reparent( }; } -// A todo with its op switched. A clear carries no text, and only a clear may -// be a prefix, so each is reset where the new op has no place for it. -export function withOp( - node: Extract, - op: "upsert" | "clear", -): Extract { - return { - ...node, - op, - text: op === "clear" ? "" : node.text, - prefix: op === "clear" ? node.prefix : false, - }; -} - // The node with `next.id` swapped for `next`. A node keeps its place and its // kind-class: the source stays the source, and nothing with children becomes // a todo. diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx deleted file mode 100644 index e398f3c4..00000000 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ /dev/null @@ -1,263 +0,0 @@ -// β€” 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 deleted file mode 100644 index 523057c6..00000000 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts +++ /dev/null @@ -1,95 +0,0 @@ -// 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 cbe8cfa4..2cb58d34 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -35,8 +35,6 @@ 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 @@ -95,73 +93,3 @@ 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 deleted file mode 100644 index 3eec4668..00000000 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ /dev/null @@ -1,299 +0,0 @@ -// β€” 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, - withOp, -} 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(withOp(node, op as TodoNode["op"]))} - /> - - 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 c31f31fc..1d3287ca 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css @@ -44,132 +44,3 @@ .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 13774104..aae28195 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -2,29 +2,20 @@ // 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. // -// 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. +// 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 { 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, @@ -65,8 +56,6 @@ 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 @@ -94,37 +83,6 @@ 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 8e61db0a..9dcf985a 100644 --- a/frontend/packages/swarm-ui/test/flow-draft.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-draft.test.mjs @@ -4,12 +4,10 @@ import { test } from "node:test"; import { addChild, blankNode, - DEFAULT_LIMITS, depthOf, duplicateFlow, heightBelow, isDirty, - limitsOf, newFlow, newNodeId, removeNode, @@ -21,7 +19,6 @@ import { subtreeIds, withFlow, withoutFlow, - withOp, } from "../src/pages/notifications/FlowDraft.ts"; const todo = (id) => ({ @@ -157,16 +154,6 @@ test("replaceNode keeps the place and the kind class", () => { assert.equal(replaceNode(f, todo("nope")).ok, false); }); -test("withOp drops what the new op has no place for", () => { - const clearing = { ...todo("t"), op: "clear", prefix: true, text: "" }; - const up = withOp(clearing, "upsert"); - assert.equal(up.op, "upsert"); - assert.equal(up.prefix, false, "only a clear may be a prefix"); - const back = withOp({ ...todo("t"), prefix: false }, "clear"); - assert.equal(back.text, "", "a clear carries no text"); - assert.equal(withOp(clearing, "clear").prefix, true, "kept on a clear"); -}); - test("new ids do not collide", () => { const f = flow(); assert.equal(newNodeId(f, "match"), "match1"); @@ -246,11 +233,3 @@ 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 deleted file mode 100644 index 5a5e364b..00000000 --- a/frontend/packages/swarm-ui/test/flow-layout.test.mjs +++ /dev/null @@ -1,122 +0,0 @@ -// 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 8c4eea32..0a8319fe 100644 --- a/frontend/packages/swarm-ui/test/flow-types.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs @@ -3,8 +3,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { describeNode, - ellipsize, - kindValues, treeRows, withBuiltin, } from "../src/pages/notifications/FlowTypes.ts"; @@ -80,28 +78,3 @@ 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"), []); -});