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 4625d9ad..29ad7deb 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 @@ -58,7 +58,7 @@ { "id": 7, "subject": "Task 7: FlowGraphView \u2014 External lane, status pills, async/spawn edge styles, pending state", - "status": "in_progress", + "status": "completed", "blockedBy": [ 0, 1, @@ -68,7 +68,7 @@ { "id": 8, "subject": "Task 8: FlowStepDetail \u2014 Lifecycle tab and outbound fields", - "status": "pending", + "status": "in_progress", "blockedBy": [ 0, 2 @@ -96,5 +96,5 @@ ] } ], - "lastUpdated": "2026-09-06T05:52:50.321199+00:00" + "lastUpdated": "2026-09-06T05:57:47.424541+00:00" } \ No newline at end of file diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor index 1c159372..87f4ca57 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor @@ -5,7 +5,10 @@ @inject NavigationManager Navigation @* Flow Debugger step detail (design 2026-09-05 §4): tabs Input · Output · - Attribute writes · Timing plus a "Where this ran" column with drill-ins. *@ + Attribute writes · Timing plus a "Where this ran" column with drill-ins. + Outbound-calls addendum (plan 2026-09-06 D3, Task 8): a Lifecycle tab for folded + API / DB / notification operations, kind-specific Input labels, and the + System / Connection / List + "Delivered by" facts. *@
@@ -30,13 +33,13 @@
@@ -85,6 +88,30 @@
No attribute writes on this step.
} break; + case "Lifecycle": + @if (Step.Lifecycle is { Count: > 0 } rows) + { + + + + @foreach (var r in rows) + { + + + + + + + + } + +
Time (UTC)KindStatusNodeError
@r.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)@r.Kind@r.Status@(r.DurationMs is int lm ? $" · {lm} ms" : "")@(r.Node ?? "—")@(r.ErrorMessage ?? "")
+ @if (Step.Outcome == FlowStepOutcome.Parked) + { +
Retry or discard from Site Calls / Notification report.
+ } + } + break; case "Timing":
Occurred (UTC)
@Step.OccurredAtUtc.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)
@@ -99,10 +126,14 @@
Where this ran
-
+
Site / node
@(Join(Step.SiteId, Step.Node) ?? "central")
Instance
@(Step.Instance ?? "—")
-
Target
@(Step.Target ?? "—")
+
@TargetLabel
@(Step.Target ?? "—")
+ @if (DeliveredBy is { } by) + { +
Delivered by
@by
+ } @if (Step.Extra?.TriggerKind is { } tk) {
Trigger
@tk@(Step.Extra.TriggerAttribute is { } ta ? " · " + ta : "")
@@ -128,6 +159,7 @@ @code { private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"]; + private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Lifecycle", "Timing"]; /// The selected step; null renders the placeholder. [Parameter] public FlowStep? Step { get; set; } @@ -143,6 +175,41 @@ private string _tab = "Input"; + private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind); + + /// Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab. + private IEnumerable VisibleTabs + => Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs; + + /// Kind-specific label for the Input tab: what the payload actually is. + private string TabLabel(string tab) + => tab != "Input" ? tab : Step?.Kind switch + { + FlowStepKind.ApiCall => "Request", + FlowStepKind.DbCall => "SQL + parameters", + FlowStepKind.Notify => "Subject + body", + _ => "Input", + }; + + private string TargetLabel => Step?.Kind switch + { + FlowStepKind.ApiCall => "System", + FlowStepKind.DbCall => "Connection", + FlowStepKind.Notify => "List", + _ => "Target", + }; + + /// The node that wrote the terminal lifecycle row — central for a notification, the S&F node for a cached call. + private string? DeliveredBy + => IsOutbound && Step?.Lifecycle is { Count: > 0 } rows && Step.Outcome != FlowStepOutcome.Pending + ? rows[^1].Node + : null; + + protected override void OnParametersSet() + { + if (!VisibleTabs.Contains(_tab)) _tab = "Input"; + } + private int AuditRowCount => Step?.ExecutionId is { } exec && Graph != null ? Graph.Steps.Count(s => s.ExecutionId == exec && s.Kind != FlowStepKind.Response) @@ -153,6 +220,8 @@ FlowStepOutcome.Ok => "bg-success", FlowStepOutcome.Failed => "bg-danger", FlowStepOutcome.TimedOut => "bg-warning text-dark", + FlowStepOutcome.Pending => "bg-warning-subtle text-warning-emphasis border border-warning", + FlowStepOutcome.Parked => "bg-warning text-dark", FlowStepOutcome.NotTraced => "bg-secondary", _ => "bg-light text-dark", }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs new file mode 100644 index 00000000..bbf9ec71 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs @@ -0,0 +1,94 @@ +using Bunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab, +/// outbound "Where this ran" fields and kind-specific Input tab labels. +/// +public class FlowStepDetailTests : BunitContext +{ + private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + + private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList? lifecycle = null) + => new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome, + "{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle); + + private static IReadOnlyList CachedLifecycle(string terminalStatus) => + [ + new(Guid.NewGuid(), "CachedSubmit", "Submitted", T0, "node-a", null, null), + new(Guid.NewGuid(), "ApiCallCached", "Attempted", T0.AddSeconds(3), "node-a", "503 Service Unavailable", 40), + new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null), + ]; + + [Fact] + public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy() + { + var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered")); + var cut = Render(p => p.Add(x => x.Step, step)); + + cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); + + var rows = cut.FindAll("[data-test=flow-step-lifecycle] tbody tr"); + Assert.Equal(3, rows.Count); + Assert.Contains("CachedSubmit", rows[0].TextContent); + Assert.Contains("Submitted", rows[0].TextContent); + Assert.Contains("503 Service Unavailable", rows[1].TextContent); + Assert.Contains("CachedResolve", rows[2].TextContent); + Assert.Contains("node-b", rows[2].TextContent); + Assert.Contains("12:00:04.100", rows[2].TextContent); + + var where = cut.Find("[data-test=flow-step-where]").TextContent; + Assert.Contains("System", where); + Assert.Contains("Delmia.AckRecipe", where); + Assert.Contains("Delivered by", where); + Assert.Contains("node-b", where); + Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]")); + } + + [Fact] + public void NoLifecycle_HidesTheTab() + { + var step = Step(FlowStepKind.AttributeWrite, FlowStepOutcome.Ok, "A") with { Lane = FlowLane.Tags }; + var cut = Render(p => p.Add(x => x.Step, step)); + Assert.Empty(cut.FindAll("[data-test=flow-step-tab-lifecycle]")); + Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); + } + + [Fact] + public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge() + { + var lifecycle = new List + { + new(Guid.NewGuid(), "NotifySend", "Submitted", T0, "node-a", null, null), + new(Guid.NewGuid(), "NotifyDeliver", "Parked", T0.AddSeconds(9), "central-a", "retries exhausted", null), + }; + var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", lifecycle); + var cut = Render(p => p.Add(x => x.Step, step)); + + Assert.Equal("Parked", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); + Assert.Contains("warning", cut.Find("[data-test=flow-step-outcome]").ClassName); + Assert.Equal("Subject + body", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); + var where = cut.Find("[data-test=flow-step-where]").TextContent; + Assert.Contains("List", where); + Assert.Contains("central-a", where); + + cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); + Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent); + } + + [Theory] + [InlineData(FlowStepKind.ApiCall, "Request", "System")] + [InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")] + [InlineData(FlowStepKind.Notify, "Subject + body", "List")] + public void OutboundKinds_LabelInputTabAndTarget(FlowStepKind kind, string inputLabel, string targetLabel) + { + var step = Step(kind, FlowStepOutcome.Pending, "T"); + var cut = Render(p => p.Add(x => x.Step, step)); + Assert.Equal(inputLabel, cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); + Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent); + Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); + } +}