Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: preview a draft flow's hit counts in the editor with the dry-run route

The editor's preview button posts the draft to POST /api/notify/dry-run and
puts each box's hit count on the canvas; any edit hides the numbers. Boxes of
kind request_reviewer label themselves "reviewer", so the long kind name no
longer runs into the hit count shown beside it.
This commit is contained in:
iris 2026-10-25 01:57:29 +02:00 • committed by mara
commit 404d8334aa
7 changed files with 97 additions and 6 deletions

View file

@ -296,6 +296,12 @@ editor offers to reload and drop your edits, or to keep them and replace this
flow's latest version with them. For any other refusal the editor shows the flow's latest version with them. For any other refusal the editor shows the
controller's own message. The router runs a saved flow from its next event. controller's own message. The router runs a saved flow from its next event.
**preview** runs the draft, saved or not, over the newest forge events and
shows on each box how many of them reached it, as a dry run does for the saved
flows in the list. It needs no save, so you can try a half-built flow. The
numbers go as soon as you edit the flow. If the draft would not save, or the
router can't run a dry run yet, the controller's own message shows instead.
Leaving the page with unsaved edits asks first, whether you close or reload the Leaving the page with unsaved edits asks first, whether you close or reload the
tab, follow a link in the app, or press the browser's back button. Choose tab, follow a link in the app, or press the browser's back button. Choose
**leave and drop edits** to go on, or cancel to stay and save. **leave and drop edits** to go on, or cancel to stay and save.

View file

@ -10,6 +10,9 @@ import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
const PAD = 10; const PAD = 10;
// The long kind name would run into the aside beside it.
const SHORT_KIND: Record<string, string> = { request_reviewer: "reviewer" };
export function BoxFace({ export function BoxFace({
kind, kind,
text, text,
@ -33,7 +36,7 @@ export function BoxFace({
<> <>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" /> <rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x={PAD} y="17"> <text class="notify-box-kind" x={PAD} y="17">
{kind} {SHORT_KIND[kind] ?? kind}
</text> </text>
{aside ? ( {aside ? (
<text <text

View file

@ -66,12 +66,15 @@ export function FlowCanvas({
selected, selected,
limits, limits,
palette, palette,
hits,
...edits ...edits
}: { }: {
flow: Flow; flow: Flow;
selected: string | null; selected: string | null;
limits: Limits; limits: Limits;
palette: PaletteKind[]; palette: PaletteKind[];
// The events of a preview that reached each node, when one is showing.
hits?: Map<string, number>;
} & CanvasEdits) { } & CanvasEdits) {
const layout = layoutFlow(flow); const layout = layoutFlow(flow);
const byId = new Map(flow.nodes.map((n) => [n.id, n])); const byId = new Map(flow.nodes.map((n) => [n.id, n]));
@ -338,7 +341,7 @@ export function FlowCanvas({
tabIndex={p.id === tabStop ? 0 : -1} tabIndex={p.id === tabStop ? 0 : -1}
role="button" role="button"
aria-pressed={p.id === selected} aria-pressed={p.id === selected}
aria-label={`${node.kind} ${p.id}: ${text}${order}`} aria-label={`${node.kind} ${p.id}: ${text}${order}${hits ? `, ${hits.get(p.id) ?? 0} hits` : ""}`}
onPointerDown={(e) => { onPointerDown={(e) => {
if (node.kind === "source") { if (node.kind === "source") {
// The source cannot move; a press just picks it. // The source cannot move; a press just picks it.
@ -359,6 +362,7 @@ export function FlowCanvas({
order={ order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
} }
hits={hits ? (hits.get(p.id) ?? 0) : undefined}
/> />
</g> </g>
); );

View file

@ -429,6 +429,19 @@ export function saveRequest(
}; };
} }
// The `POST /api/notify/dry-run` request for the draft: the same document a
// save sends, without a revision since nothing is stored.
export function previewRequest(
flows: Flow[],
disabledBuiltins: string[],
): { method: "POST"; headers: Record<string, string>; body: string } {
return {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ disabled_builtins: disabledBuiltins, flows }),
};
}
export type SaveOutcome = "saved" | "conflict" | "invalid" | "error"; export type SaveOutcome = "saved" | "conflict" | "invalid" | "error";
// What a save's HTTP status means to the editor: 409 is a stale revision (read // What a save's HTTP status means to the editor: 409 is a stale revision (read

View file

@ -38,6 +38,7 @@ import {
reparent, reparent,
savedAs, savedAs,
saveOutcome, saveOutcome,
previewRequest,
saveRequest, saveRequest,
seedFlow, seedFlow,
parentOf, parentOf,
@ -48,6 +49,7 @@ import {
import { useLeaveGuard } from "./useLeaveGuard.js"; import { useLeaveGuard } from "./useLeaveGuard.js";
import { NodeInspector } from "./NodeInspector.js"; import { NodeInspector } from "./NodeInspector.js";
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js"; import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
import "./NotificationsPage.css"; import "./NotificationsPage.css";
const FLOWS_URL = "/api/notify/flows"; const FLOWS_URL = "/api/notify/flows";
@ -75,6 +77,14 @@ export function FlowEditorPage({
const [saveError, setSaveError] = useState<ProblemDetails | null>(null); const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
const [stale, setStale] = useState(false); const [stale, setStale] = useState(false);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
// The last preview and the draft it ran, which stops counting once the
// draft is edited.
const [preview, setPreview] = useState<{
draft: Flow;
flowId: string;
dry: DryRun;
} | null>(null);
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
@ -148,6 +158,32 @@ export function FlowEditorPage({
return false; return false;
} }
// Run the draft, saved or not, over the newest forge events, and put the
// number each node would have caught on its box.
async function runPreview() {
if (!view || !draft) return;
const flow = isNew ? savedAs(view, draft) : draft;
setBusy(true);
setPreviewError(null);
try {
const r = await fetch(
DRY_RUN_URL,
previewRequest(withFlow(view.flows, flow), view.disabled_builtins),
);
if (r.ok) {
setPreview({ draft, flowId: flow.id, dry: (await r.json()) as DryRun });
} else {
setPreview(null);
setPreviewError(await readApiError(r));
}
} catch (err) {
setPreview(null);
setPreviewError({ detail: String(err) });
} finally {
setBusy(false);
}
}
function onEdit(result: Edit, select?: string) { function onEdit(result: Edit, select?: string) {
if (!result.ok) { if (!result.ok) {
setNotice(result.reason); setNotice(result.reason);
@ -217,6 +253,8 @@ export function FlowEditorPage({
const schema = view.schema ?? FALLBACK_SCHEMA; const schema = view.schema ?? FALLBACK_SCHEMA;
const limits = limitsOf(view.schema); const limits = limitsOf(view.schema);
const node = draft.nodes.find((n) => n.id === selected) ?? null; const node = draft.nodes.find((n) => n.id === selected) ?? null;
const shown = preview?.draft === draft ? preview : null;
const hits = shown ? hitsOf(shown.dry, shown.flowId) : undefined;
return ( return (
<Panel <Panel
@ -241,6 +279,12 @@ export function FlowEditorPage({
{saveError ? ( {saveError ? (
<ApiErrorPanel context="the flow was not saved" problem={saveError} /> <ApiErrorPanel context="the flow was not saved" problem={saveError} />
) : null} ) : null}
{previewError ? (
<ApiErrorPanel
context="the preview did not run"
problem={previewError}
/>
) : null}
{notice ? ( {notice ? (
<p class="notify-notice" role="status"> <p class="notify-notice" role="status">
{notice} {notice}
@ -288,6 +332,9 @@ export function FlowEditorPage({
> >
discard edits discard edits
</Button> </Button>
<Button disabled={busy} onClick={() => void runPreview()}>
preview
</Button>
{isNew ? null : ( {isNew ? null : (
<Button disabled={busy} onClick={() => setConfirmDelete(true)}> <Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow delete flow
@ -295,6 +342,13 @@ export function FlowEditorPage({
)} )}
</div> </div>
</div> </div>
{shown ? (
<p class="notify-hint" role="status">
{hits
? `Preview: each box shows how many of the newest ${shown.dry.events} forge events reached it. Edit the flow and the numbers go.`
: "The preview skipped this flow because it is off."}
</p>
) : null}
<div class="notify-editor-body"> <div class="notify-editor-body">
<FlowCanvas <FlowCanvas
@ -302,6 +356,7 @@ export function FlowEditorPage({
selected={selected} selected={selected}
limits={limits} limits={limits}
palette={paletteKinds(schema)} palette={paletteKinds(schema)}
hits={hits}
onSelect={setSelected} onSelect={setSelected}
onAdd={(kind, parentId) => { onAdd={(kind, parentId) => {
const added = blankNode(draft, kind); const added = blankNode(draft, kind);

View file

@ -1,8 +1,8 @@
// Wire types and helpers for what the notification router reports about // Wire types and helpers for what the notification router reports about
// itself: its deliveries log and a dry run of the saved flows. Backed by // itself: its deliveries log and a dry run of the saved flows, or with
// swarm-controller's `GET /api/notify/deliveries` and // `POST` of a draft. Backed by swarm-controller's
// `GET /api/notify/dry-run`, which answer 503 while the running router // `GET /api/notify/deliveries` and `GET`/`POST /api/notify/dry-run`, which
// predates them. // answer 503 while the running router predates them.
import type { ProblemDetails } from "@hive/shared/api-error.js"; import type { ProblemDetails } from "@hive/shared/api-error.js";
export const DELIVERIES_URL = "/api/notify/deliveries"; export const DELIVERIES_URL = "/api/notify/deliveries";

View file

@ -21,6 +21,7 @@ import {
replaceNode, replaceNode,
savedAs, savedAs,
saveOutcome, saveOutcome,
previewRequest,
saveRequest, saveRequest,
seedFlow, seedFlow,
siblingPosition, siblingPosition,
@ -246,6 +247,15 @@ test("saveRequest sends the whole document at the revision read", () => {
assert.equal("builtins" in body, false, "built-ins are never sent"); assert.equal("builtins" in body, false, "built-ins are never sent");
}); });
test("previewRequest posts the document without a revision", () => {
const req = previewRequest([flow()], ["b1"]);
assert.equal(req.method, "POST");
const body = JSON.parse(req.body);
assert.deepEqual(body.disabled_builtins, ["b1"]);
assert.equal(body.flows[0].id, "f");
assert.equal("revision" in body, false, "nothing is stored, so no revision");
});
test("saveOutcome tells a stale revision from a refused document", () => { test("saveOutcome tells a stale revision from a refused document", () => {
assert.equal(saveOutcome(200), "saved"); assert.equal(saveOutcome(200), "saved");
assert.equal(saveOutcome(409), "conflict"); assert.equal(saveOutcome(409), "conflict");