feat(central-ui): Flow Debugger page (predict, live run, replay, step detail) + nav entry

/deployment/flow-debugger (RequireDeployment): entry toggle (inbound method | value-change
trigger), method picker or Site → Instance → Attribute pickers (site-scope filtered), Live |
Replay mode, inputs form generated from the method's ParameterDefinitions via
InboundApiSchema (values coerced by declared type into the request JsonElement), status
strip (state chip, run #, start, total, steps/boundaries/writes, root ExecutionId link to
the Execution Chain page), the FlowGraphView with −/Fit/+ zoom, and FlowStepDetail (tabs
Input · Output · Writes · Timing, 'Where this ran' column, Open in Debug View when the
instance is known, Audit rows (n) → /audit/log?executionId=). State machine Idle →
Predicted → Running → Completed | Failed | TimedOut; STANDBY_NODE renders as an inline
warning alert with the server message verbatim; ?methodId= / ?instanceId=&attribute= /
?executionId= drill-ins (the last replays on first render); last entry + inputs persisted
per method in localStorage; 'site not traced' label when a run's route call came back with
no child execution and no Site-lane steps. NavMenu: Deployment → Flow Debugger.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:31:55 -04:00
parent ba15a38327
commit 254118a467
6 changed files with 1242 additions and 0 deletions
@@ -0,0 +1,177 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment
@using System.Globalization
@using System.Text.Json
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
@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. *@
<div class="card" data-test="flow-step-detail">
<div class="card-body">
@if (Step is null)
{
<div class="text-muted small" data-test="flow-step-detail-empty">Select a step in the flow to see its input, output and timing.</div>
}
else
{
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div>
<div class="fsd-eyebrow">@Step.Kind.ToString().ToUpperInvariant() · @Step.Lane</div>
<h6 class="mb-0" data-test="flow-step-title">@Step.Title</h6>
@if (!string.IsNullOrEmpty(Step.Subtitle))
{
<div class="small text-muted">@Step.Subtitle</div>
}
</div>
<span class="badge @OutcomeBadge" data-test="flow-step-outcome">@Step.Outcome</span>
</div>
<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)
{
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>
</li>
}
</ul>
<div class="fsd-pane" role="tabpanel">
@switch (_tab)
{
case "Input":
<pre class="fsd-json" data-test="flow-step-input">@Pretty(Step.InputJson)</pre>
break;
case "Output":
@if (!string.IsNullOrEmpty(Step.ErrorMessage))
{
<div class="alert alert-danger small py-1 mb-2" data-test="flow-step-error">@Step.ErrorMessage</div>
}
<pre class="fsd-json" data-test="flow-step-output">@Pretty(Step.OutputJson)</pre>
break;
case "Writes":
@if (Step.Extra?.Writes is { Count: > 0 } writes)
{
<table class="table table-sm small mb-0" data-test="flow-step-writes">
<thead><tr><th>Attribute</th><th>Written</th><th>Result</th></tr></thead>
<tbody>
@foreach (var w in writes)
{
<tr>
<td class="font-monospace">@w.Attribute</td>
<td class="font-monospace">@(w.Written ?? "—")</td>
<td>@(w.Success ? "ok" : (w.Error ?? "failed"))</td>
</tr>
}
</tbody>
</table>
}
else if (Step.Kind == FlowStepKind.AttributeWait && Step.Extra is { } wx)
{
<dl class="row small mb-0">
<dt class="col-4">Attribute</dt><dd class="col-8 font-monospace">@wx.WaitAttribute</dd>
<dt class="col-4">Target</dt><dd class="col-8 font-monospace">@wx.WaitTarget</dd>
<dt class="col-4">Timeout</dt><dd class="col-8">@(wx.WaitTimeoutMs is int ms ? ms + " ms" : "—")</dd>
<dt class="col-4">Outcome</dt><dd class="col-8">@wx.WaitOutcome</dd>
<dt class="col-4">Observed</dt><dd class="col-8 font-monospace">@(wx.ObservedValue ?? "—")</dd>
</dl>
}
else
{
<div class="text-muted small">No attribute writes on this step.</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>
<dt class="col-4">Duration</dt><dd class="col-8 font-monospace">@(Step.DurationMs is int d ? d.ToString(CultureInfo.InvariantCulture) + " ms" : "—")</dd>
<dt class="col-4">Execution</dt><dd class="col-8 font-monospace">@(Step.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4">Parent</dt><dd class="col-8 font-monospace">@(Step.ParentExecutionId?.ToString() ?? "—")</dd>
</dl>
break;
}
</div>
</div>
<div class="col-lg-4">
<div class="fsd-eyebrow mb-1">Where this ran</div>
<dl class="row small mb-2">
<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>
@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>
}
</dl>
<div class="d-flex flex-wrap gap-2">
@if (SiteDbId is int sid && InstanceDbId is int iid)
{
<a class="btn btn-outline-secondary btn-sm" data-test="open-debug-view"
href="@($"/deployment/debug-view?siteId={sid}&instanceId={iid}")">Open in Debug View</a>
}
@if (Step.ExecutionId is { } exec)
{
<a class="btn btn-outline-secondary btn-sm" data-test="open-audit-rows"
href="@($"/audit/log?executionId={exec}")">Audit rows (@AuditRowCount)</a>
}
</div>
</div>
</div>
}
</div>
</div>
@code {
private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"];
/// <summary>The selected step; null renders the placeholder.</summary>
[Parameter] public FlowStep? Step { get; set; }
/// <summary>The whole graph, used to count sibling audit rows of the step's execution.</summary>
[Parameter] public FlowGraph? Graph { get; set; }
/// <summary>Site database id for the Debug View drill-in (known for value-change entries).</summary>
[Parameter] public int? SiteDbId { get; set; }
/// <summary>Instance database id for the Debug View drill-in (known for value-change entries).</summary>
[Parameter] public int? InstanceDbId { get; set; }
private string _tab = "Input";
private int AuditRowCount
=> Step?.ExecutionId is { } exec && Graph != null
? Graph.Steps.Count(s => s.ExecutionId == exec && s.Kind != FlowStepKind.Response)
: 0;
private string OutcomeBadge => Step?.Outcome switch
{
FlowStepOutcome.Ok => "bg-success",
FlowStepOutcome.Failed => "bg-danger",
FlowStepOutcome.TimedOut => "bg-warning text-dark",
FlowStepOutcome.NotTraced => "bg-secondary",
_ => "bg-light text-dark",
};
private static string? Join(string? a, string? b)
=> string.IsNullOrEmpty(a) ? b : string.IsNullOrEmpty(b) ? a : $"{a} / {b}";
/// <summary>Pretty-prints JSON when the text is JSON; otherwise returns it as-is (or an em-dash).</summary>
internal static string Pretty(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return "—";
try
{
using var doc = JsonDocument.Parse(text);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch (JsonException)
{
return text;
}
}
}