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:
Joseph Doherty
2026-09-06 01:59:55 -04:00
parent 56154fd1d2
commit 8f6f7ed92b
3 changed files with 171 additions and 8 deletions
@@ -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&amp;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",
};