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
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue