feat(central-ui): FlowStepDetail Lifecycle tab + outbound request/response labels
A folded outbound step shows a Lifecycle tab (time · kind · status · node · error, oldest first; a Parked step adds the 'retry or discard from Site Calls / Notification report' hint), replaces the never-populated Writes tab for those steps, labels the Input tab Request / SQL + parameters / Subject + body by kind, and 'Where this ran' names the System / Connection / List plus 'Delivered by' (the terminal row's node). Pending/Parked outcome badges. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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. *@
|
||||
|
||||
<div class="card" data-test="flow-step-detail">
|
||||
<div class="card-body">
|
||||
@@ -30,13 +33,13 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8">
|
||||
<ul class="nav nav-tabs nav-tabs-sm" role="tablist">
|
||||
@foreach (var tab in Tabs)
|
||||
@foreach (var tab in VisibleTabs)
|
||||
{
|
||||
var t = tab;
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == t ? "active" : "")" type="button" role="tab"
|
||||
data-test="@("flow-step-tab-" + t.ToLowerInvariant())"
|
||||
@onclick="() => _tab = t">@t</button>
|
||||
@onclick="() => _tab = t">@TabLabel(t)</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -85,6 +88,30 @@
|
||||
<div class="text-muted small">No attribute writes on this step.</div>
|
||||
}
|
||||
break;
|
||||
case "Lifecycle":
|
||||
@if (Step.Lifecycle is { Count: > 0 } rows)
|
||||
{
|
||||
<table class="table table-sm small mb-0" data-test="flow-step-lifecycle">
|
||||
<thead><tr><th>Time (UTC)</th><th>Kind</th><th>Status</th><th>Node</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in rows)
|
||||
{
|
||||
<tr>
|
||||
<td class="font-monospace text-nowrap">@r.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)</td>
|
||||
<td class="font-monospace">@r.Kind</td>
|
||||
<td>@r.Status@(r.DurationMs is int lm ? $" · {lm} ms" : "")</td>
|
||||
<td class="font-monospace">@(r.Node ?? "—")</td>
|
||||
<td class="text-break">@(r.ErrorMessage ?? "")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (Step.Outcome == FlowStepOutcome.Parked)
|
||||
{
|
||||
<div class="text-muted small mt-2" data-test="flow-step-parked-hint">Retry or discard from Site Calls / Notification report.</div>
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Timing":
|
||||
<dl class="row small mb-0" data-test="flow-step-timing">
|
||||
<dt class="col-4">Occurred (UTC)</dt><dd class="col-8 font-monospace">@Step.OccurredAtUtc.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)</dd>
|
||||
@@ -99,10 +126,14 @@
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="fsd-eyebrow mb-1">Where this ran</div>
|
||||
<dl class="row small mb-2">
|
||||
<dl class="row small mb-2" data-test="flow-step-where">
|
||||
<dt class="col-5">Site / node</dt><dd class="col-7 font-monospace">@(Join(Step.SiteId, Step.Node) ?? "central")</dd>
|
||||
<dt class="col-5">Instance</dt><dd class="col-7 font-monospace">@(Step.Instance ?? "—")</dd>
|
||||
<dt class="col-5">Target</dt><dd class="col-7 font-monospace text-break">@(Step.Target ?? "—")</dd>
|
||||
<dt class="col-5">@TargetLabel</dt><dd class="col-7 font-monospace text-break">@(Step.Target ?? "—")</dd>
|
||||
@if (DeliveredBy is { } by)
|
||||
{
|
||||
<dt class="col-5">Delivered by</dt><dd class="col-7 font-monospace">@by</dd>
|
||||
}
|
||||
@if (Step.Extra?.TriggerKind is { } tk)
|
||||
{
|
||||
<dt class="col-5">Trigger</dt><dd class="col-7 font-monospace">@tk@(Step.Extra.TriggerAttribute is { } ta ? " · " + ta : "")</dd>
|
||||
@@ -128,6 +159,7 @@
|
||||
|
||||
@code {
|
||||
private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"];
|
||||
private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Lifecycle", "Timing"];
|
||||
|
||||
/// <summary>The selected step; null renders the placeholder.</summary>
|
||||
[Parameter] public FlowStep? Step { get; set; }
|
||||
@@ -143,6 +175,41 @@
|
||||
|
||||
private string _tab = "Input";
|
||||
|
||||
private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind);
|
||||
|
||||
/// <summary>Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab.</summary>
|
||||
private IEnumerable<string> VisibleTabs
|
||||
=> Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs;
|
||||
|
||||
/// <summary>Kind-specific label for the Input tab: what the payload actually is.</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>The node that wrote the terminal lifecycle row — central for a notification, the S&F node for a cached call.</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<FlowLifecycleRow>? 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<FlowLifecycleRow> 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<FlowStepDetail>(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<FlowStepDetail>(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<FlowLifecycleRow>
|
||||
{
|
||||
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<FlowStepDetail>(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<FlowStepDetail>(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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user