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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user