Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
13 changed files with 271 additions and 408 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 |
| `/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 graph, and an editor for custom flows |
| `/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 |
@ -223,25 +223,20 @@ routes.
in two groups. Each **built-in** flow has an on/off switch; switching one off
stops the router running it from its next event, and switching it on again
restores it. Each **custom** flow shows its name and whether it's on. Every
flow draws as a graph, the one the editor shows but read-only: the forge events
it starts from on the left, the conditions after them, and the todo or review
request each branch ends in. A box cuts a long line of text with an ellipsis;
hover over the box to read all of it.
flow draws as an indented tree: the forge events it starts from, the
conditions under it, and the todo or review request each branch ends in.
A built-in flow has a **duplicate to edit** button and a custom flow has **edit**
and **duplicate** buttons. **create flow**, at the top of the page beside the
refresh interval, starts a custom flow with just a source. Creating and
duplicating open the editor on a draft and make nothing: the flow exists once
you **save** it there. Leaving the draft without saving, or discarding it, leaves
the flows as they were.
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.
Each box in a graph says how many of the router's newest 50 forge events reached
it (**12 hits**, say), from a dry run of the saved flows (`GET /api/notify/dry-run`).
Beside each node in the trees, a number says how many of the router's newest 50
forge events reached it, from a dry run of the saved flows (`GET /api/notify/dry-run`).
Below the flows, **recent deliveries** lists what the router delivered, newest
first: when, which flow and node, the event and issue, who got it, and whether it
went through (`GET /api/notify/deliveries`). Both come from the router; while the
@ -251,10 +246,7 @@ works. See [deliveries and dry runs](notify-router.md#deliveries-and-dry-runs).
#### Editing a flow
`/notifications/<flow id>` edits one custom flow, and `/notifications/draft/new` makes
one: it opens on an empty flow, or on a copy of a flow with `?from=<flow id>`, as
**create flow** and **duplicate** do. A new flow takes its id from its name when
you save it, and **save** needs a name. The canvas draws the flow left
`/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 leaf nodes at
the ends. Pick a node to edit it in the panel beside the canvas:

View file

@ -9,7 +9,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 { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
@ -49,9 +48,6 @@ export function App() {
<Route path="/broadcast" component={BroadcastPage} />
<Route path="/messages" component={MessagesPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path={NEW_FLOW_PATH}>
<FlowEditorPage params={{ flowId: "" }} isNew />
</Route>
<Route path="/notifications/:flowId" component={FlowEditorPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/review" component={ReviewPage} />

View file

@ -1,58 +0,0 @@
// <BoxFace> — what one node's box shows: its kind, when it runs among its
// siblings, how many events reached it, and what it does. The editor's canvas
// and the list's read-only graph both draw their boxes with it, inside a
// `<g>` of their own that carries the pointer and key handling.
//
// The line of text is cut by the browser to the box's own width, so a long
// description ends in an ellipsis inside the box instead of running out of it;
// the full text is the box's tooltip.
import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
const PAD = 10;
export function BoxFace({
kind,
text,
order,
hits,
}: {
kind: string;
text: string;
// "runs 2nd" when the node has siblings, else nothing.
order?: string;
// The events of the last dry run that reached the node, when one covers it.
hits?: number;
}) {
const aside = [
order,
hits === undefined ? undefined : `${hits} ${hits === 1 ? "hit" : "hits"}`,
]
.filter((part) => part !== undefined)
.join(" · ");
return (
<>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x={PAD} y="17">
{kind}
</text>
{aside ? (
<text
class="notify-box-kind notify-box-order"
x={NODE_WIDTH - PAD}
y="17"
text-anchor="end"
>
{aside}
</text>
) : null}
<foreignObject
x={PAD}
y="23"
width={NODE_WIDTH - 2 * PAD}
height={NODE_HEIGHT - 25}
>
<div class="notify-box-text">{text}</div>
</foreignObject>
</>
);
}

View file

@ -21,10 +21,16 @@ import {
type Edit,
type Limits,
} from "./FlowDraft.js";
import { BoxFace } from "./FlowBox.js";
import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js";
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
import {
layoutFlow,
neighbour,
nodeAt,
NODE_HEIGHT,
NODE_WIDTH,
} from "./FlowLayout.js";
import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js";
const TEXT_CHARS = 30;
// How far the pointer must travel before a press counts as a drag.
const DRAG_PX = 4;
@ -353,13 +359,23 @@ export function FlowCanvas({
onKeyDown={(e) => onKey(e, p.id)}
>
<title>{text}</title>
<BoxFace
kind={node.kind}
text={text}
order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
}
/>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17">
{node.kind}
</text>
{pos.count > 1 ? (
<text
class="notify-box-kind notify-box-order"
x={NODE_WIDTH - 10}
y="17"
text-anchor="end"
>
{`runs ${ordinal(pos.index + 1)}`}
</text>
) : null}
<text class="notify-box-text" x="10" y="35">
{ellipsize(text, TEXT_CHARS)}
</text>
</g>
);
})}

View file

@ -356,38 +356,6 @@ export function duplicateFlow(
});
}
// The editor's route for a flow nobody has saved yet. It has a segment more
// than a flow's own route, so no flow's id can reach it.
export const NEW_FLOW_PATH = "/notifications/draft/new";
// The id a draft carries until it is saved (see `savedAs`).
export const NEW_FLOW_ID = "new";
function savedIds(view: FlowsView): string[] {
return [...view.builtins.map((f) => f.id), ...view.flows.map((f) => f.id)];
}
// The draft the editor opens for a flow not saved yet: an empty one, or a copy
// of the built-in or custom flow `from`. Nothing is sent until it is saved, so
// the id is a placeholder (see `savedAs`). Null when `from` names no flow.
export function seedFlow(view: FlowsView, from: string | null): Flow | null {
const taken = savedIds(view);
if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID };
const source = [...view.builtins, ...view.flows].find((f) => f.id === from);
if (!source) return null;
return {
...duplicateFlow(source, `${source.name} (copy)`, taken),
id: NEW_FLOW_ID,
};
}
// The draft as it is saved: with an id made of its name that no flow has,
// the built-ins' included (the controller refuses a custom flow that shares a
// built-in's).
export function savedAs(view: FlowsView, draft: Flow): Flow {
return { ...draft, id: slugId(draft.name, savedIds(view)) };
}
// `flows` with `flow` in place of the one of its id, or added last.
export function withFlow(flows: Flow[], flow: Flow): Flow[] {
return flows.some((f) => f.id === flow.id)

View file

@ -3,21 +3,12 @@
// 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 same page makes a flow: `NEW_FLOW_PATH` is an empty draft, and
// `?from=<id>` a copy of a flow to start from. Nothing exists until Save, which
// gives the flow an id made of its name.
//
// 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,
useSearch,
type RouteComponentProps,
} from "wouter-preact";
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";
@ -36,10 +27,8 @@ import {
paletteKinds,
removeNode,
reparent,
savedAs,
saveOutcome,
saveRequest,
seedFlow,
parentOf,
withFlow,
withoutFlow,
@ -58,18 +47,11 @@ function rootOf(flow: Flow): string | null {
export function FlowEditorPage({
params,
isNew = false,
}: RouteComponentProps<{ flowId: string }> & {
// The page for a flow not saved yet, which has no flow id.
isNew?: boolean;
}) {
}: RouteComponentProps<{ flowId: string }>) {
const flowId = decodeURIComponent(params.flowId);
const from = new URLSearchParams(useSearch()).get("from");
const [, navigate] = useLocation();
const [view, setView] = useState<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(null);
// What a new draft started as, so leaving it untouched is not "unsaved".
const [seed, setSeed] = 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);
@ -92,10 +74,7 @@ export function FlowEditorPage({
setStale(false);
setSaveError(null);
if (!keepDraft) {
const flow = isNew
? seedFlow(next, from)
: next.flows.find((f) => f.id === flowId);
setSeed(isNew && flow ? structuredClone(flow) : null);
const flow = next.flows.find((f) => f.id === flowId);
setDraft(flow ? structuredClone(flow) : null);
setSelected(flow ? rootOf(flow) : null);
}
@ -105,7 +84,7 @@ export function FlowEditorPage({
);
}
},
[flowId, isNew, from],
[flowId],
);
useEffect(() => {
void load(false);
@ -114,9 +93,7 @@ export function FlowEditorPage({
const dirty =
view !== null &&
draft !== null &&
(isNew
? JSON.stringify(draft) !== JSON.stringify(seed)
: isDirty(view, withFlow(view.flows, draft), view.disabled_builtins));
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
// Leaving the page with edits not saved asks first, whether by closing the
// tab, following a link or pressing back.
@ -161,18 +138,6 @@ export function FlowEditorPage({
}
}
// Save the draft. A new flow is added under its name's id, and the page
// moves on to editing it.
async function save() {
if (!view || !draft) return;
const flow = isNew ? savedAs(view, draft) : draft;
if (!(await put(withFlow(view.flows, flow))) || !isNew) return;
leaving.release();
navigate(`/notifications/${encodeURIComponent(flow.id)}`, {
replace: true,
});
}
async function onDelete() {
if (!view) return;
setConfirmDelete(false);
@ -204,11 +169,9 @@ export function FlowEditorPage({
return (
<Panel title="notification flow" icon="🔔" actions={back}>
<p>
{isNew
? `There is no flow ${from} to copy; it may have been deleted.`
: 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.`}
{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>
);
@ -219,11 +182,7 @@ export function FlowEditorPage({
const node = draft.nodes.find((n) => n.id === selected) ?? null;
return (
<Panel
title={isNew ? "new flow" : `flow ${draft.id}`}
icon="🔔"
actions={back}
>
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
{stale ? (
<div class="notify-stale" role="alert">
<p>
@ -253,7 +212,6 @@ export function FlowEditorPage({
id="flow-name"
label="name"
value={draft.name}
placeholder="what the flow is for"
onInput={(name) => setDraft({ ...draft, name })}
/>
</div>
@ -263,38 +221,28 @@ export function FlowEditorPage({
checked={draft.enabled}
onChange={(enabled) => setDraft({ ...draft, enabled })}
/>
<div class="notify-editor-actions">
{dirty ? (
<span class="notify-hint" role="status">
not saved yet
</span>
) : null}
<Button
variant="primary"
disabled={(!isNew && !dirty) || busy || draft.name.trim() === ""}
onClick={() => void save()}
>
save
</Button>
<Button
disabled={!dirty || busy}
onClick={() => {
const start = isNew
? seed
: view.flows.find((f) => f.id === flowId);
if (start) setDraft(structuredClone(start));
setNotice(null);
}}
>
discard edits
</Button>
{isNew ? null : (
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
</Button>
)}
</div>
<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

View file

@ -1,66 +0,0 @@
// <FlowGraph> — a flow drawn as boxes and lines, to look at: the source on the
// left, its branches to the right. The list shows each flow with it; the
// editor's <FlowCanvas> draws the same picture and adds the rewiring.
//
// `hits`, when the dry run covers this flow, puts the number of the newest
// events that reached each node in its box.
import { BoxFace } from "./FlowBox.js";
import { layoutFlow } from "./FlowLayout.js";
import { siblingPosition } from "./FlowDraft.js";
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
export function FlowGraph({
flow,
hits,
}: {
flow: Flow;
hits?: Map<string, number>;
}) {
const layout = layoutFlow(flow);
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
return (
<div class="notify-canvas notify-graph">
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
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);
const pos = siblingPosition(flow, p.id);
return (
<g
key={p.id}
class={`notify-box notify-box-${node.kind}`}
transform={`translate(${p.x} ${p.y})`}
>
<title>{text}</title>
<BoxFace
kind={node.kind}
text={text}
order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
}
hits={hits ? (hits.get(node.id) ?? 0) : undefined}
/>
</g>
);
})}
</svg>
</div>
);
}

View file

@ -40,6 +40,32 @@ export interface FlowsView {
schema?: FlowSchema;
}
// One node of a flow with its depth below the source, in the order a reader
// walks the tree: a parent, then each child's subtree in edge order.
export interface TreeRow {
node: FlowNode;
depth: number;
}
export function treeRows(flow: Flow): TreeRow[] {
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
const root = flow.nodes.find((n) => n.kind === "source");
if (!root) return [];
const rows: TreeRow[] = [];
const seen = new Set<string>();
const walk = (id: string, depth: number) => {
const node = byId.get(id);
// The controller refuses a flow that is not a tree; a repeat here would
// mean a cycle, which `seen` stops from looping.
if (!node || seen.has(id)) return;
seen.add(id);
rows.push({ node, depth });
for (const e of flow.edges) if (e.from === id) walk(e.to, depth + 1);
};
walk(root.id, 0);
return rows;
}
export function describeRecipients(to: Recipients): string {
return "field" in to ? `{${to.field}}` : to.agent;
}
@ -135,6 +161,11 @@ export function ordinal(n: number): string {
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
}
// `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(

View file

@ -3,7 +3,6 @@
// remove it. Every change goes out as an `Edit` of the flow; the editor shows
// the reason when one is refused. New nodes come from the canvas palette.
import { Button } from "../../ui/button/Button.js";
import { Card } from "../../ui/card/Card.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";
@ -75,7 +74,7 @@ export function NodeInspector({
const order = siblingPosition(flow, node.id);
return (
<Card class="notify-inspector">
<div class="notify-inspector">
<h4>
{node.kind}{" "}
{node.id !== node.kind ? (
@ -191,7 +190,7 @@ export function NodeInspector({
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
/>
) : null}
</Card>
</div>
);
}

View file

@ -1,4 +1,4 @@
/* NotificationsPage — the flows, each drawn as its graph. */
/* NotificationsPage — the flows drawn as indented trees. */
.notify-flows {
display: flex;
@ -15,49 +15,55 @@
}
.notify-flow-name {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
overflow-wrap: anywhere;
}
.notify-flow-state {
.notify-flow-tree {
margin: 0.4em 0 0;
padding: 0;
list-style: none;
font-size: 0.9em;
}
.notify-node {
display: flex;
gap: 0.5em;
padding: 0.1em 0;
}
.notify-node-kind {
flex: none;
min-width: 3.5em;
color: var(--muted);
}
.notify-flow-actions,
.notify-panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
}
.notify-graph {
margin-top: 0.4em;
.notify-node-text {
overflow-wrap: anywhere;
}
.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 1em;
margin-bottom: 1em;
padding-bottom: 1em;
border-bottom: 1px solid var(--border);
}
.notify-editor-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-left: auto;
gap: 0.75em;
margin-bottom: 0.5em;
}
.notify-editor-body {
@ -202,41 +208,13 @@
font-size: 10px;
}
/* The box's line of text is cut by the browser at the box's own edge. */
.notify-box-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-size: 12px;
line-height: 16px;
pointer-events: none;
}
.notify-inspector {
flex: 0 1 22em;
min-width: 16em;
gap: 0.75em;
}
.notify-inspector h4 {
margin: 0;
}
.notify-inspector fieldset {
display: flex;
flex-direction: column;
gap: 0.6em;
margin: 0;
padding: 0.6em 0.8em 0.8em;
border: 1px solid var(--border);
border-radius: 6px;
}
.notify-inspector legend {
padding: 0 0.4em;
color: var(--muted);
font-size: 0.9em;
margin: 0 0 0.75em;
}
.notify-condition {
@ -272,7 +250,14 @@
flex: 0 1 24em;
}
/* The dry run's header line, and the router's "not available". */
/* The dry run's counts beside each node, and the router's "not available". */
.notify-node-hits {
margin-left: auto;
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.notify-dry-run {
margin: 0;
color: var(--muted);

View file

@ -2,27 +2,37 @@
// 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 its graph, read-only. A built-in flow has a switch and can
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
// A new flow and a duplicate both open that page on a draft: nothing is created
// until it is saved there.
// 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 (<FlowEditorPage>), and a new one starts here.
import { useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
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 { Deliveries } from "./Deliveries.js";
import { NEW_FLOW_PATH } from "./FlowDraft.js";
import { FlowGraph } from "./FlowGraph.js";
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
import {
duplicateFlow,
newFlow,
saveOutcome,
saveRequest,
} from "./FlowDraft.js";
import {
describeNode,
treeRows,
withBuiltin,
type Flow,
type FlowsView,
} from "./FlowTypes.js";
import {
DELIVERIES_URL,
DRY_RUN_URL,
@ -35,6 +45,30 @@ import "./NotificationsPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
// `hits`, when the dry run covers this flow, puts the number of the newest events
// that reached each node beside it.
function FlowTree({ flow, hits }: { flow: Flow; hits?: Map<string, number> }) {
return (
<ul class="notify-flow-tree">
{treeRows(flow).map(({ node, depth }) => (
<li
class="notify-node"
key={node.id}
style={{ paddingLeft: `${depth * 1.5}em` }}
>
<span class="notify-node-kind">{node.kind}</span>
<span class="notify-node-text">{describeNode(node)}</span>
{hits ? (
<span class="notify-node-hits" title="events of the last dry run">
{hits.get(node.id) ?? 0}
</span>
) : null}
</li>
))}
</ul>
);
}
export function NotificationsPage() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -59,6 +93,7 @@ export function NotificationsPage() {
const shown = dryError ? null : dry;
const [pending, setPending] = useState(false);
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
@ -87,29 +122,47 @@ export function NotificationsPage() {
}
}
// Open the editor on a draft: empty, or a copy of the flow `from`.
const draftFrom = (from?: string) =>
navigate(
from === undefined
? NEW_FLOW_PATH
: `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`,
);
// 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 (
<Panel
title="notifications"
icon="🔔"
actions={
<div class="notify-panel-actions">
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
<Button variant="primary" onClick={() => draftFrom()}>
create flow
</Button>
</div>
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{error ? (
@ -147,37 +200,46 @@ export function NotificationsPage() {
disabled={pending}
onChange={(checked) => setBuiltin(flow.id, checked)}
/>
<div class="notify-flow-actions">
<Button onClick={() => draftFrom(flow.id)}>
duplicate to edit
</Button>
</div>
</div>
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate to edit
</Button>
</Card>
);
})}
<h3>custom flows</h3>
{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) => (
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
<div class="notify-flow-head">
<span class="notify-flow-name">{flow.name}</span>
<span class="notify-flow-state">
{flow.enabled ? "on" : "off"}
</span>
<div class="notify-flow-actions">
<Button
onClick={() =>
navigate(`/notifications/${encodeURIComponent(flow.id)}`)
}
>
edit
</Button>
<Button onClick={() => draftFrom(flow.id)}>duplicate</Button>
</div>
<span>{flow.enabled ? "on" : "off"}</span>
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
edit
</Link>
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate
</Button>
</div>
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
</Card>
))}
<h3>recent deliveries</h3>

View file

@ -12,17 +12,14 @@ import {
isLeafKind,
limitsOf,
moveAmongSiblings,
NEW_FLOW_ID,
newFlow,
newNodeId,
paletteKinds,
removeNode,
reparent,
replaceNode,
savedAs,
saveOutcome,
saveRequest,
seedFlow,
siblingPosition,
slugId,
subtreeIds,
@ -354,38 +351,3 @@ test("moveAmongSiblings swaps two branches and leaves other edges alone", () =>
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
});
test("seedFlow opens an empty or copied draft under the placeholder id", () => {
const view = {
revision: 1,
disabled_builtins: [],
builtins: [{ ...flow(), id: "assigned", name: "Assigned" }],
flows: [{ ...flow(), id: "mine", name: "Mine" }],
};
const empty = seedFlow(view, null);
assert.equal(empty.id, NEW_FLOW_ID);
assert.equal(empty.name, "");
assert.deepEqual(
empty.nodes.map((n) => n.kind),
["source"],
);
const copy = seedFlow(view, "assigned");
assert.equal(copy.id, NEW_FLOW_ID);
assert.equal(copy.name, "Assigned (copy)");
assert.equal(copy.nodes.length, flow().nodes.length);
assert.equal(seedFlow(view, "gone"), null);
assert.equal(view.flows.length, 1, "nothing is added to the view");
});
test("savedAs takes the id from the name, clear of every saved id", () => {
const view = {
revision: 1,
disabled_builtins: [],
builtins: [{ ...flow(), id: "assigned" }],
flows: [{ ...flow(), id: "mine" }],
};
const draft = { ...flow(), id: NEW_FLOW_ID };
assert.equal(savedAs(view, { ...draft, name: "Closed" }).id, "closed");
assert.equal(savedAs(view, { ...draft, name: "Assigned" }).id, "assigned-2");
assert.equal(savedAs(view, { ...draft, name: "mine" }).id, "mine-2");
});

View file

@ -3,8 +3,10 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
describeNode,
ellipsize,
kindValues,
ordinal,
treeRows,
withBuiltin,
} from "../src/pages/notifications/FlowTypes.ts";
@ -43,6 +45,25 @@ const flow = {
],
};
test("treeRows: each subtree follows its parent, with its depth", () => {
assert.deepEqual(
treeRows(flow).map((r) => [r.node.id, r.depth]),
[
["s", 0],
["m", 1],
["t", 2],
["m2", 1],
["u", 2],
],
);
});
test("treeRows: a cycle ends the walk and a flow with no source is empty", () => {
const cyclic = { ...flow, edges: [...flow.edges, { from: "t", to: "s" }] };
assert.equal(treeRows(cyclic).length, 5);
assert.deepEqual(treeRows({ ...flow, nodes: [] }), []);
});
test("describeNode: says what each kind does", () => {
assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign");
assert.equal(describeNode(flow.nodes[1]), "action = closed");
@ -69,6 +90,13 @@ test("withBuiltin: switching off adds once, switching on removes", () => {
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: [