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,226 @@
|
||||
@page "/deployment/flow-debugger"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
|
||||
<PageTitle>Flow Debugger</PageTitle>
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Flow Debugger</h4>
|
||||
|
||||
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
||||
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Entry point</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_mode == FlowMode.Live)
|
||||
{
|
||||
@if (_entry == EntryKind.Method)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm">Method</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-method-select"
|
||||
@bind="_selectedMethodId" @bind:after="OnMethodChangedAsync" disabled="@_running">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var m in _methods)
|
||||
{
|
||||
<option value="@m.Id">@m.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Site</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-site-select"
|
||||
@bind="_selectedSiteId" @bind:after="OnSiteChangedAsync" disabled="@_running">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var s in _sites)
|
||||
{
|
||||
<option value="@s.Id">@s.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Instance</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-instance-select"
|
||||
@bind="_selectedInstanceId" @bind:after="OnInstanceChangedAsync" disabled="@(_running || _selectedSiteId == 0)">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var i in _instances)
|
||||
{
|
||||
<option value="@i.Id">@i.UniqueName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Attribute</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-attribute-select"
|
||||
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
|
||||
<option value="">— choose —</option>
|
||||
@foreach (var a in _attributes)
|
||||
{
|
||||
<option value="@a">@a</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label form-label-sm">Execution ID</label>
|
||||
<input class="form-control form-control-sm font-monospace" data-test="flow-execution-id"
|
||||
placeholder="any execution id in the chain" @bind="_replayExecutionIdText" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Mode</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
|
||||
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => _mode = FlowMode.Live">Live</button>
|
||||
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => _mode = FlowMode.Replay">Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-auto">
|
||||
@if (_mode == FlowMode.Live)
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" data-test="flow-run"
|
||||
disabled="@(!CanRun)" @onclick="RunAsync">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
}
|
||||
Run
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" data-test="flow-load"
|
||||
disabled="@(_running || !Guid.TryParse(_replayExecutionIdText, out _))" @onclick="LoadReplayAsync">Load from audit</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Inline alerts ───────────────────────────────────────────────────── *@
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="alert @(_errorCode == "STANDBY_NODE" ? "alert-warning" : "alert-danger") py-2 small"
|
||||
role="alert" data-test="flow-error" data-code="@_errorCode">
|
||||
@if (_errorCode != null)
|
||||
{
|
||||
<strong class="me-1">@_errorCode</strong>
|
||||
}
|
||||
@_error
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Status strip ─────────────────────────────────────────────────────── *@
|
||||
<div class="fd-status mb-3" data-test="flow-status">
|
||||
<span class="fd-chip @StateCss" data-test="flow-state">@StateLabel</span>
|
||||
@if (_runNumber > 0)
|
||||
{
|
||||
<span><span class="fd-label">Run</span><span class="fd-mono">#@_runNumber</span></span>
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
<span><span class="fd-label">Started</span><span class="fd-mono">@_raw.StartedAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff")Z</span></span>
|
||||
<span><span class="fd-label">Total</span><span class="fd-mono">@((int)(_raw.FinishedAtUtc - _raw.StartedAtUtc).TotalMilliseconds) ms</span></span>
|
||||
}
|
||||
@if (_graph != null)
|
||||
{
|
||||
<span><span class="fd-label">Steps</span><span class="fd-mono" data-test="flow-step-count">@_graph.Steps.Count</span></span>
|
||||
<span><span class="fd-label">Boundaries</span><span class="fd-mono">@_graph.Edges.Count(e => e.CrossesBoundary)</span></span>
|
||||
<span><span class="fd-label">Writes</span><span class="fd-mono">@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)</span></span>
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
<span>
|
||||
<span class="fd-label">Root</span>
|
||||
<a class="fd-mono" data-test="flow-root-execution" href="@($"/audit/execution-tree?executionId={_raw.RootExecutionId}")">@_raw.RootExecutionId</a>
|
||||
</span>
|
||||
}
|
||||
@if (_graph?.Truncated == true)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">event list truncated</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* ── Inputs (Live only) ────────────────────────────────────────────── *@
|
||||
@if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0))
|
||||
{
|
||||
<div class="col-lg-3">
|
||||
<div class="card fd-inputs" data-test="flow-inputs">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-muted">Inputs</h6>
|
||||
@if (_entry == EntryKind.Method)
|
||||
{
|
||||
@foreach (var f in _inputFields)
|
||||
{
|
||||
var field = f;
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">
|
||||
@field.Name
|
||||
<span class="text-muted">(@field.Schema.Type@(field.Required ? ", required" : ""))</span>
|
||||
</label>
|
||||
<input class="form-control form-control-sm" data-test="@("flow-input-" + field.Name)"
|
||||
value="@InputValue(field.Name)" @oninput="e => SetInput(field.Name, e.Value?.ToString())" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label>
|
||||
<input class="form-control form-control-sm" data-test="flow-input-value"
|
||||
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Flow panel ────────────────────────────────────────────────────── *@
|
||||
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
|
||||
<div class="fd-flow-tools">
|
||||
<div class="small text-muted">
|
||||
@if (_graph == null && _predicted != null)
|
||||
{
|
||||
<span data-test="flow-predicted-note">Predicted from script syntax — dashed nodes depend on inputs or live site state.</span>
|
||||
}
|
||||
else if (SiteNotTraced)
|
||||
{
|
||||
<span data-test="flow-site-not-traced">Site not traced — the site returned no trace rows for the routed call (older site build or an untraced call).</span>
|
||||
}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Max(0.4, _zoom - 0.1)" aria-label="Zoom out">−</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = 1.0" aria-label="Fit">Fit</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Min(2.0, _zoom + 0.1)" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowGraphView Graph="_graph" Predicted="_predicted" SelectedEventId="_selected?.EventId" OnSelect="OnSelectStep" Zoom="_zoom" />
|
||||
</div>
|
||||
|
||||
@* ── Detail panel ──────────────────────────────────────────────────── *@
|
||||
<div class="col-12">
|
||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user