8047de6903
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
291 lines
17 KiB
Plaintext
291 lines
17 KiB
Plaintext
@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>
|