Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: edit a notification flow on a canvas

A custom flow opens at /notifications/<id>: its nodes drawn left to right
(source, then matches, then todos), and a panel to edit the picked node: the
source's events, a match's conditions, a todo's recipients, op, key and text.
Add a match or todo below a node, move a branch under another match, or remove
it; an edit the controller would refuse (a todo with children, a second
parent, a cycle, over the node or depth cap) is turned away with the reason.

Edits stay in a draft until save, which sends the whole document at the
revision read. A stale revision is told apart from a refused document: the
editor offers to reload or to keep the draft on top of the latest. The form
choices come from the controller's schema when it sends one.

The list page gains edit and duplicate on custom flows, duplicate on the
built-ins, and a new-flow box; a new flow's id avoids the built-ins' too.
This commit is contained in:
iris 2026-10-24 14:00:44 +02:00 • committed by mara
commit f28e1e9063
13 changed files with 1209 additions and 6 deletions

View file

@ -19,7 +19,7 @@ domain, covers host-level detail for one hive.
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/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 | | `/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 | | `/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 | | `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/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 | | `/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 flow draws as an indented tree: the forge events it starts from, the
conditions under it, and the todo each branch ends in. 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 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 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 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. the page shows why. Reload to switch against the current document.
#### Editing a flow
`/notifications/<flow id>` 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 ### Sending a message to an agent
Under an agent's terminal, in the preview and in its own tab, is a **message** Under an agent's terminal, in the preview and in its own tab, is a **message**

View file

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

View file

@ -0,0 +1,74 @@
// <FlowCanvas> — 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 (
<div class="notify-canvas">
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="group"
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
>
{layout.edges.map((e) => {
const mid = (e.x1 + e.x2) / 2;
return (
<path
key={`${e.from}>${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 (
<g
key={p.id}
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
transform={`translate(${p.x} ${p.y})`}
tabIndex={0}
role="button"
aria-pressed={p.id === selected}
aria-label={`${node.kind} ${p.id}: ${text}`}
onClick={() => onSelect(p.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(p.id);
}
}}
>
<title>{text}</title>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17">
{node.kind}
</text>
<text class="notify-box-text" x="10" y="35">
{ellipsize(text, TEXT_CHARS)}
</text>
</g>
);
})}
</svg>
</div>
);
}

View file

@ -5,7 +5,7 @@
// these edits only refuse what could never be saved, so the canvas can // 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 // 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. // 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 { export interface Limits {
maxNodes: number; maxNodes: number;
@ -21,6 +21,14 @@ export const DEFAULT_LIMITS: Limits = {
maxFlows: 100, 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 }; export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
const ID = /^[A-Za-z0-9_-]{1,64}$/; const ID = /^[A-Za-z0-9_-]{1,64}$/;

View file

@ -0,0 +1,263 @@
// <FlowEditorPage> — 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<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
const [stale, setStale] = useState(false);
const [notice, setNotice] = useState<string | null>(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<FlowsView>(
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<boolean> {
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 = <Link href="/notifications">all flows</Link>;
if (loadError) {
return (
<Panel title="notification flow" icon="🔔" actions={back}>
<ApiErrorPanel context="failed to load the flows" problem={loadError} />
</Panel>
);
}
if (!view) {
return (
<Panel title="notification flow" icon="🔔" actions={back}>
<p>loading…</p>
</Panel>
);
}
if (!draft) {
const builtin = view.builtins.some((b) => b.id === flowId);
return (
<Panel title="notification flow" icon="🔔" actions={back}>
<p>
{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.`}
</p>
</Panel>
);
}
const schema = view.schema ?? FALLBACK_SCHEMA;
const limits = limitsOf(view.schema);
const node = draft.nodes.find((n) => n.id === selected) ?? null;
return (
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
{stale ? (
<div class="notify-stale" role="alert">
<p>
The flows were changed somewhere else since you opened this one, so
this was not saved.
</p>
<Button onClick={() => void load(false)}>
reload and drop my edits
</Button>
<Button onClick={() => void load(true)}>
keep my edits on top of the latest
</Button>
</div>
) : null}
{saveError ? (
<ApiErrorPanel context="the flow was not saved" problem={saveError} />
) : null}
{notice ? (
<p class="notify-notice" role="status">
{notice}
</p>
) : null}
<div class="notify-editor-head">
<div class="notify-name">
<TextField
id="flow-name"
label="name"
value={draft.name}
onInput={(name) => setDraft({ ...draft, name })}
/>
</div>
<CheckboxField
id="flow-enabled"
label="on"
checked={draft.enabled}
onChange={(enabled) => setDraft({ ...draft, enabled })}
/>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void put(withFlow(view.flows, draft))}
>
save
</Button>
<Button
disabled={!dirty || busy}
onClick={() => {
const saved = view.flows.find((f) => f.id === flowId);
if (saved) setDraft(structuredClone(saved));
setNotice(null);
}}
>
discard edits
</Button>
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
</Button>
</div>
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<div class="notify-editor-body">
<FlowCanvas flow={draft} selected={selected} onSelect={setSelected} />
{node ? (
<NodeInspector
flow={draft}
node={node}
schema={schema}
limits={limits}
onEdit={onEdit}
/>
) : null}
</div>
<ConfirmDialog
open={confirmDelete}
label="delete this flow"
confirmLabel="delete"
onCancel={() => setConfirmDelete(false)}
onConfirm={() => void onDelete()}
>
<p>Delete the flow {draft.name}? The router stops running it.</p>
</ConfirmDialog>
</Panel>
);
}

View file

@ -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<string, string[]>();
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<string, PlacedNode>();
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,
};
}

View file

@ -35,6 +35,8 @@ export interface FlowsView {
disabled_builtins: string[]; disabled_builtins: string[];
builtins: Flow[]; builtins: Flow[];
flows: 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 // 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); const rest = disabled.filter((d) => d !== id);
return (enabled ? rest : [...rest, id]).sort(); 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) : [];
}

View file

@ -0,0 +1,305 @@
// <NodeInspector> — 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<FlowNode, { kind: "todo" }>;
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 (
<div class="notify-inspector">
<h4>
{node.kind}{" "}
{node.id !== node.kind ? (
<span class="notify-node-id">{node.id}</span>
) : null}
</h4>
{node.kind === "source" ? (
<fieldset>
<legend>forge events</legend>
{schema.source_events.map((ev) => (
<CheckboxField
key={ev.name}
id={`${key}-ev-${ev.name}`}
label={
ev.description ? `${ev.name} — ${ev.description}` : ev.name
}
checked={node.events.includes(ev.name)}
onChange={(on) =>
set({
...node,
events: on
? [...node.events, ev.name]
: node.events.filter((e) => e !== ev.name),
})
}
/>
))}
</fieldset>
) : null}
{node.kind === "match" ? (
<MatchFields
node={node}
keyPrefix={key}
fieldOptions={fieldOptions}
firstField={firstField}
onChange={set}
/>
) : null}
{node.kind === "todo" ? (
<TodoFields
node={node}
keyPrefix={key}
schema={schema}
fieldOptions={fieldOptions}
firstField={firstField}
onChange={set}
/>
) : null}
<div class="notify-inspector-actions">
{node.kind !== "todo" ? (
<>
<Button onClick={() => add("match")}>add a match below</Button>
<Button onClick={() => add("todo")}>add a todo below</Button>
</>
) : null}
{node.kind !== "source" ? (
<Button
onClick={() =>
onEdit(removeNode(flow, node.id), parent ?? undefined)
}
>
remove
{flow.edges.some((e) => e.from === node.id)
? " with its branch"
: ""}
</Button>
) : null}
</div>
{node.kind !== "source" && parent !== undefined ? (
<SelectField
id={`${key}-parent`}
label="move under"
value={parent}
options={parents}
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
/>
) : null}
</div>
);
}
function MatchFields({
node,
keyPrefix,
fieldOptions,
firstField,
onChange,
}: {
node: Extract<FlowNode, { kind: "match" }>;
keyPrefix: string;
fieldOptions: { value: string; label: string }[];
firstField: string;
onChange: (next: FlowNode) => void;
}) {
const setWhen = (when: Condition[]) => onChange({ ...node, when });
return (
<fieldset>
<legend>passes when all of these hold</legend>
{node.when.length === 0 ? <p>no condition: passes everything</p> : null}
{node.when.map((c, i) => (
<div class="notify-condition" key={i}>
<SelectField
id={`${keyPrefix}-c${i}-field`}
label="field"
value={c.field}
options={fieldOptions}
onChange={(field) =>
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
}
/>
<TextField
id={`${keyPrefix}-c${i}-equals`}
label="equals"
value={c.equals}
onInput={(equals) =>
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
}
/>
<Button onClick={() => setWhen(node.when.filter((_, j) => j !== i))}>
remove condition
</Button>
</div>
))}
<Button
onClick={() =>
setWhen([...node.when, { field: firstField, equals: "" }])
}
>
add a condition
</Button>
</fieldset>
);
}
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 (
<fieldset>
<legend>emits a todo</legend>
<SelectField
id={`${keyPrefix}-op`}
label="op"
value={node.op}
options={opList}
onChange={(op) =>
onChange({
...node,
op: op as TodoNode["op"],
// A clear carries no text.
text: op === "clear" ? "" : node.text,
})
}
/>
<SelectField
id={`${keyPrefix}-to-kind`}
label="to"
value={byField ? "field" : "agent"}
options={[
{ value: "field", label: "the agents an event field names" },
{ value: "agent", label: "one agent" },
]}
onChange={(kind) =>
onChange({
...node,
to: kind === "field" ? { field: firstField } : { agent: "" },
})
}
/>
{"field" in node.to ? (
<SelectField
id={`${keyPrefix}-to-field`}
label="event field"
value={node.to.field}
options={fieldOptions}
onChange={(field) => onChange({ ...node, to: { field } })}
/>
) : (
<TextField
id={`${keyPrefix}-to-agent`}
label="agent"
value={node.to.agent}
onInput={(agent) => onChange({ ...node, to: { agent } })}
/>
)}
<TextField
id={`${keyPrefix}-key`}
label="todo key"
value={node.key}
onInput={(k) => onChange({ ...node, key: k })}
/>
{node.op === "clear" ? (
<CheckboxField
id={`${keyPrefix}-prefix`}
label="the key is a prefix"
checked={node.prefix}
onChange={(prefix) => onChange({ ...node, prefix })}
/>
) : (
<TextAreaField
id={`${keyPrefix}-text`}
label="todo text"
value={node.text}
rows={3}
onInput={(text) => onChange({ ...node, text })}
/>
)}
<p class="notify-hint">The key and text can use {hint}</p>
</fieldset>
);
}

View file

@ -44,3 +44,132 @@
.notify-off { .notify-off {
opacity: 0.6; 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;
}

View file

@ -2,20 +2,29 @@
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only // 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. // 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 // A flow draws as an indented tree, the shape the API validates it to. A
// indented tree, the shape the API validates it to. Custom flows are not // built-in flow has a switch and can be duplicated; a custom flow is edited
// editable here: they arrive through the API. // on its own page (<FlowEditorPage>), and a new one starts here.
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Link, useLocation } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.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 { Card } from "../../ui/card/Card.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js"; } from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import {
duplicateFlow,
newFlow,
saveOutcome,
saveRequest,
} from "./FlowDraft.js";
import { import {
describeNode, describeNode,
treeRows, treeRows,
@ -56,6 +65,8 @@ export function NotificationsPage() {
); );
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState<ProblemDetails | null>(null); const [actionError, setActionError] = useState<ProblemDetails | null>(null);
const [newName, setNewName] = useState("");
const [, navigate] = useLocation();
// Switch one built-in on or off: the whole document goes back with the // 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 // 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 ( return (
<Panel <Panel
title="notifications" title="notifications"
@ -123,16 +165,42 @@ export function NotificationsPage() {
/> />
</div> </div>
<FlowTree flow={flow} /> <FlowTree flow={flow} />
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate to edit
</Button>
</Card> </Card>
); );
})} })}
<h3>custom flows</h3> <h3>custom flows</h3>
{view.flows.length === 0 ? <p>no custom flows</p> : null} {view.flows.length === 0 ? <p>no custom flows</p> : null}
<div class="notify-new">
<TextField
id="notify-new-name"
label="new flow"
placeholder="name"
value={newName}
onInput={setNewName}
/>
<Button
disabled={pending || newName.trim() === ""}
onClick={() =>
void addFlow((taken) => newFlow(newName.trim(), taken))
}
>
create
</Button>
</div>
{view.flows.map((flow) => ( {view.flows.map((flow) => (
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}> <Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
<div class="notify-flow-head"> <div class="notify-flow-head">
<span class="notify-flow-name">{flow.name}</span> <span class="notify-flow-name">{flow.name}</span>
<span>{flow.enabled ? "on" : "off"}</span> <span>{flow.enabled ? "on" : "off"}</span>
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
edit
</Link>
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate
</Button>
</div> </div>
<FlowTree flow={flow} /> <FlowTree flow={flow} />
</Card> </Card>

View file

@ -4,10 +4,12 @@ import { test } from "node:test";
import { import {
addChild, addChild,
blankNode, blankNode,
DEFAULT_LIMITS,
depthOf, depthOf,
duplicateFlow, duplicateFlow,
heightBelow, heightBelow,
isDirty, isDirty,
limitsOf,
newFlow, newFlow,
newNodeId, newNodeId,
removeNode, removeNode,
@ -233,3 +235,11 @@ test("saveOutcome tells a stale revision from a refused document", () => {
assert.equal(saveOutcome(503), "error"); assert.equal(saveOutcome(503), "error");
assert.equal(saveOutcome(500), "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);
});

View file

@ -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);
});

View file

@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
describeNode, describeNode,
ellipsize,
kindValues,
treeRows, treeRows,
withBuiltin, withBuiltin,
} from "../src/pages/notifications/FlowTypes.ts"; } 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(["c"], "a", false), ["a", "c"]);
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]); 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"), []);
});