fix(central-ui): Flow Debugger spawn edges — real arrowhead + bolt badge; spawner picked by trigger attribute
The trigger-cascade (Spawn) edge used a lightning-bolt marker IN PLACE of the arrowhead. On the upward Tags→Site hop the bolt sat on top of the riser and read as a broken arrow (rig screenshot 2026-09-08). Every connector now ends in an ordinary arrowhead; the bolt is a badge (circle + bolt) centred on the connector's vertical run (FlowGraphView.SpawnBadgePoint, kept in step with OrthogonalPath/AsyncPath). Same screenshot, second defect: the cascade spawned by "RecipeDownloadFlag = false" was drawn from the LAST write of the burst (RecipeProcessedFlag) because all four writes share a millisecond and the spawner rule was time-only. FlowGraphBuilder now prefers the parent's last AttributeWrite of the attribute the child's triggerAttribute names (at or before the child, else the last such write); the time/position rules remain the fallback when the child names no attribute or the parent never wrote it. Verified live on the rebuilt rig (DelmiaSim01 / DelmiaReceiverSimulator): both spawn edges show an arrowhead + badge, the second one originates from the RecipeDownloadFlag write. bUnit + builder tests updated/added; Playwright FlowDebuggerTests 4/4. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -17,10 +17,6 @@
|
||||
<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 *@
|
||||
@@ -57,6 +53,17 @@
|
||||
@* 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")
|
||||
}
|
||||
@if (SpawnBadgeFor(e) is { } badge)
|
||||
{
|
||||
@* Trigger cascade (plan D9): a bolt badge on the connector itself — this write fired a value-change
|
||||
script. The edge keeps an ordinary arrowhead; the bolt used to BE the arrowhead, and on the
|
||||
upward Tags→Site hop it read as a broken arrow (rig, 2026-09-08). *@
|
||||
<g class="fg-spawn-badge" transform="translate(@badge.X @badge.Y)" data-test="flow-edge-spawn">
|
||||
<title>Trigger cascade: this write fired the script</title>
|
||||
<circle r="8" class="fg-spawn-badge-bg" />
|
||||
<path d="M -1 -6 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-spawn-bolt" />
|
||||
</g>
|
||||
}
|
||||
}
|
||||
|
||||
@* Nodes *@
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed record FlowLayoutNode(
|
||||
/// <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>
|
||||
/// <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>
|
||||
/// <param name="Spawn">Bolt badge on the connector — 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>
|
||||
@@ -184,8 +184,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (<see cref="SpawnBadgeFor"/>).</summary>
|
||||
private static string MarkerFor(FlowLayoutEdge e)
|
||||
=> e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
|
||||
=> 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)
|
||||
@@ -351,6 +352,47 @@ public partial class FlowGraphView : ComponentBase
|
||||
return b is null ? null : (F(b.X + 22), F(b.Y - 4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a spawn edge's bolt badge sits: centred on the connector's vertical run (the riser between the
|
||||
/// lanes), so the badge is read as a property of the hop and the arrowhead stays an arrowhead. Null for
|
||||
/// every non-spawn edge.
|
||||
/// </summary>
|
||||
private (string X, string Y)? SpawnBadgeFor(FlowLayoutEdge e)
|
||||
{
|
||||
if (!e.Spawn) return null;
|
||||
var a = _layout.Nodes.FirstOrDefault(n => n.Key == e.From);
|
||||
var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To);
|
||||
if (a is null || b is null) return null;
|
||||
var (x, y) = SpawnBadgePoint(a, b, e.Async);
|
||||
return (F(x), F(y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint of the vertical run of the connector <see cref="OrthogonalPath"/> / <see cref="AsyncPath"/>
|
||||
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
|
||||
/// Kept in step with those two path builders.
|
||||
/// </summary>
|
||||
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
|
||||
{
|
||||
var x1 = a.X + NodeWidth;
|
||||
var y1 = a.Y + NodeHeight / 2;
|
||||
var x2 = b.X;
|
||||
var y2 = b.Y + NodeHeight / 2;
|
||||
|
||||
if (isAsync && b.Y > a.Y)
|
||||
{
|
||||
// AsyncPath: first vertical run drops from the source's bottom-centre to the strip above the target.
|
||||
var strip = b.Y - LanePad / 2;
|
||||
return (a.X + NodeWidth / 2, (a.Y + NodeHeight + strip) / 2);
|
||||
}
|
||||
if (Math.Abs(y1 - y2) < 0.01)
|
||||
return ((x1 + x2) / 2, y1);
|
||||
if (x2 >= x1)
|
||||
return (x1 + (x2 - x1) / 2, (y1 + y2) / 2);
|
||||
var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
|
||||
return ((x1 + 8 + x2 - 8) / 2, drop);
|
||||
}
|
||||
|
||||
private Task SelectAsync(FlowLayoutNode n)
|
||||
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
|
||||
|
||||
|
||||
@@ -121,11 +121,13 @@
|
||||
.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). */
|
||||
carried on without waiting. Spawn: solid line with an ordinary arrowhead plus a bolt badge sitting
|
||||
on the riser (a trigger cascade) — the bolt is NOT the arrowhead, that read as a broken arrow. */
|
||||
.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-spawn-badge-bg { fill: var(--bs-body-bg, #fff); stroke: var(--ink-soft); stroke-width: 1; }
|
||||
.fg-spawn-bolt { fill: var(--ink-soft); }
|
||||
.fg-wrap ::deep .fg-edge-tag { fill: var(--ink-faint); font-size: 0.6rem; font-family: var(--mono); }
|
||||
|
||||
@@ -27,7 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
/// <c>Notify</c>, or a cached operation whose first row is <c>CachedSubmit</c>. Each skipped step instead gets an
|
||||
/// <c>Async</c> (dashed) edge from the chain step immediately before it in time.</item>
|
||||
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
|
||||
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the last non-async,
|
||||
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the parent's last
|
||||
/// <c>AttributeWrite</c> of the attribute the child's <c>triggerAttribute</c> names (at or before the child's
|
||||
/// first step, else the last such write at all), and only when the child names no attribute or the parent
|
||||
/// never wrote it, the last non-async,
|
||||
/// non-<c>Response</c> parent step that <b>occurred at or before</b> the child's first step. Parent and
|
||||
/// child of a cascade share the site clock, so that timestamp rule picks the write that actually fired
|
||||
/// the trigger; only when it finds nothing (a child clock BEHIND its parent's — a mixed-clock Replay)
|
||||
@@ -146,11 +149,21 @@ public static class FlowGraphBuilder
|
||||
// (a run shaped ScriptRun → Write(A) → Wait → Write(B) would spawn from Write(B)).
|
||||
// Position stays as the fallback for the mixed-clock case F5 was written for — a child
|
||||
// clock behind its parent's, where no parent step satisfies the time rule.
|
||||
// Before either rule: when the child names the attribute that fired it, the spawner is the
|
||||
// parent's last write OF THAT ATTRIBUTE. Timestamps alone cannot separate a burst of writes
|
||||
// that all land in the same millisecond (a run writing Result, ResultText, Flag=false, Processed
|
||||
// back-to-back spawned its value-change child from the LAST of them on the rig, not from Flag).
|
||||
var childTime = first.OccurredAtUtc;
|
||||
var childPosition = position[first.EventId];
|
||||
spawner = parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
|
||||
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
|
||||
?? parentChain[0];
|
||||
var triggerAttribute = first.Extra?.TriggerAttribute;
|
||||
if (!string.IsNullOrEmpty(triggerAttribute))
|
||||
{
|
||||
spawner = parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute) && p.OccurredAtUtc <= childTime)
|
||||
?? parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute));
|
||||
}
|
||||
spawner ??= parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
|
||||
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
|
||||
?? parentChain[0];
|
||||
}
|
||||
|
||||
if (spawner is null) continue;
|
||||
@@ -228,6 +241,12 @@ public static class FlowGraphBuilder
|
||||
private static bool Spawnable(FlowStep step)
|
||||
=> step.Kind != FlowStepKind.Response && !IsAsync(step);
|
||||
|
||||
/// <summary>Is this an <c>AttributeWrite</c> step whose write list names <paramref name="attribute"/> (ordinal, case-insensitive)?</summary>
|
||||
private static bool WritesAttribute(FlowStep step, string attribute)
|
||||
=> step.Kind == FlowStepKind.AttributeWrite
|
||||
&& step.Extra?.Writes is { Count: > 0 } writes
|
||||
&& writes.Any(w => string.Equals(w.Attribute, attribute, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
|
||||
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
|
||||
|
||||
Reference in New Issue
Block a user