Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e7b5f84e9 | ||
|
|
d0a55502a8 |
13 changed files with 408 additions and 271 deletions
|
|
@ -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, every flow drawn as a graph, 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,20 +223,25 @@ 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 an indented tree: the forge events it starts from, the
|
||||
conditions under it, and the todo or review request each branch ends in.
|
||||
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.
|
||||
|
||||
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 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 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.
|
||||
|
||||
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`).
|
||||
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`).
|
||||
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
|
||||
|
|
@ -246,7 +251,10 @@ works. See [deliveries and dry runs](notify-router.md#deliveries-and-dry-runs).
|
|||
|
||||
#### Editing a flow
|
||||
|
||||
`/notifications/<flow id>` edits one custom flow. The canvas draws the flow left
|
||||
`/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
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AlertsPage } from "./pages/AlertsPage.js";
|
|||
import { BuildsPage } from "./pages/builds/BuildsPage.js";
|
||||
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||
import { 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";
|
||||
|
|
@ -48,6 +49,9 @@ 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} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
// <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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,16 +21,10 @@ import {
|
|||
type Edit,
|
||||
type Limits,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
layoutFlow,
|
||||
neighbour,
|
||||
nodeAt,
|
||||
NODE_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
} from "./FlowLayout.js";
|
||||
import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js";
|
||||
import { BoxFace } from "./FlowBox.js";
|
||||
import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js";
|
||||
import { describeNode, 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;
|
||||
|
||||
|
|
@ -359,23 +353,13 @@ export function FlowCanvas({
|
|||
onKeyDown={(e) => onKey(e, 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>
|
||||
{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>
|
||||
<BoxFace
|
||||
kind={node.kind}
|
||||
text={text}
|
||||
order={
|
||||
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -356,6 +356,38 @@ 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)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,21 @@
|
|||
// 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, type RouteComponentProps } from "wouter-preact";
|
||||
import {
|
||||
Link,
|
||||
useLocation,
|
||||
useSearch,
|
||||
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";
|
||||
|
|
@ -27,8 +36,10 @@ import {
|
|||
paletteKinds,
|
||||
removeNode,
|
||||
reparent,
|
||||
savedAs,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
seedFlow,
|
||||
parentOf,
|
||||
withFlow,
|
||||
withoutFlow,
|
||||
|
|
@ -47,11 +58,18 @@ function rootOf(flow: Flow): string | null {
|
|||
|
||||
export function FlowEditorPage({
|
||||
params,
|
||||
}: RouteComponentProps<{ flowId: string }>) {
|
||||
isNew = false,
|
||||
}: RouteComponentProps<{ flowId: string }> & {
|
||||
// The page for a flow not saved yet, which has no flow id.
|
||||
isNew?: boolean;
|
||||
}) {
|
||||
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);
|
||||
|
|
@ -74,7 +92,10 @@ export function FlowEditorPage({
|
|||
setStale(false);
|
||||
setSaveError(null);
|
||||
if (!keepDraft) {
|
||||
const flow = next.flows.find((f) => f.id === flowId);
|
||||
const flow = isNew
|
||||
? seedFlow(next, from)
|
||||
: next.flows.find((f) => f.id === flowId);
|
||||
setSeed(isNew && flow ? structuredClone(flow) : null);
|
||||
setDraft(flow ? structuredClone(flow) : null);
|
||||
setSelected(flow ? rootOf(flow) : null);
|
||||
}
|
||||
|
|
@ -84,7 +105,7 @@ export function FlowEditorPage({
|
|||
);
|
||||
}
|
||||
},
|
||||
[flowId],
|
||||
[flowId, isNew, from],
|
||||
);
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
|
|
@ -93,7 +114,9 @@ export function FlowEditorPage({
|
|||
const dirty =
|
||||
view !== null &&
|
||||
draft !== null &&
|
||||
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
|
||||
(isNew
|
||||
? JSON.stringify(draft) !== JSON.stringify(seed)
|
||||
: 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.
|
||||
|
|
@ -138,6 +161,18 @@ 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);
|
||||
|
|
@ -169,9 +204,11 @@ export function FlowEditorPage({
|
|||
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.`}
|
||||
{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.`}
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
|
|
@ -182,7 +219,11 @@ export function FlowEditorPage({
|
|||
const node = draft.nodes.find((n) => n.id === selected) ?? null;
|
||||
|
||||
return (
|
||||
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
|
||||
<Panel
|
||||
title={isNew ? "new flow" : `flow ${draft.id}`}
|
||||
icon="🔔"
|
||||
actions={back}
|
||||
>
|
||||
{stale ? (
|
||||
<div class="notify-stale" role="alert">
|
||||
<p>
|
||||
|
|
@ -212,6 +253,7 @@ export function FlowEditorPage({
|
|||
id="flow-name"
|
||||
label="name"
|
||||
value={draft.name}
|
||||
placeholder="what the flow is for"
|
||||
onInput={(name) => setDraft({ ...draft, name })}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -221,28 +263,38 @@ export function FlowEditorPage({
|
|||
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 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>
|
||||
</div>
|
||||
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
|
||||
|
||||
<div class="notify-editor-body">
|
||||
<FlowCanvas
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,32 +40,6 @@ 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;
|
||||
}
|
||||
|
|
@ -161,11 +135,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 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";
|
||||
|
|
@ -74,7 +75,7 @@ export function NodeInspector({
|
|||
const order = siblingPosition(flow, node.id);
|
||||
|
||||
return (
|
||||
<div class="notify-inspector">
|
||||
<Card class="notify-inspector">
|
||||
<h4>
|
||||
{node.kind}{" "}
|
||||
{node.id !== node.kind ? (
|
||||
|
|
@ -190,7 +191,7 @@ export function NodeInspector({
|
|||
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* NotificationsPage — the flows drawn as indented trees. */
|
||||
/* NotificationsPage — the flows, each drawn as its graph. */
|
||||
|
||||
.notify-flows {
|
||||
display: flex;
|
||||
|
|
@ -15,55 +15,49 @@
|
|||
}
|
||||
|
||||
.notify-flow-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.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;
|
||||
.notify-flow-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notify-node-text {
|
||||
overflow-wrap: anywhere;
|
||||
.notify-flow-actions,
|
||||
.notify-panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.notify-graph {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.notify-editor-body {
|
||||
|
|
@ -208,13 +202,41 @@
|
|||
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 {
|
||||
margin: 0 0 0.75em;
|
||||
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;
|
||||
}
|
||||
|
||||
.notify-condition {
|
||||
|
|
@ -250,14 +272,7 @@
|
|||
flex: 0 1 24em;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* The dry run's header line, and the router's "not available". */
|
||||
.notify-dry-run {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
|
|
|
|||
|
|
@ -2,37 +2,27 @@
|
|||
// 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 (<FlowEditorPage>), and a new one starts here.
|
||||
// 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.
|
||||
import { useState } from "preact/hooks";
|
||||
import { Link, useLocation } from "wouter-preact";
|
||||
import { 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 {
|
||||
duplicateFlow,
|
||||
newFlow,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
describeNode,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
type Flow,
|
||||
type FlowsView,
|
||||
} from "./FlowTypes.js";
|
||||
import { NEW_FLOW_PATH } from "./FlowDraft.js";
|
||||
import { FlowGraph } from "./FlowGraph.js";
|
||||
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
|
||||
import {
|
||||
DELIVERIES_URL,
|
||||
DRY_RUN_URL,
|
||||
|
|
@ -45,30 +35,6 @@ 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);
|
||||
|
|
@ -93,7 +59,6 @@ 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
|
||||
|
|
@ -122,47 +87,29 @@ 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));
|
||||
// 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)}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
icon="🔔"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<div class="notify-panel-actions">
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => draftFrom()}>
|
||||
create flow
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
|
|
@ -200,46 +147,37 @@ 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>
|
||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
</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>{flow.enabled ? "on" : "off"}</span>
|
||||
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
|
||||
edit
|
||||
</Link>
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
</Card>
|
||||
))}
|
||||
<h3>recent deliveries</h3>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ import {
|
|||
isLeafKind,
|
||||
limitsOf,
|
||||
moveAmongSiblings,
|
||||
NEW_FLOW_ID,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
paletteKinds,
|
||||
removeNode,
|
||||
reparent,
|
||||
replaceNode,
|
||||
savedAs,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
seedFlow,
|
||||
siblingPosition,
|
||||
slugId,
|
||||
subtreeIds,
|
||||
|
|
@ -351,3 +354,38 @@ 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ import assert from "node:assert/strict";
|
|||
import { test } from "node:test";
|
||||
import {
|
||||
describeNode,
|
||||
ellipsize,
|
||||
kindValues,
|
||||
ordinal,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
} from "../src/pages/notifications/FlowTypes.ts";
|
||||
|
||||
|
|
@ -45,25 +43,6 @@ 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");
|
||||
|
|
@ -90,13 +69,6 @@ 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: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue