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 @@
- @foreach (var tab in Tabs)
+ @foreach (var tab in VisibleTabs)
{
var t = tab;
-
+ @onclick="() => _tab = t">@TabLabel(t)
}
@@ -85,6 +88,30 @@
No attribute writes on this step.
}
break;
+ case "Lifecycle":
+ @if (Step.Lifecycle is { Count: > 0 } rows)
+ {
+
+ | Time (UTC) | Kind | Status | Node | Error |
+
+ @foreach (var r in rows)
+ {
+
+ | @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());
+ }
+}