feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors
Four lanes (LaneCount 4; External sublabel = distinct targets, three then +n); ApiCall/DbCall/Notify nodes carry a status pill (Ok/Pending/Parked/Failed — pending pulses, reduced-motion aware) plus a sync/cached/notify mode; folded lifecycles show '+4.1 s' submit→terminal wall time. Async edges are dashed and routed through the inter-lane strip with a gutter tag; spawn edges use a bolt marker. Predicted async edges (Task 6's PredictedEdge.Async) render the same way. de-DE height pinned at 319.2 = (4×110+16)×0.7. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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). *@
|
||||
|
||||
<div class="fg-wrap" data-test="flow-graph">
|
||||
<svg width="@RenderedWidth" height="@RenderedHeight" viewBox="@ViewBox"
|
||||
@@ -16,10 +17,14 @@
|
||||
<marker id="fg-arrow-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead accent" />
|
||||
</marker>
|
||||
@* Trigger spawn (plan D9): a small bolt in place of the arrowhead — this write fired a value-change script. *@
|
||||
<marker id="fg-arrow-spawn" viewBox="0 0 12 14" refX="10" refY="7" markerWidth="12" markerHeight="14" orient="auto-start-reverse">
|
||||
<path d="M 5 1 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-arrowhead spawn" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
@* Lanes *@
|
||||
@for (var lane = 0; lane < 3; lane++)
|
||||
@for (var lane = 0; lane < LaneCount; lane++)
|
||||
{
|
||||
var i = lane;
|
||||
<g class="fg-lane" data-lane="@LaneName(i)">
|
||||
@@ -47,6 +52,11 @@
|
||||
@foreach (var e in _layout.Edges)
|
||||
{
|
||||
<path d="@e.Path" class="@EdgeClass(e)" marker-end="@MarkerFor(e)" data-test="flow-edge" data-from="@e.From" data-to="@e.To" />
|
||||
@if (AsyncTagFor(e) is { } tag)
|
||||
{
|
||||
@* Names the branch where it lands: the run carried on without waiting for this. *@
|
||||
@SvgText(tag.X, tag.Y, "fg-edge-tag", "async", dataTest: "flow-edge-tag")
|
||||
}
|
||||
}
|
||||
|
||||
@* Nodes *@
|
||||
@@ -69,7 +79,18 @@
|
||||
<span class="fg-duration">@Duration(node)</span>
|
||||
</div>
|
||||
<div class="fg-title" data-test="flow-node-title">@WrapHints(node.DisplayTitle)</div>
|
||||
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
|
||||
@if (HasPill(node))
|
||||
{
|
||||
@* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@
|
||||
<div class="fg-status" data-test="flow-node-status">
|
||||
<span class="@PillClass(node.Step!.Outcome)" data-test="flow-node-pill">@PillText(node.Step.Outcome)</span>
|
||||
<span class="fg-mode">· @OutboundMode(node.Kind, node.Subtitle)</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
|
||||
}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
@@ -54,7 +54,9 @@ public sealed record FlowLayoutNode(
|
||||
/// <param name="CrossesBoundary">Accent colour — a central↔site hop.</param>
|
||||
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
|
||||
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
|
||||
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly);
|
||||
/// <param name="Async">Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).</param>
|
||||
/// <param name="Spawn">Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9).</param>
|
||||
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
|
||||
|
||||
/// <summary>A computed swimlane layout.</summary>
|
||||
/// <param name="Nodes">Nodes with coordinates.</param>
|
||||
@@ -76,12 +78,13 @@ public sealed record FlowLayout(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="FlowGraph"/>, a <see cref="PredictedFlow"/>, or both —
|
||||
/// in which case predicted nodes the run never produced are greyed. The layout is a pure
|
||||
/// function (<see cref="ComputeLayout"/>) so it is unit-tested without rendering; every
|
||||
/// coordinate string is <see cref="CultureInfo.InvariantCulture"/>-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
|
||||
/// <see cref="FlowGraph"/>, a <see cref="PredictedFlow"/>, or both — in which case predicted nodes
|
||||
/// the run never produced are greyed. The layout is a pure function (<see cref="ComputeLayout"/>)
|
||||
/// so it is unit-tested without rendering; every coordinate string is
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>-formatted.
|
||||
/// </summary>
|
||||
public partial class FlowGraphView : ComponentBase
|
||||
{
|
||||
@@ -100,7 +103,10 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// <summary>Right-hand canvas padding.</summary>
|
||||
public const double RightPad = 20;
|
||||
|
||||
private const int LaneCount = 3;
|
||||
/// <summary>Number of swimlanes (<see cref="FlowLane"/> members, in order).</summary>
|
||||
public const int LaneCount = 4;
|
||||
/// <summary>Most distinct targets the External gutter names before folding the rest into <c>+n</c>.</summary>
|
||||
public const int ExternalSublabelTargets = 3;
|
||||
|
||||
/// <summary>The real graph (from a run or a replay); null before a run.</summary>
|
||||
[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)";
|
||||
|
||||
/// <summary>True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.</summary>
|
||||
private static bool HasPill(FlowLayoutNode n)
|
||||
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
|
||||
|
||||
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The text after the pill: <c>sync</c> / <c>cached</c> / <c>notify</c> per the node anatomy, with a
|
||||
/// sync database call's operation detail (<c>write · 3 rows</c>) 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.
|
||||
/// </summary>
|
||||
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<Commons.Types.Enums.AuditStatus>(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);
|
||||
/// <summary>
|
||||
/// 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 <c>+</c> (<c>+4.1 s</c>), since that
|
||||
/// span is elapsed time downstream of the script, not time the script spent (node anatomy).
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
|
||||
public static string Clip(string? text, int max)
|
||||
@@ -274,6 +340,17 @@ public partial class FlowGraphView : ComponentBase
|
||||
return new MarkupString(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where an async branch's <c>async</c> 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
var lane = new Dictionary<string, FlowLane>(StringComparer.Ordinal);
|
||||
var preds = new Dictionary<string, List<string>>(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<string>();
|
||||
var stepByKey = new Dictionary<string, FlowStep>(StringComparer.Ordinal);
|
||||
var predByKey = new Dictionary<string, PredictedNode>(StringComparer.Ordinal);
|
||||
var ghostKeys = new HashSet<string>(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}";
|
||||
|
||||
/// <summary>
|
||||
/// The External gutter: the distinct targets touched, in first-seen order, joined with <c>·</c> —
|
||||
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
|
||||
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
|
||||
/// </summary>
|
||||
public static string ExternalSublabel(IReadOnlyList<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OrthogonalPath"/> when
|
||||
/// the target is not below the source.
|
||||
/// </summary>
|
||||
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)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orthogonal connector from the right-middle of <paramref name="a"/> to the left-middle
|
||||
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -142,11 +142,12 @@ public class FlowGraphViewTests : BunitContext
|
||||
{
|
||||
var cut = Render<FlowGraphView>();
|
||||
|
||||
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<FlowGraphView>(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());
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static FlowGraph OutboundGraph(bool fourthTarget = false)
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user