Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
T
Joseph Doherty 8047de6903 fix(central-ui): Flow Debugger picker — site change clears the pick (test); wider column, shorter placeholder
Pins the site-change behaviour the picker's reference contract exists for: after
picking an instance, switching the site select empties both the page's
_selectedInstanceId and the picker's field text (the new site hands it a
different instance list), and leaves the script select on its placeholder.

Layout: the Instance column goes col-md-2 → col-md-3 so a full instance name
fits — the script-entry branch still sums to 8 of 12 (site 2 + instance 3 +
script 3), and Method never renders beside them — with the placeholder
shortened to "Search instances…" for the narrower field.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:49:05 -04:00

291 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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
@* arch-review F3: opening the page is a Replay-capable read (Deployer + the OperationalAudit
roles) so the Audit Log / Execution Chain drill-ins work for an auditor. Every Live-run
affordance below is additionally gated on _canLive (the Deployer role). *@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireFlowDebugger)]
<PageTitle>Flow Debugger</PageTitle>
<div class="container-fluid mt-3">
<h4 class="mb-3">Flow Debugger</h4>
<ToastNotification @ref="_toast" />
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
@* The entry point only selects what a Live run starts from, so it is Deployer-only. *@
@if (_canLive)
{
<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.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</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>
@* Wider than the site select beside it: the field carries a whole instance
name, not a short site name. The script-entry branch still sums to 8 of 12
(site 2 + instance 3 + script 3) alongside the auto-width toolbar columns. *@
<div class="col-md-3">
<label class="form-label form-label-sm">Instance</label>
@* A site can hold hundreds of instances, so this is the shared area-tree
combobox, not a flat list (design 2026-09-08). _areas/_instances are
loaded once per site — the picker treats a new list as a site change. *@
<InstancePicker Areas="_areas" Instances="_instances"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
Disabled="@(_running || _selectedSiteId == 0)"
Placeholder="Search instances…" DataTestPrefix="flow-instance" />
</div>
<div class="col-md-3">
<label class="form-label form-label-sm">Script</label>
<select class="form-select form-select-sm" data-test="flow-script-select"
@bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<option value="">— choose —</option>
@foreach (var s in _scripts)
{
<option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</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>
}
@* Without Deployer there is only Replay, so the toggle would be a single dead button. *@
@if (_canLive)
{
<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="() => SetMode(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="() => SetMode(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>
@if (!_canLive)
{
<div class="text-muted small mb-2" data-test="flow-replay-only">
Replay only — rebuilding a past execution from the audit log. Starting a live run
requires the Deployer role.
</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 (PendingCount > 0)
{
<span><span class="fd-label">Pending</span><span class="fd-mono" data-test="flow-pending-count">@PendingCount</span></span>
}
}
@if (_raw != null)
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0" data-test="flow-refresh"
disabled="@(!CanRefresh)" @onclick="RefreshOutcomesAsync">Refresh outcomes</button>
}
@if (_refreshing)
{
<span class="text-muted small" data-test="flow-pending-note">outcomes still arriving — refreshing</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.Script ? SelectedScript != null : _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 if (SelectedScript is { } script)
{
@* A write kind is fired by writing its own trigger attribute — the operator
supplies only the value. Every other kind is called directly with the
parameters its definitions declare. *@
@if (script.IsWriteKind)
{
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
<div class="form-text">@script.TriggerType trigger — writing the attribute fires the script.</div>
</div>
}
else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
<div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
}
else
{
<div data-test="flow-script-params">
@* Keyed on the script so a change of script REMOUNTS the form: its raw
text and parse errors are per-instance state that must not survive
(and two scripts may even declare byte-identical definitions, which
would otherwise skip the reparse and its ValidChanged entirely). *@
<ParameterValueForm @key="@script.CanonicalName"
ParameterDefinitions="@script.ParameterDefinitions"
Values="_scriptParams"
ValuesChanged="@(v => _scriptParams = v)"
ValidChanged="@(ok => _scriptParamsValid = ok)" />
</div>
}
}
</div>
</div>
</div>
}
@* ── Flow panel ────────────────────────────────────────────────────── *@
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _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">
@* Retry / Discard relay a command to the site — only wired for a Deployer, so the
buttons never render for an auditor (arch-review F3). *@
<FlowStepDetail Step="_selected" Graph="_graph"
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" />
</div>
</div>
</div>