diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
index 5924f484..4625d9ad 100644
--- a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
+++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
@@ -34,7 +34,7 @@
{
"id": 4,
"subject": "Task 4: Central \u2014 audit InboundDatabaseHelper (DbOutbound) and feed the trace sink",
- "status": "in_progress",
+ "status": "completed",
"blockedBy": [
0
]
@@ -50,7 +50,7 @@
{
"id": 6,
"subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges",
- "status": "in_progress",
+ "status": "completed",
"blockedBy": [
0
]
@@ -96,5 +96,5 @@
]
}
],
- "lastUpdated": "2026-09-06T05:51:19.355720+00:00"
+ "lastUpdated": "2026-09-06T05:52:50.321199+00:00"
}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor
index 95a8c399..3be9dd4f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor
@@ -2,9 +2,10 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
@using ZB.MOM.WW.ScadaBridge.ScriptAnalysis
-@* Flow Debugger swimlane (design 2026-09-05 §4). Dependency-free inline SVG:
- three fixed lanes, one node per step, orthogonal connectors. All coordinate
- math lives in the code-behind (InvariantCulture throughout). *@
+@* Flow Debugger swimlane (design 2026-09-05 §4; External lane, status pills and async/spawn
+ connectors per the 2026-09-06 outbound-calls plan). Dependency-free inline SVG: four fixed
+ lanes, one node per step, orthogonal connectors. All coordinate math lives in the
+ code-behind (InvariantCulture throughout). *@
@WrapHints(node.DisplayTitle)
- @node.DisplaySubtitle
+ @if (HasPill(node))
+ {
+ @* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@
+
+ @PillText(node.Step.Outcome)
+ · @OutboundMode(node.Kind, node.Subtitle)
+
+ }
+ else
+ {
+ @node.DisplaySubtitle
+ }
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
index e268cd3b..4b566d42 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
@@ -54,7 +54,9 @@ public sealed record FlowLayoutNode(
/// Accent colour — a central↔site hop.
/// Dashed — an external actor (the tag source responding).
/// Light — an edge of the prediction, not of a run.
-public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly);
+/// Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).
+/// Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9).
+public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
/// A computed swimlane layout.
/// Nodes with coordinates.
@@ -76,12 +78,13 @@ public sealed record FlowLayout(
}
///
-/// Custom-SVG swimlane renderer for the Flow Debugger (design 2026-09-05 §4). Three fixed lanes
-/// (Central / Site / Tags), columns from a topological pass over the graph's edges, orthogonal
-/// connectors. Renders a real , a , or both —
-/// in which case predicted nodes the run never produced are greyed. The layout is a pure
-/// function () so it is unit-tested without rendering; every
-/// coordinate string is -formatted.
+/// Custom-SVG swimlane renderer for the Flow Debugger (design 2026-09-05 §4). Four fixed lanes
+/// (Central / Site / Tags / External — the fourth added by the 2026-09-06 outbound-calls plan, D2),
+/// columns from a topological pass over the graph's edges, orthogonal connectors. Renders a real
+/// , a , or both — in which case predicted nodes
+/// the run never produced are greyed. The layout is a pure function ()
+/// so it is unit-tested without rendering; every coordinate string is
+/// -formatted.
///
public partial class FlowGraphView : ComponentBase
{
@@ -100,7 +103,10 @@ public partial class FlowGraphView : ComponentBase
/// Right-hand canvas padding.
public const double RightPad = 20;
- private const int LaneCount = 3;
+ /// Number of swimlanes ( members, in order).
+ public const int LaneCount = 4;
+ /// Most distinct targets the External gutter names before folding the rest into +n.
+ public const int ExternalSublabelTargets = 3;
/// The real graph (from a run or a replay); null before a run.
[Parameter] public FlowGraph? Graph { get; set; }
@@ -160,6 +166,8 @@ public partial class FlowGraphView : ComponentBase
case FlowStepOutcome.Failed: sb.Append(" failed"); break;
case FlowStepOutcome.TimedOut: sb.Append(" timed-out"); break;
case FlowStepOutcome.NotTraced: sb.Append(" not-traced"); break;
+ case FlowStepOutcome.Pending: sb.Append(" pending"); break;
+ case FlowStepOutcome.Parked: sb.Append(" parked"); break;
}
return sb.ToString();
@@ -170,12 +178,57 @@ public partial class FlowGraphView : ComponentBase
var sb = new StringBuilder("fg-edge");
if (e.CrossesBoundary) sb.Append(" boundary");
if (e.External) sb.Append(" external");
+ if (e.Async) sb.Append(" async");
+ if (e.Spawn) sb.Append(" spawn");
if (e.PredictedOnly) sb.Append(" predicted");
return sb.ToString();
}
private static string MarkerFor(FlowLayoutEdge e)
- => e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
+ => e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
+
+ /// True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.
+ private static bool HasPill(FlowLayoutNode n)
+ => n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
+
+ /// Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).
+ public static string PillText(FlowStepOutcome outcome) => outcome switch
+ {
+ FlowStepOutcome.Ok => "Ok",
+ FlowStepOutcome.Pending => "Pending",
+ FlowStepOutcome.Parked => "Parked",
+ FlowStepOutcome.Failed => "Failed",
+ FlowStepOutcome.TimedOut => "Timed out",
+ FlowStepOutcome.NotTraced => "Not traced",
+ _ => outcome.ToString(),
+ };
+
+ private static string PillClass(FlowStepOutcome outcome) => outcome switch
+ {
+ FlowStepOutcome.Ok => "fg-pill ok",
+ FlowStepOutcome.Pending => "fg-pill pending",
+ FlowStepOutcome.Parked => "fg-pill parked",
+ FlowStepOutcome.Failed or FlowStepOutcome.TimedOut => "fg-pill failed",
+ _ => "fg-pill",
+ };
+
+ ///
+ /// The text after the pill: sync / cached / notify per the node anatomy, with a
+ /// sync database call's operation detail (write · 3 rows) kept in front of it. The projector's
+ /// subtitle already leads with the audit status, which the pill now carries, so that head is dropped.
+ ///
+ public static string OutboundMode(FlowStepKind kind, string? subtitle)
+ {
+ if (kind == FlowStepKind.Notify) return "notify";
+ var detail = subtitle ?? string.Empty;
+ // Strip a leading "{AuditStatus} · " / a bare status word (the pill says it).
+ var head = detail.Split(" · ", 2, StringSplitOptions.None);
+ if (head.Length > 0 && Enum.TryParse(head[0], ignoreCase: false, out _))
+ detail = head.Length == 2 ? head[1] : string.Empty;
+ if (detail is "cached" or "sync") return detail;
+ if (detail.EndsWith(" · cached", StringComparison.Ordinal) || detail.EndsWith(" · sync", StringComparison.Ordinal)) return detail;
+ return detail.Length == 0 ? "sync" : detail + " · sync";
+ }
private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
{
@@ -191,8 +244,21 @@ public partial class FlowGraphView : ComponentBase
_ => "STEP",
};
- private static string Duration(FlowLayoutNode n)
- => n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty);
+ ///
+ /// The head-row duration: a step's own milliseconds, or — for a folded outbound lifecycle of more
+ /// than one row — the submit→terminal wall time with a leading + (+4.1 s), since that
+ /// span is elapsed time downstream of the script, not time the script spent (node anatomy).
+ ///
+ public static string Duration(FlowLayoutNode n)
+ {
+ if (n.Step?.DurationMs is not int ms)
+ return n.PredictedOnly ? "predicted" : string.Empty;
+ if (n.Step.Lifecycle is { Count: > 1 })
+ return ms < 1000
+ ? "+" + ms.ToString(CultureInfo.InvariantCulture) + " ms"
+ : "+" + (ms / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " s";
+ return ms.ToString(CultureInfo.InvariantCulture) + " ms";
+ }
/// Clips a gutter label to what fits, keeping the full text in a title.
public static string Clip(string? text, int max)
@@ -274,6 +340,17 @@ public partial class FlowGraphView : ComponentBase
return new MarkupString(sb.ToString());
}
+ ///
+ /// Where an async branch's async tag sits: just right of the drop into the target's top, in
+ /// the strip between lanes (mirrors the mockup). Null for every non-async edge.
+ ///
+ private (string X, string Y)? AsyncTagFor(FlowLayoutEdge e)
+ {
+ if (!e.Async) return null;
+ var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To);
+ return b is null ? null : (F(b.X + 22), F(b.Y - 4));
+ }
+
private Task SelectAsync(FlowLayoutNode n)
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
@@ -299,7 +376,8 @@ public partial class FlowGraphView : ComponentBase
var keys = new List();
var lane = new Dictionary(StringComparer.Ordinal);
var preds = new Dictionary>(StringComparer.Ordinal);
- var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly)>();
+ var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly, bool Async, bool Spawn)>();
+ var externalTargets = new List();
var stepByKey = new Dictionary(StringComparer.Ordinal);
var predByKey = new Dictionary(StringComparer.Ordinal);
var ghostKeys = new HashSet(StringComparer.Ordinal);
@@ -316,14 +394,18 @@ public partial class FlowGraphView : ComponentBase
sublabels[(int)FlowLane.Site] = JoinLabel(s.SiteId, s.Instance);
if (s.Lane == FlowLane.Tags && string.IsNullOrEmpty(sublabels[(int)FlowLane.Tags]))
sublabels[(int)FlowLane.Tags] = s.Instance ?? string.Empty;
+ if (s.Lane == FlowLane.External && !string.IsNullOrWhiteSpace(s.Target) && !externalTargets.Contains(s.Target, StringComparer.Ordinal))
+ externalTargets.Add(s.Target);
}
+ sublabels[(int)FlowLane.External] = ExternalSublabel(externalTargets);
+
foreach (var e in graph.Edges)
{
var from = "e:" + e.FromEventId.ToString("N");
var to = "e:" + e.ToEventId.ToString("N");
if (lane.ContainsKey(from) && lane.ContainsKey(to))
- rawEdges.Add((from, to, e.CrossesBoundary, e.External, false));
+ rawEdges.Add((from, to, e.CrossesBoundary, e.External, false, e.Async, e.Spawn));
}
if (hasPredicted)
@@ -365,11 +447,11 @@ public partial class FlowGraphView : ComponentBase
var from = "p:" + e.From;
var to = "p:" + e.To;
if (lane.ContainsKey(from) && lane.ContainsKey(to))
- rawEdges.Add((from, to, e.CrossesBoundary, false, true));
+ rawEdges.Add((from, to, e.CrossesBoundary, false, true, e.Async, false));
}
}
- foreach (var (from, to, _, _, _) in rawEdges)
+ foreach (var (from, to, _, _, _, _, _) in rawEdges)
{
if (!preds.TryGetValue(to, out var list))
preds[to] = list = [];
@@ -444,11 +526,12 @@ public partial class FlowGraphView : ComponentBase
}
var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal);
- foreach (var (from, to, crosses, external, predictedOnly) in rawEdges)
+ foreach (var (from, to, crosses, external, predictedOnly, isAsync, spawn) in rawEdges)
{
var a = byKey[from];
var b = byKey[to];
- edges.Add(new FlowLayoutEdge(from, to, OrthogonalPath(a, b), crosses, external, predictedOnly));
+ var path = isAsync ? AsyncPath(a, b) : OrthogonalPath(a, b);
+ edges.Add(new FlowLayoutEdge(from, to, path, crosses, external, predictedOnly, isAsync, spawn));
}
var width = ColumnOrigin + (maxCol + 1) * ColumnPitch + RightPad;
@@ -463,6 +546,36 @@ public partial class FlowGraphView : ComponentBase
private static string JoinLabel(string? a, string? b)
=> string.IsNullOrEmpty(a) ? b ?? string.Empty : string.IsNullOrEmpty(b) ? a : $"{a} · {b}";
+ ///
+ /// The External gutter: the distinct targets touched, in first-seen order, joined with · —
+ /// up to named, the rest folded into +n
+ /// (Delmia.AckRecipe · MesDb · Planners +1). Empty when the lane is unused.
+ ///
+ public static string ExternalSublabel(IReadOnlyList distinctTargets)
+ {
+ if (distinctTargets.Count == 0) return string.Empty;
+ var shown = string.Join(" · ", distinctTargets.Take(ExternalSublabelTargets));
+ var rest = distinctTargets.Count - ExternalSublabelTargets;
+ return rest > 0 ? $"{shown} +{rest.ToString(CultureInfo.InvariantCulture)}" : shown;
+ }
+
+ ///
+ /// Connector for an async branch (cached call / notification): leaves the source's bottom-centre,
+ /// runs along the strip above the target's lane and drops into the target's top — so it never
+ /// crosses a node sitting between the two lanes. Falls back to when
+ /// the target is not below the source.
+ ///
+ public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b)
+ {
+ if (b.Y <= a.Y)
+ return OrthogonalPath(a, b);
+ var x1 = a.X + NodeWidth / 2;
+ var y1 = a.Y + NodeHeight;
+ var strip = b.Y - LanePad / 2;
+ var x2 = b.X + 16;
+ return $"M {F(x1)} {F(y1)} V {F(strip)} H {F(x2)} V {F(b.Y)}";
+ }
+
///
/// Orthogonal connector from the right-middle of to the left-middle
/// of : straight when in the same lane, otherwise right-down/up-right
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css
index f6cf74ff..9a4e2f11 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css
@@ -46,6 +46,7 @@
.fg-node.failed .fg-node-box,
.fg-node.timed-out .fg-node-box { stroke: var(--bs-danger, #c0392b); }
.fg-node.not-traced .fg-node-box { stroke-dasharray: 1 3; }
+.fg-node.parked .fg-node-box { stroke: var(--warn, var(--bs-warning, #e8920c)); }
/* Node body: HTML inside a foreignObject so text is measured by the browser. */
.fg-body {
@@ -77,6 +78,40 @@
.fg-node.failed .fg-eyebrow,
.fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); }
+/* Outbound nodes (API call / database / notify): the folded outcome as a status pill, then the
+ call mode. Kit status tokens first (dark mode follows via site.css), Bootstrap's as fallback. */
+.fg-status { margin-top: auto; display: flex; align-items: center; gap: 4px; min-width: 0; }
+.fg-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ font-size: 0.6rem;
+ font-weight: 500;
+ line-height: 1.3;
+ padding: 0 6px;
+ border-radius: 999px;
+ white-space: nowrap;
+ color: var(--ink-soft);
+ background: var(--rule);
+}
+.fg-pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
+.fg-pill.ok { color: var(--ok, var(--bs-success, #2f9e44)); background: var(--ok-bg, rgba(var(--bs-success-rgb, 25, 135, 84), 0.12)); }
+.fg-pill.pending { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
+.fg-pill.parked { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
+.fg-pill.failed { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); }
+.fg-mode { color: var(--ink-soft); font-size: 0.66rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* A pending pill pulses while the page keeps refreshing outcomes after a Live run. */
+.fg-node.pending .fg-pill { animation: fg-pulse 1.6s ease-in-out infinite; }
+@keyframes fg-pulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
+ 50% { opacity: 0.6; box-shadow: 0 0 0 4px var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .fg-node.pending .fg-pill { animation: none; }
+}
+
/* Connectors */
.fg-edge {
fill: none;
@@ -85,6 +120,12 @@
}
.fg-edge.boundary { stroke: var(--accent); }
.fg-edge.external { stroke-dasharray: 5 4; }
+/* Async branch (cached call / notification): same dash as the echo edge, its own class — the run
+ carried on without waiting. Spawn: solid line ending in the bolt marker (a trigger cascade). */
+.fg-edge.async { stroke-dasharray: 5 4; }
+.fg-edge.spawn { stroke: var(--ink-soft); }
.fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; }
.fg-arrowhead { fill: var(--ink-faint); }
.fg-arrowhead.accent { fill: var(--accent); }
+.fg-arrowhead.spawn { fill: var(--ink-soft); }
+.fg-wrap ::deep .fg-edge-tag { fill: var(--ink-faint); font-size: 0.6rem; font-family: var(--mono); }
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
index 15fa9b56..69ce06e8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
@@ -142,11 +142,12 @@ public class FlowGraphViewTests : BunitContext
{
var cut = Render();
- Assert.Equal(3, cut.FindAll("g.fg-lane").Count);
+ Assert.Equal(4, cut.FindAll("g.fg-lane").Count);
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
Assert.Empty(cut.FindAll("g[data-test='flow-node']"));
Assert.Contains("Central", cut.Markup);
Assert.Contains("Tags", cut.Markup);
+ Assert.Contains("External", cut.Markup);
}
[Fact]
@@ -204,8 +205,9 @@ public class FlowGraphViewTests : BunitContext
var cut = Render(p => p.Add(c => c.Graph, DelmiaGraph()).Add(c => c.Zoom, 0.7));
var svg = cut.Find("svg");
- // 346 × 0.7 = 242.2 — a comma here would be the German decimal separator leaking through.
- Assert.Equal("242.2", svg.GetAttribute("height"));
+ // Four lanes: (4 × 110 + 16) × 0.7 = 456 × 0.7 = 319.2 — a comma here would be the German decimal separator leaking through.
+ Assert.Equal((4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad) * 0.7, 319.2, 9);
+ Assert.Equal("319.2", svg.GetAttribute("height"));
Assert.DoesNotContain(",", svg.GetAttribute("viewBox"));
foreach (var path in cut.FindAll("path[data-test='flow-edge']"))
Assert.DoesNotContain(",", path.GetAttribute("d"));
@@ -301,4 +303,198 @@ public class FlowGraphViewTests : BunitContext
// The gutter sublabel fits without clipping at the widened gutter.
Assert.Contains("site-a · DelmiaSim01", cut.Markup.Replace("·", "·"));
}
+
+ // ── Outbound calls (plan 2026-09-06): External lane, status pills, async/spawn connectors ──
+
+ private static readonly Guid SiteRun = Guid.NewGuid();
+ private static readonly Guid Cascade = Guid.NewGuid();
+
+ private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target,
+ string node = "node-a", string? error = null, int? durationMs = null)
+ => ScadaBridgeAuditEventFactory.Create(channel, kind, status,
+ occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation,
+ executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs);
+
+ private static AuditEvent WriteRow(int ms, string attribute) => ScadaBridgeAuditEventFactory.Create(
+ AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(ms), target: "M1." + attribute, executionId: SiteRun, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 1,
+ extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("M1." + attribute, "1", true, null)] }.ToJson());
+
+ ///
+ /// Inbound → route → site run → write A → sync API call → write B → (spawned cascade run), with a
+ /// pending cached DB write and a parked notification branching off the run.
+ ///
+ private static FlowGraph OutboundGraph(bool fourthTarget = false)
+ {
+ var events = new List
+ {
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: T0, target: "POST /api/FlowOutboundDemo", executionId: Root, durationMs: 900, responseSummary: "{}"),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(5), target: "M1.OutboundDemo", executionId: Root, durationMs: 850,
+ extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson()),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(20), target: "OutboundDemo", executionId: SiteRun, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800),
+ WriteRow(30, "A"),
+ Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212),
+ Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 36, Guid.NewGuid(), "MesDb"),
+ WriteRow(40, "B"),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(45), target: "OnBChanged", executionId: Cascade, parentExecutionId: SiteRun,
+ sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
+ extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "M1.B", TriggerValue = "1" }.ToJson()),
+ };
+ var notificationId = Guid.NewGuid();
+ events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 37, notificationId, "Planners"));
+ events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9002, notificationId, "Planners", node: "central-a", error: "retries exhausted"));
+ if (fourthTarget)
+ events.Add(Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 38, null, "Other.Thing", durationMs: 5));
+ return FlowGraphBuilder.Build(events);
+ }
+
+ [Fact]
+ public void Layout_OutboundSteps_LandInTheExternalLane_WithATargetSublabel()
+ {
+ var layout = FlowGraphView.ComputeLayout(OutboundGraph(), null);
+
+ var external = layout.Nodes.Where(n => n.Lane == FlowLane.External).ToList();
+ Assert.Equal(3, external.Count);
+ Assert.Equal([FlowStepKind.ApiCall, FlowStepKind.DbCall, FlowStepKind.Notify], external.Select(n => n.Kind));
+ Assert.All(external, n => Assert.Equal(FlowGraphView.LanePad + 3 * FlowGraphView.LaneHeight, n.Y));
+ Assert.Equal("Delmia.AckRecipe · MesDb · Planners", layout.LaneSublabels[(int)FlowLane.External]);
+ Assert.Equal(4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad, layout.Height);
+ // The edge flags ride through from the graph.
+ Assert.Equal(2, layout.Edges.Count(e => e.Async));
+ Assert.Single(layout.Edges, e => e.Spawn);
+ }
+
+ [Fact]
+ public void Layout_ExternalSublabel_ListsThreeTargetsThenCounts()
+ {
+ var layout = FlowGraphView.ComputeLayout(OutboundGraph(fourthTarget: true), null);
+
+ Assert.Equal("Delmia.AckRecipe · MesDb · Planners +1", layout.LaneSublabels[(int)FlowLane.External]);
+ }
+
+ [Fact]
+ public void Render_ApiCall_HasEyebrowAndOkPillWithSyncMode()
+ {
+ var cut = Render(p => p.Add(c => c.Graph, OutboundGraph()));
+
+ Assert.Equal(4, cut.FindAll("g.fg-lane").Count);
+ var api = cut.Find("g[data-test='flow-node'][data-kind='ApiCall']");
+ Assert.Equal("External", api.GetAttribute("data-lane"));
+ Assert.Equal("API CALL", api.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
+ Assert.Equal("212 ms", api.QuerySelector(".fg-duration")!.TextContent.Trim());
+ var pill = api.QuerySelector("[data-test='flow-node-pill']")!;
+ Assert.Equal("Ok", pill.TextContent.Trim());
+ Assert.Contains("ok", pill.ClassList);
+ Assert.Equal("· sync", api.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
+ Assert.DoesNotContain("pending", api.ClassList);
+ Assert.DoesNotContain("parked", api.ClassList);
+ // Non-outbound nodes keep the plain subtitle line and get no pill.
+ var run = cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']");
+ Assert.Null(run.QuerySelector("[data-test='flow-node-pill']"));
+ Assert.NotNull(run.QuerySelector("[data-test='flow-node-subtitle']"));
+ }
+
+ [Fact]
+ public void Render_PendingCachedCall_HasPendingClassAndPill()
+ {
+ var cut = Render(p => p.Add(c => c.Graph, OutboundGraph()));
+
+ var db = cut.Find("g[data-test='flow-node'][data-kind='DbCall']");
+ Assert.Contains("pending", db.ClassList);
+ Assert.Equal("DATABASE", db.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
+ var pill = db.QuerySelector("[data-test='flow-node-pill']")!;
+ Assert.Equal("Pending", pill.TextContent.Trim());
+ Assert.Contains("pending", pill.ClassList);
+ Assert.Equal("· cached", db.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
+ Assert.Equal(string.Empty, db.QuerySelector(".fg-duration")!.TextContent.Trim());
+ }
+
+ [Fact]
+ public void Render_ParkedNotification_HasParkedClass_AndSubmitToTerminalDuration()
+ {
+ var cut = Render(p => p.Add(c => c.Graph, OutboundGraph()));
+
+ var notify = cut.Find("g[data-test='flow-node'][data-kind='Notify']");
+ Assert.Contains("parked", notify.ClassList);
+ Assert.Equal("NOTIFY", notify.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
+ var pill = notify.QuerySelector("[data-test='flow-node-pill']")!;
+ Assert.Equal("Parked", pill.TextContent.Trim());
+ Assert.Contains("parked", pill.ClassList);
+ Assert.Equal("· notify", notify.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
+ // A folded lifecycle shows submit→terminal wall time with a leading "+" (8965 ms → "+9 s").
+ Assert.Equal("+9 s", notify.QuerySelector(".fg-duration")!.TextContent.Trim());
+ }
+
+ [Fact]
+ public void Render_AsyncEdgesAreDashedClass_SpawnEdgeUsesTheBoltMarker()
+ {
+ var cut = Render(p => p.Add(c => c.Graph, OutboundGraph()));
+
+ Assert.NotNull(cut.Find("marker#fg-arrow-spawn"));
+ var edges = cut.FindAll("path[data-test='flow-edge']");
+ var async = edges.Where(e => e.ClassList.Contains("async")).ToList();
+ Assert.Equal(2, async.Count);
+ Assert.All(async, e => Assert.Equal("url(#fg-arrow)", e.GetAttribute("marker-end")));
+ Assert.All(async, e => Assert.DoesNotContain("external", e.ClassList));
+ var spawn = Assert.Single(edges, e => e.ClassList.Contains("spawn"));
+ Assert.Equal("url(#fg-arrow-spawn)", spawn.GetAttribute("marker-end"));
+ Assert.All(edges.Where(e => !e.ClassList.Contains("spawn")), e => Assert.NotEqual("url(#fg-arrow-spawn)", e.GetAttribute("marker-end")));
+ Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
+ }
+
+ [Theory]
+ [InlineData(FlowStepKind.ApiCall, "Delivered · sync", "sync")]
+ [InlineData(FlowStepKind.ApiCall, "Failed · sync", "sync")]
+ [InlineData(FlowStepKind.ApiCall, "Delivered · cached", "cached")]
+ [InlineData(FlowStepKind.DbCall, "Submitted · cached", "cached")]
+ [InlineData(FlowStepKind.DbCall, "write · 3 rows", "write · 3 rows · sync")]
+ [InlineData(FlowStepKind.DbCall, "Delivered", "sync")]
+ [InlineData(FlowStepKind.DbCall, null, "sync")]
+ [InlineData(FlowStepKind.Notify, "Parked", "notify")]
+ [InlineData(FlowStepKind.Notify, null, "notify")]
+ public void OutboundMode_DropsTheStatusThePillCarries_AndNamesTheCallMode(FlowStepKind kind, string? subtitle, string expected)
+ {
+ Assert.Equal(expected, FlowGraphView.OutboundMode(kind, subtitle));
+ }
+
+ [Fact]
+ public void Render_PredictedOutboundNodes_AreGhostsWithoutPills()
+ {
+ var predicted = new PredictedFlow(
+ [
+ new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "OutboundDemo", false, null),
+ new("n2", FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe", false, "sync"),
+ new("n3", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, "async"),
+ ], [new("n1", "n2", false), new("n1", "n3", false)]);
+
+ var cut = Render(p => p.Add(c => c.Predicted, predicted));
+
+ var external = cut.FindAll("g[data-test='flow-node'][data-lane='External']");
+ Assert.Equal(2, external.Count);
+ Assert.All(external, n => Assert.Contains("predicted", n.ClassList));
+ Assert.All(external, n => Assert.Null(n.QuerySelector("[data-test='flow-node-pill']")));
+ Assert.Equal("API CALL", external[0].QuerySelector(".fg-eyebrow")!.TextContent.Trim());
+ }
+
+ [Fact]
+ public void PredictedAsyncEdge_RendersWithAsyncClass()
+ {
+ var flow = new PredictedFlow(
+ [
+ new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "S", false, null),
+ new("n2", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, null),
+ ],
+ [new("n1", "n2", false, Async: true)]);
+
+ var layout = FlowGraphView.ComputeLayout(null, flow);
+
+ var edge = Assert.Single(layout.Edges);
+ Assert.True(edge.Async);
+ }
}