Files
ScadaBridge/docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html
Joseph Doherty 3a933e3f68 docs(plans): Flow Debugger outbound calls — plan + mockup for API/DB/notification nodes on an External lane
Gap verified 2026-09-06: the projector and predictor already know the three outbound
channels, but the live FlowTraceCollector is fed only by EmitTrace (writes/waits) and
the replay query filters to ScriptTrace + the inbound row, so a run leaves every
predicted outbound node as a ghost. Central method-script Database.* calls are not
audited at all.

Plan (11 tasks): three step kinds ApiCall/DbCall/Notify on a fourth External lane;
one node per operation with the cached/notify lifecycle folded in (Pending/Parked
outcomes, Lifecycle tab); sync calls in the chain, async ones on dashed branch edges;
site rows ride the existing collector, replay filter widens, central DB helper gets
DbOutbound rows; the page refreshes pending outcomes via the existing Replay command;
spawn edges styled and Response linked from the route call (the "two parallel paths"
question). Mockup: docs/plans/…-mockup.html, published as a Claude artifact.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 01:33:41 -04:00

329 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Flow Debugger External Lane</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root {
--ground: #f4f3ee; --surface: #fbfaf7; --surface-2: #f0efe9;
--ink: #1f231f; --ink-soft: #5d625d; --ink-faint: #8a8f89;
--rule: #dcdbd3; --rule-strong: #c7c6bd;
--accent: #2f5fd0; --active-bg: #eef2fc;
--ok: #2e7d4a; --ok-bg: #e4f2e8; --warn: #b8741a; --warn-bg: #fbf0dc; --danger: #b3362d; --danger-bg: #f9e3e0;
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
}
:root[data-theme="dark"] {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
.page { max-width: 1180px; margin: 0 auto; padding: 28px 24px 56px; display: flex; flex-direction: column; gap: 22px; }
h1 { font-size: 1.45rem; font-weight: 600; margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 8px; }
p { margin: 0; max-width: 68ch; }
.lede { color: var(--ink-soft); }
.eyebrow { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); }
/* ── the mocked screen ─────────────────────────────────────────────── */
.screen { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 14px 22px; align-items: flex-end; }
.field label { display: block; font-size: 0.72rem; color: var(--ink-soft); margin-bottom: 3px; }
.seg { display: inline-flex; border: 1px solid var(--rule-strong); border-radius: 4px; overflow: hidden; }
.seg span { padding: 4px 10px; font-size: 0.8rem; color: var(--ink-soft); background: var(--surface); }
.seg span.on { background: var(--accent); color: #fff; }
.select { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 28px 4px 8px; font-size: 0.8rem; background: var(--surface) linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%) no-repeat right 12px top 55% / 6px 6px; min-width: 190px; }
.btn { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 12px; font-size: 0.8rem; background: var(--surface); color: var(--ink); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.spacer { flex: 1; }
.status { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; padding: 8px 12px; border: 1px solid var(--rule); border-radius: 4px; background: var(--surface-2); font-size: 0.8rem; }
.status .k { color: var(--ink-faint); font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; margin-right: 6px; }
.chip { border: 1px solid var(--ok); color: var(--ok); border-radius: 999px; padding: 1px 10px; font-size: 0.76rem; font-weight: 500; }
.note { color: var(--warn); display: inline-flex; align-items: center; gap: 6px; }
.note .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 var(--warn-bg);} 50% { opacity: .6; box-shadow: 0 0 0 5px var(--warn-bg);} }
@media (prefers-reduced-motion: reduce) { .note .dot, .fg-node.pending .pill { animation: none !important; } }
.flow-wrap { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); overflow-x: auto; }
svg.fg { display: block; width: 1370px; height: 456px; font-family: var(--sans); }
.lane-bg.alt { fill: var(--rule); fill-opacity: .25; } .lane-bg { fill: transparent; }
.lane-rule { stroke: var(--rule); stroke-width: 1; }
.lane-label { fill: var(--ink-soft); font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
.lane-sub { fill: var(--ink-faint); font-size: 10.5px; font-family: var(--mono); }
.fg-node { cursor: pointer; }
.fg-node rect { fill: var(--surface); stroke: var(--rule-strong); stroke-width: 1; }
.fg-node.selected rect { stroke: var(--accent); stroke-width: 2; fill: var(--active-bg); }
.body { box-sizing: border-box; width: 100%; height: 100%; padding: 6px 8px 5px; display: flex; flex-direction: column; gap: 1px; overflow: hidden; line-height: 1.2; color: var(--ink); font-family: var(--sans); }
.head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.eb { color: var(--ink-faint); font-size: 9.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; }
.dur { color: var(--ink-soft); font-size: 9.5px; font-family: var(--mono); white-space: nowrap; }
.title { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sub { color: var(--ink-soft); font-size: 10.5px; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pill { margin-top: auto; align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 500; padding: 1px 7px; border-radius: 999px; white-space: nowrap; }
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: var(--ok-bg); }
.pill.pending { color: var(--warn); background: var(--warn-bg); animation: pulse 1.6s ease-in-out infinite; }
.pill.parked { color: var(--warn); background: var(--warn-bg); }
.pill.failed { color: var(--danger); background: var(--danger-bg); }
.edge { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; }
.edge.boundary { stroke: var(--accent); }
.edge.async { stroke-dasharray: 5 4; }
.edge.echo { stroke-dasharray: 2 3; }
.arrow { fill: var(--ink-faint); } .arrow.accent { fill: var(--accent); }
.async-tag { fill: var(--ink-faint); font-size: 9.5px; font-family: var(--mono); }
/* ── detail panel ─────────────────────────────────────────────────── */
.detail { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); padding: 14px 16px; display: grid; grid-template-columns: 1fr 300px; gap: 18px 28px; }
.detail .hd { grid-column: 1 / -1; display: flex; align-items: flex-start; gap: 12px; }
.detail .hd h3 { margin: 2px 0 0; font-size: 1rem; font-weight: 600; }
.detail .hd .who { color: var(--ink-soft); font-size: 0.8rem; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--rule); margin-bottom: 10px; }
.tabs span { padding: 5px 12px; font-size: 0.82rem; color: var(--accent); }
.tabs span.on { color: var(--ink); border: 1px solid var(--rule); border-bottom-color: var(--surface); border-radius: 4px 4px 0 0; margin-bottom: -1px; background: var(--surface); }
table.life { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.life th { text-align: left; font-weight: 500; color: var(--ink-faint); font-size: 0.68rem; letter-spacing: .06em; text-transform: uppercase; padding: 4px 8px; border-bottom: 1px solid var(--rule); }
table.life td { padding: 6px 8px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.life tr:last-child td { border-bottom: 0; }
.kv { display: grid; grid-template-columns: 110px 1fr; gap: 6px 10px; font-size: 0.8rem; align-items: baseline; }
.kv .k { font-weight: 500; color: var(--ink-soft); }
.kv .v { font-family: var(--mono); font-size: 0.78rem; }
.btnrow { display: flex; gap: 8px; margin-top: 12px; }
.btn.sm { font-size: 0.74rem; padding: 3px 9px; }
/* ── explanatory sections ──────────────────────────────────────────── */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
@media (max-width: 860px) { .grid2, .detail { grid-template-columns: 1fr; } }
.legend { display: grid; grid-template-columns: 34px 1fr; gap: 8px 12px; align-items: center; font-size: 0.82rem; }
.legend svg { display: block; }
.legend .pill { margin: 0; font-size: 11px; }
table.rows { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.rows th { text-align: left; font-weight: 500; color: var(--ink-faint); font-size: 0.68rem; letter-spacing: .06em; text-transform: uppercase; padding: 4px 8px 6px; border-bottom: 1px solid var(--rule-strong); }
table.rows td { padding: 6px 8px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.rows code { font-family: var(--mono); font-size: 0.76rem; }
.table-wrap { overflow-x: auto; }
ol.plan { margin: 0; padding-left: 22px; display: grid; gap: 5px; font-size: 0.84rem; }
ol.plan b { font-weight: 600; }
.callout { border-left: 3px solid var(--accent); background: var(--surface-2); padding: 10px 14px; font-size: 0.84rem; border-radius: 0 4px 4px 0; }
</style>
<div class="page">
<header>
<div class="eyebrow">Flow Debugger · design addendum · 2026-09-06</div>
<h1>Outbound calls on a fourth lane</h1>
<p class="lede">What the swimlane looks like once <code class="mono">ExternalSystem</code>, <code class="mono">Database</code> and <code class="mono">Notify</code> calls become nodes. The flow below is the rig demo the plan seeds (<code class="mono">FlowOutboundDemo</code> → <code class="mono">DelmiaSim01.OutboundDemo</code>): one synchronous REST call, one cached database write, one notification.</p>
</header>
<!-- ══ the mocked page ══ -->
<section class="screen" aria-label="Flow Debugger mockup">
<div class="toolbar">
<div class="field"><label>Entry point</label><span class="seg"><span class="on">Inbound API method</span><span>Value-change trigger</span></span></div>
<div class="field"><label>Method</label><span class="select">FlowOutboundDemo</span></div>
<div class="field"><label>Mode</label><span class="seg"><span class="on">Live</span><span>Replay</span></span></div>
<div class="spacer"></div>
<span class="btn">Refresh outcomes</span>
<span class="btn primary">Run</span>
</div>
<div class="status">
<span class="chip">Completed</span>
<span><span class="k">Started</span><span class="mono">05:31:02.118Z</span></span>
<span><span class="k">Total</span><span class="mono">371 ms</span></span>
<span><span class="k">Steps</span><span class="mono">9</span></span>
<span><span class="k">Boundaries</span><span class="mono">2</span></span>
<span><span class="k">Writes</span><span class="mono">2</span></span>
<span><span class="k">Pending</span><span class="mono">1</span></span>
<span class="note"><span class="dot"></span>outcomes still arriving — refreshing every 5 s (1:40 left)</span>
</div>
<div class="flow-wrap">
<svg class="fg" id="fg" viewBox="0 0 1370 456" role="img" aria-label="Flow diagram: 9 steps on four lanes, 2 boundary crossings, 1 pending outbound call">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow"/></marker>
<marker id="ar-acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow accent"/></marker>
</defs>
</svg>
</div>
<!-- detail panel: the cached DB write is selected -->
<div class="detail">
<div class="hd">
<div>
<div class="eyebrow">DbCall · External</div>
<h3>RigDb · CachedWrite</h3>
<div class="who">DelmiaSim01 · OutboundDemo</div>
</div>
<div class="spacer"></div>
<span class="pill ok">Delivered</span>
</div>
<div>
<div class="tabs"><span>SQL + parameters</span><span>Result</span><span class="on">Lifecycle</span><span>Timing</span></div>
<table class="life">
<thead><tr><th>Time</th><th>Row</th><th>Status</th><th>Node</th><th>Error</th></tr></thead>
<tbody>
<tr><td class="mono">05:31:02.301</td><td>CachedSubmit</td><td>Submitted</td><td class="mono">site-a / node-a</td><td>—</td></tr>
<tr><td class="mono">+0.9 s</td><td>DbWriteCached</td><td>Forwarded</td><td class="mono">node-a</td><td>—</td></tr>
<tr><td class="mono">+4.1 s</td><td>CachedResolve</td><td>Delivered</td><td class="mono">node-a</td><td>—</td></tr>
</tbody>
</table>
</div>
<div>
<div class="eyebrow" style="margin-bottom:8px">Where this ran</div>
<div class="kv">
<span class="k">Site / node</span><span class="v">site-a / node-a</span>
<span class="k">Instance</span><span class="v">DelmiaSim01</span>
<span class="k">Connection</span><span class="v">RigDb</span>
<span class="k">Operation</span><span class="v">7c2e91d4…</span>
<span class="k">Delivered by</span><span class="v">node-a · +4.1 s</span>
</div>
<div class="btnrow"><span class="btn sm">Open in Debug View</span><span class="btn sm">Audit rows (3)</span></div>
</div>
</div>
</section>
<!-- ══ legend + node anatomy ══ -->
<section class="grid2">
<div>
<h2>Connectors and pills</h2>
<div class="legend">
<svg width="34" height="12"><path d="M0 6H30" class="edge" marker-end="url(#ar)"/></svg><span>Executed edge — the run continued here.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge boundary" marker-end="url(#ar-acc)"/></svg><span>Crossed the central ↔ site gRPC boundary.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge async" marker-end="url(#ar)"/></svg><span><b>New.</b> Async branch — <code class="mono">CachedCall</code> / <code class="mono">CachedWrite</code> / <code class="mono">Notify.Send</code> returned at once; the run carried on without it.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge echo" marker-end="url(#ar)"/></svg><span>Inferred device echo (a write that satisfied a wait).</span>
<svg width="34" height="14"><path d="M0 7H26" class="edge" /><path d="M20 1l-4 6h5l-3 6 7-8h-5l3-4z" class="arrow"/></svg><span><b>New.</b> Trigger spawn — this write fired a value-change script.</span>
<span class="pill ok">Ok</span><span>Sync call answered, or cached operation delivered.</span>
<span class="pill pending">Pending</span><span>Submitted / forwarded, not yet resolved. The page keeps refreshing for up to two minutes after a Live run.</span>
<span class="pill parked">Parked</span><span>Retries exhausted or permanent failure downstream; retry or discard from Site Calls / Notifications.</span>
<span class="pill failed">Failed</span><span>A sync call failed and the script received the error.</span>
</div>
</div>
<div>
<h2>What changed in the graph</h2>
<div class="callout">
<p style="max-width:none"><b>External is a lane, not a caller-side node.</b> Like Tags, it holds actors outside ScadaBridge — a REST endpoint, a database, the mail relay — so a hop that leaves the system reads as one. Its gutter lists the targets touched.</p>
<p style="max-width:none;margin-top:8px"><b>One node per operation.</b> A cached call's four audit rows (Submitted → Forwarded → Attempted → Delivered) fold into one node; the pill is the latest status and the Lifecycle tab is the history. A notification folds the site's <code class="mono">NotifySend</code> with central's <code class="mono">NotifyDeliver</code> rows.</p>
<p style="max-width:none;margin-top:8px"><b>Sync calls sit in the chain; async ones branch.</b> The script blocks on <code class="mono">ExternalSystem.Call</code>, so the chain runs through it (write → API call → write). It does not block on the cached write or the notification, so those hang off the script run on a dashed edge and the chain skips them.</p>
<p style="max-width:none;margin-top:8px"><b>The Response now starts from the route call</b>, not from whatever step happened last — a cascaded write on another instance is not what answered the HTTP request.</p>
</div>
</div>
</section>
<!-- ══ rows → nodes ══ -->
<section>
<h2>How audit rows become nodes</h2>
<div class="table-wrap">
<table class="rows">
<thead><tr><th>Script call</th><th>Audit rows already written</th><th>Node</th><th>Live inline</th><th>Arrives later</th></tr></thead>
<tbody>
<tr><td><code>ExternalSystem.Call</code></td><td><code>ApiOutbound / ApiCall</code> Delivered|Failed, request + response, duration</td><td>API CALL · in chain</td><td>whole node</td><td>—</td></tr>
<tr><td><code>ExternalSystem.CachedCall</code></td><td><code>CachedSubmit</code> → <code>ApiCallCached</code> Forwarded / Attempted ×n → <code>CachedResolve</code></td><td>API CALL · async branch</td><td>Submitted</td><td>Forwarded, attempts, Delivered / Parked</td></tr>
<tr><td><code>Database.Query / Execute</code></td><td><code>DbOutbound / DbWrite</code>, <code>Extra {op, rows…}</code></td><td>DATABASE · in chain</td><td>whole node</td><td>—</td></tr>
<tr><td><code>Database.CachedWrite</code></td><td><code>CachedSubmit</code> → <code>DbWriteCached</code> → <code>CachedResolve</code></td><td>DATABASE · async branch</td><td>Submitted</td><td>the rest</td></tr>
<tr><td><code>Notify.Send</code></td><td>site <code>NotifySend</code> Submitted → central <code>NotifyDeliver</code> Attempted / Delivered / Parked</td><td>NOTIFY · async branch</td><td>Submitted</td><td>central outbox rows</td></tr>
<tr><td>central method script <code>Database.*</code></td><td><b>none today</b> → gets <code>DbOutbound / DbWrite</code> (closes an audit gap)</td><td>DATABASE · in chain, Central-side caller</td><td>whole node</td><td>—</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ══ plan at a glance ══ -->
<section class="grid2">
<div>
<h2>Plan at a glance</h2>
<ol class="plan" start="0">
<li><b>Commons types</b> — kinds <code class="mono">ApiCall / DbCall / Notify</code>, lane <code class="mono">External</code>, outcomes <code class="mono">Pending / Parked</code>, <code class="mono">FlowLifecycleRow</code>.</li>
<li><b>Projector</b> maps the three outbound channels onto the External lane.</li>
<li><b>Graph builder</b> folds lifecycles into one node, async branch edges, spawn edges, Response from the route call.</li>
<li><b>Site runtime</b> routes outbound rows through the existing trace collector.</li>
<li><b>Central</b> audits method-script <code class="mono">Database.*</code> and feeds the trace sink.</li>
<li><b>Replay</b> filter widens to the three channels; site pull verified.</li>
<li><b>Predictor</b> emits the three kinds with async edges.</li>
<li><b>FlowGraphView</b> — fourth lane, pills, async/spawn connectors.</li>
<li><b>Step detail</b> — Lifecycle tab, request/response labels.</li>
<li><b>Page</b> — refresh pending outcomes after a Live run.</li>
<li><b>CLI, specs, rig demo</b> + verification step I.</li>
</ol>
</div>
<div>
<h2>Not in this slice</h2>
<p>Retry / Discard on a parked node (Site Calls and the Notification report own those actions), deep links by operation id into those pages, and per-attempt request diffs. Each is a register row, not a blocker.</p>
<p style="margin-top:10px">Repo plan: <code class="mono">docs/plans/2026-09-06-flow-debugger-outbound-calls.md</code>.</p>
</div>
</section>
</div>
<script>
(function () {
const LH = 110, NW = 150, NH = 84, X0 = 160, PITCH = 170, PAD = 16;
const lanes = [
{ name: 'Central', sub: '' },
{ name: 'Site', sub: 'site-a · DelmiaSim01' },
{ name: 'Tags', sub: 'DelmiaSim01' },
{ name: 'External', sub: 'RigRest · RigDb · FlowDemo' },
];
// lane, column, eyebrow, duration, title, subtitle | pill
const nodes = {
req: { lane: 0, col: 0, eb: 'Request', dur: '13 ms', title: 'FlowOutboundDemo', sub: 'multi-role' },
route: { lane: 0, col: 1, eb: 'Route call', dur: '358 ms', title: 'DelmiaSim01.OutboundDemo', sub: '→ 9b1c02aa' },
run: { lane: 1, col: 2, eb: 'Script run', dur: '341 ms', title: 'OutboundDemo', sub: 'DelmiaSim01 · call' },
w1: { lane: 2, col: 3, eb: 'Write', dur: '0 ms', title: 'RecipeProcessedFlag', sub: 'RecipeProcessedFlag = True' },
api: { lane: 3, col: 4, eb: 'API call', dur: '212 ms', title: 'RigRest.AckRecipe', pill: ['ok', 'Ok · 200 · sync'] },
w2: { lane: 2, col: 5, eb: 'Write', dur: '0 ms', title: 'LastAckAt', sub: 'LastAckAt = 05:31:02.5' },
db: { lane: 3, col: 3, eb: 'Database', dur: '+4.1 s', title: 'RigDb · CachedWrite', pill: ['ok', 'Delivered · cached'], selected: true },
ntf: { lane: 3, col: 5, eb: 'Notify', dur: '', title: 'FlowDemo', pill: ['pending', 'Pending · submitted'] },
resp: { lane: 0, col: 6, eb: 'Response', dur: '', title: 'Response', sub: '200' },
};
const edges = [
['req', 'route', ''], ['route', 'run', 'boundary'], ['run', 'w1', ''], ['w1', 'api', ''], ['api', 'w2', ''],
['run', 'db', 'async'], ['run', 'ntf', 'async'], ['route', 'resp', ''],
];
const svg = document.getElementById('fg');
const NS = 'http://www.w3.org/2000/svg';
const el = (t, a, parent) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); (parent || svg).appendChild(e); return e; };
const W = 1370;
lanes.forEach((l, i) => {
el('rect', { x: 0, y: i * LH, width: W, height: LH, class: 'lane-bg' + (i % 2 ? ' alt' : '') });
el('line', { x1: 0, y1: i * LH, x2: W, y2: i * LH, class: 'lane-rule' });
el('text', { x: 12, y: i * LH + 24, class: 'lane-label' }).textContent = l.name;
if (l.sub) el('text', { x: 12, y: i * LH + 40, class: 'lane-sub' }).textContent = l.sub;
});
for (const k in nodes) { const n = nodes[k]; n.x = X0 + n.col * PITCH; n.y = PAD + n.lane * LH; }
const path = (a, b) => {
const x1 = a.x + NW, y1 = a.y + NH / 2, x2 = b.x, y2 = b.y + NH / 2;
if (Math.abs(y1 - y2) < .01) return `M ${x1} ${y1} H ${x2}`;
const mid = x1 + (x2 - x1) / 2;
return `M ${x1} ${y1} H ${mid} V ${y2} H ${x2}`;
};
// Async branches leave the source's bottom edge, run along the strip above the target
// lane's nodes and drop into the target's top — so they never cross a node in between.
const asyncPath = (a, b) => `M ${a.x + NW / 2} ${a.y + NH} V ${b.y - 8} H ${b.x + 16} V ${b.y}`;
edges.forEach(([f, t, cls]) => {
const d = cls === 'async' ? asyncPath(nodes[f], nodes[t]) : path(nodes[f], nodes[t]);
el('path', { d, class: 'edge ' + cls, 'marker-end': cls === 'boundary' ? 'url(#ar-acc)' : 'url(#ar)' });
if (cls === 'async') el('text', { x: nodes[t].x + 22, y: nodes[t].y - 11, class: 'async-tag' }).textContent = 'async';
});
for (const k in nodes) {
const n = nodes[k];
const g = el('g', { class: 'fg-node' + (n.selected ? ' selected' : '') + (n.pill && n.pill[0] === 'pending' ? ' pending' : ''), transform: `translate(${n.x} ${n.y})`, role: 'button', tabindex: 0 });
el('rect', { x: 0, y: 0, width: NW, height: NH, rx: 4, ry: 4 }, g);
const fo = el('foreignObject', { x: 0, y: 0, width: NW, height: NH }, g);
const body = document.createElement('div'); body.className = 'body'; body.title = n.title + (n.sub ? ' — ' + n.sub : '');
const bottom = n.pill ? `<span class="pill ${n.pill[0]}">${n.pill[1]}</span>` : `<div class="sub">${n.sub || ''}</div>`;
body.innerHTML = `<div class="head"><span class="eb">${n.eb}</span><span class="dur">${n.dur}</span></div><div class="title">${n.title.replace(/([a-z])([A-Z])/g, '$1​$2').replace(/\./g, '.​')}</div>${bottom}`;
fo.appendChild(body);
}
})();
</script>