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:
parent
e8538d8690
commit
404d8334aa
7 changed files with 97 additions and 6 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue