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

@ -10,6 +10,9 @@ import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
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({
kind,
text,
@ -33,7 +36,7 @@ export function BoxFace({
<>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x={PAD} y="17">
{kind}
{SHORT_KIND[kind] ?? kind}
</text>
{aside ? (
<text

View file

@ -66,12 +66,15 @@ export function FlowCanvas({
selected,
limits,
palette,
hits,
...edits
}: {
flow: Flow;
selected: string | null;
limits: Limits;
palette: PaletteKind[];
// The events of a preview that reached each node, when one is showing.
hits?: Map<string, number>;
} & CanvasEdits) {
const layout = layoutFlow(flow);
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
@ -338,7 +341,7 @@ export function FlowCanvas({
tabIndex={p.id === tabStop ? 0 : -1}
role="button"
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) => {
if (node.kind === "source") {
// The source cannot move; a press just picks it.
@ -359,6 +362,7 @@ export function FlowCanvas({
order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
}
hits={hits ? (hits.get(p.id) ?? 0) : undefined}
/>
</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";
// What a save's HTTP status means to the editor: 409 is a stale revision (read

View file

@ -38,6 +38,7 @@ import {
reparent,
savedAs,
saveOutcome,
previewRequest,
saveRequest,
seedFlow,
parentOf,
@ -48,6 +49,7 @@ import {
import { useLeaveGuard } from "./useLeaveGuard.js";
import { NodeInspector } from "./NodeInspector.js";
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
import "./NotificationsPage.css";
const FLOWS_URL = "/api/notify/flows";
@ -75,6 +77,14 @@ export function FlowEditorPage({
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
const [stale, setStale] = useState(false);
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 [confirmDelete, setConfirmDelete] = useState(false);
@ -148,6 +158,32 @@ export function FlowEditorPage({
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) {
if (!result.ok) {
setNotice(result.reason);
@ -217,6 +253,8 @@ export function FlowEditorPage({
const schema = view.schema ?? FALLBACK_SCHEMA;
const limits = limitsOf(view.schema);
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 (
<Panel
@ -241,6 +279,12 @@ export function FlowEditorPage({
{saveError ? (
<ApiErrorPanel context="the flow was not saved" problem={saveError} />
) : null}
{previewError ? (
<ApiErrorPanel
context="the preview did not run"
problem={previewError}
/>
) : null}
{notice ? (
<p class="notify-notice" role="status">
{notice}
@ -288,6 +332,9 @@ export function FlowEditorPage({
>
discard edits
</Button>
<Button disabled={busy} onClick={() => void runPreview()}>
preview
</Button>
{isNew ? null : (
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
@ -295,6 +342,13 @@ export function FlowEditorPage({
)}
</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">
<FlowCanvas
@ -302,6 +356,7 @@ export function FlowEditorPage({
selected={selected}
limits={limits}
palette={paletteKinds(schema)}
hits={hits}
onSelect={setSelected}
onAdd={(kind, parentId) => {
const added = blankNode(draft, kind);

View file

@ -1,8 +1,8 @@
// 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
// swarm-controller's `GET /api/notify/deliveries` and
// `GET /api/notify/dry-run`, which answer 503 while the running router
// predates them.
// itself: its deliveries log and a dry run of the saved flows, or with
// `POST` of a draft. Backed by swarm-controller's
// `GET /api/notify/deliveries` and `GET`/`POST /api/notify/dry-run`, which
// answer 503 while the running router predates them.
import type { ProblemDetails } from "@hive/shared/api-error.js";
export const DELIVERIES_URL = "/api/notify/deliveries";

View file

@ -21,6 +21,7 @@ import {
replaceNode,
savedAs,
saveOutcome,
previewRequest,
saveRequest,
seedFlow,
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");
});
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", () => {
assert.equal(saveOutcome(200), "saved");
assert.equal(saveOutcome(409), "conflict");