1016 lines
40 KiB
C#
1016 lines
40 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.JSInterop;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
|
|
|
/// <summary>
|
|
/// Flow Debugger page (design 2026-09-05 §4, script entry 2026-09-08 §5): pick an entry point
|
|
/// (inbound API method or a site script), see the predicted swimlane, run it live with tracing on
|
|
/// the active central node, or replay a past execution from the audit store. State machine
|
|
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
|
|
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
|
|
/// </summary>
|
|
public partial class FlowDebugger : ComponentBase, IDisposable
|
|
{
|
|
/// <summary>Entry point kinds.</summary>
|
|
public enum EntryKind
|
|
{
|
|
/// <summary>An inbound API method.</summary>
|
|
Method,
|
|
/// <summary>
|
|
/// A script on a site instance: a write-kind trigger (ValueChange / Conditional) is fired
|
|
/// by writing its monitored attribute, every other kind is called directly.
|
|
/// </summary>
|
|
Script,
|
|
}
|
|
|
|
/// <summary>Live vs Replay.</summary>
|
|
public enum FlowMode
|
|
{
|
|
/// <summary>Run it now.</summary>
|
|
Live,
|
|
/// <summary>Rebuild a past flow from the audit store.</summary>
|
|
Replay,
|
|
}
|
|
|
|
/// <summary>Page state.</summary>
|
|
public enum FlowState
|
|
{
|
|
/// <summary>Nothing chosen.</summary>
|
|
Idle,
|
|
/// <summary>Predicted graph shown, not yet run.</summary>
|
|
Predicted,
|
|
/// <summary>A run or replay is in flight.</summary>
|
|
Running,
|
|
/// <summary>Run finished (or replay loaded).</summary>
|
|
Completed,
|
|
/// <summary>Run refused or failed.</summary>
|
|
Failed,
|
|
/// <summary>Run timed out / no trigger within the window.</summary>
|
|
TimedOut,
|
|
}
|
|
|
|
private const string StorageEntry = "flowDebugger.entry";
|
|
private const string StorageMethod = "flowDebugger.methodId";
|
|
private const string StorageSite = "flowDebugger.siteId";
|
|
private const string StorageInstance = "flowDebugger.instanceId";
|
|
private const string StorageScript = "flowDebugger.script";
|
|
private const string StorageInputsPrefix = "flowDebugger.inputs.";
|
|
|
|
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
|
|
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
|
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
|
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
|
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
|
[Inject] private IDialogService Dialog { get; set; } = default!;
|
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
|
|
|
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
|
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
|
|
|
/// <summary>Drill-in: pre-select an instance (script entry).</summary>
|
|
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
|
|
|
|
/// <summary>Drill-in: pre-select the script (with <see cref="InstanceId"/>).</summary>
|
|
[SupplyParameterFromQuery] public string? Script { get; set; }
|
|
|
|
/// <summary>Drill-in: replay this execution on first render.</summary>
|
|
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
|
|
|
|
private EntryKind _entry = EntryKind.Method;
|
|
private FlowMode _mode = FlowMode.Live;
|
|
private FlowState _state = FlowState.Idle;
|
|
|
|
/// <summary>
|
|
/// True when the principal holds <see cref="Roles.Deployer"/> and may therefore start a
|
|
/// Live run (and relay Retry/Discard on a parked node). The page itself opens to the
|
|
/// wider <see cref="AuthorizationPolicies.RequireFlowDebugger"/> set so an auditor can
|
|
/// follow the Audit Log / Execution Chain drill-in into Replay (arch-review F3); every
|
|
/// Live affordance is hidden when this is false, and the server enforces the same split
|
|
/// (the two run commands stay Deployer-only in the ManagementActor role matrix).
|
|
/// </summary>
|
|
private bool _canLive;
|
|
|
|
private List<ApiMethod> _methods = [];
|
|
private List<Site> _sites = [];
|
|
|
|
/// <summary>
|
|
/// The selected site's instances, feeding the <c>InstancePicker</c> alongside
|
|
/// <see cref="_areas"/>. The picker compares both by REFERENCE — a new list object is read
|
|
/// as "the site changed" and closes its panel — so they are assigned exactly once per site
|
|
/// load (<see cref="LoadInstancesAsync"/>) and never rebuilt on a render path.
|
|
/// </summary>
|
|
private List<Instance> _instances = [];
|
|
|
|
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
|
|
private List<Area> _areas = [];
|
|
|
|
/// <summary>
|
|
/// True while <see cref="LoadInstancesAsync"/> is fetching a site's instances and areas, and
|
|
/// the sole reason the picker is disabled for that window. Blazor renders the moment the load
|
|
/// hits its first await, and by then <see cref="_selectedSiteId"/> is already the NEW site
|
|
/// while the two lists are still the OLD ones — so without this the field is enabled over
|
|
/// stale data, and anything typed into it is wiped when the lists land (the picker reads a new
|
|
/// list object as "the site changed" and clears its text).
|
|
/// </summary>
|
|
private bool _instancesLoading;
|
|
private List<FlowScriptOption> _scripts = [];
|
|
|
|
private int _selectedMethodId;
|
|
private int _selectedSiteId;
|
|
private int _selectedInstanceId;
|
|
private string _selectedScript = string.Empty;
|
|
private string _valueChangeValue = string.Empty;
|
|
private string _replayExecutionIdText = string.Empty;
|
|
|
|
/// <summary>The chosen script's option row, or null when nothing (or something stale) is selected.</summary>
|
|
private FlowScriptOption? SelectedScript
|
|
=> _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
|
|
|
|
/// <summary>Call-kind parameter values from <c>ParameterValueForm</c>, and whether it currently parses.</summary>
|
|
private Dictionary<string, object?> _scriptParams = new();
|
|
private bool _scriptParamsValid;
|
|
|
|
private List<InboundApiSchemaField> _inputFields = [];
|
|
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
|
|
|
|
private PredictedFlow? _predicted;
|
|
private FlowGraph? _graph;
|
|
private FlowDebugRunResult? _raw;
|
|
private FlowStep? _selected;
|
|
private string? _error;
|
|
private string? _errorCode;
|
|
private int _runNumber;
|
|
private double _zoom = 1.0;
|
|
private bool _running;
|
|
private bool _storageRestored;
|
|
|
|
// Outbound calls (plan 2026-09-06 D5): after a Live run whose graph still has Pending
|
|
// outbound steps, re-read the tree through the Replay command until every step is terminal
|
|
// or the window closes. Page constants, not options — nothing operational depends on them.
|
|
internal static readonly TimeSpan PendingRefreshInterval = TimeSpan.FromSeconds(5);
|
|
internal static readonly TimeSpan PendingRefreshWindow = TimeSpan.FromMinutes(2);
|
|
private CancellationTokenSource? _refreshCts;
|
|
private bool _refreshing;
|
|
|
|
// Node actions (follow-ups plan 2026-09-06 D5/D6): Retry / Discard on a Parked External node.
|
|
// The relay itself lives in IFlowDebugService; the page owns the site-scope re-check, the
|
|
// confirm dialog and the re-poll that watches the acted node until it leaves Parked.
|
|
private ToastNotification _toast = default!;
|
|
private bool _actionInProgress;
|
|
private Guid? _watchedStepId;
|
|
|
|
/// <summary>Delay seam for the pending-refresh loop (tests inject an immediate delay).</summary>
|
|
internal Func<TimeSpan, CancellationToken, Task> Delay { get; set; } = Task.Delay;
|
|
|
|
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
|
|
|
|
/// <summary>
|
|
/// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
|
|
/// carries any. <c>error</c> and <c>warn</c> are counted by name (ordinal, case-insensitive —
|
|
/// the level rides the audit row as free text); everything else, <c>debug</c> included, is
|
|
/// "info", so the three counts always add up to <c>Total</c>. Lines the builder could not place
|
|
/// on a step are NOT here — they are reported separately as
|
|
/// <see cref="FlowGraph.UnattachedLogLines"/>.
|
|
/// </summary>
|
|
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
|
|
{
|
|
if (graph is null)
|
|
return (0, 0, 0, 0);
|
|
|
|
var errors = 0;
|
|
var warnings = 0;
|
|
var info = 0;
|
|
foreach (var line in graph.Steps.SelectMany(s => s.Logs ?? []))
|
|
{
|
|
if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++;
|
|
else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++;
|
|
else info++;
|
|
}
|
|
|
|
return (errors, warnings, info, errors + warnings + info);
|
|
}
|
|
|
|
private (int Errors, int Warnings, int Info, int Total) LogCounts => CountLogs(_graph);
|
|
|
|
/// <summary>Status-strip text for <see cref="LogCounts"/>.</summary>
|
|
private string LogSummary
|
|
{
|
|
get
|
|
{
|
|
var counts = LogCounts;
|
|
return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
|
|
}
|
|
}
|
|
|
|
/// <summary>Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.</summary>
|
|
private bool ShouldKeepPolling =>
|
|
PendingCount > 0
|
|
|| (_watchedStepId is { } w && _graph?.Steps.Any(s => s.EventId == w && s.Outcome == FlowStepOutcome.Parked) == true);
|
|
|
|
private bool CanRefresh => !_running && _raw is not null;
|
|
|
|
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
|
|
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
|
|
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
|
_entry == EntryKind.Method ? _selectedMethodId > 0
|
|
: SelectedScript is { } s && (s.IsWriteKind
|
|
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
|
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
|
|
|
private string StateLabel => _state switch
|
|
{
|
|
FlowState.Idle => "Not run",
|
|
FlowState.Predicted => "Predicted",
|
|
FlowState.Running => "Running",
|
|
FlowState.Completed => "Completed",
|
|
FlowState.Failed => "Failed",
|
|
FlowState.TimedOut => "Timed out",
|
|
_ => _state.ToString(),
|
|
};
|
|
|
|
private string StateCss => _state switch
|
|
{
|
|
FlowState.Idle => "idle",
|
|
FlowState.Predicted => "predicted",
|
|
FlowState.Running => "running",
|
|
FlowState.Completed => "completed",
|
|
FlowState.Failed => "failed",
|
|
FlowState.TimedOut => "timed-out",
|
|
_ => "idle",
|
|
};
|
|
|
|
/// <summary>
|
|
/// True when a run produced a route call whose site leg came back untraced — no Site-lane
|
|
/// steps and no child execution id on the route call (older site build / untraced call).
|
|
/// </summary>
|
|
private bool SiteNotTraced
|
|
=> _graph is { Steps.Count: > 0 }
|
|
&& _graph.Steps.All(s => s.Lane != FlowLane.Site)
|
|
&& _graph.Steps.Any(s => s.Kind == FlowStepKind.RouteCall && s.Extra?.ChildExecutionId == null);
|
|
|
|
/// <inheritdoc/>
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// arch-review F3: page access is the wider RequireFlowDebugger set; only a Deployer
|
|
// gets the Live half. Resolved before anything else so the Live-only drill-ins below
|
|
// are skipped and the mode starts on Replay for an auditor.
|
|
_canLive = (await Auth.GetAuthenticationStateAsync()).User
|
|
.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
|
|
if (!_canLive)
|
|
_mode = FlowMode.Replay;
|
|
|
|
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
|
|
|
if (!_canLive)
|
|
{
|
|
// ?methodId= / ?instanceId= are Live-run drill-ins; ?executionId= is the audit
|
|
// drill-in and is the only one that means anything without Deployer.
|
|
if (ExecutionId is Guid auditExec && auditExec != Guid.Empty)
|
|
{
|
|
_replayExecutionIdText = auditExec.ToString();
|
|
await LoadReplayAsync();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (InstanceId is int iid && iid > 0)
|
|
{
|
|
_entry = EntryKind.Script;
|
|
var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid);
|
|
if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId))
|
|
{
|
|
_selectedSiteId = inst.SiteId;
|
|
await LoadInstancesAsync();
|
|
_selectedInstanceId = inst.Id;
|
|
await LoadScriptsAsync();
|
|
// The link may spell the script in any case; the picker binds the canonical one.
|
|
var named = _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, Script, StringComparison.OrdinalIgnoreCase));
|
|
if (named != null)
|
|
{
|
|
_selectedScript = named.CanonicalName;
|
|
await PredictAsync();
|
|
}
|
|
}
|
|
}
|
|
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
|
|
{
|
|
_entry = EntryKind.Method;
|
|
_selectedMethodId = mid;
|
|
await OnMethodChangedAsync();
|
|
}
|
|
|
|
if (ExecutionId is Guid exec && exec != Guid.Empty)
|
|
{
|
|
_mode = FlowMode.Replay;
|
|
_replayExecutionIdText = exec.ToString();
|
|
await LoadReplayAsync();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (!firstRender || _storageRestored)
|
|
return;
|
|
_storageRestored = true;
|
|
|
|
// Only restore from localStorage when nothing was chosen through the URL.
|
|
if (_selectedMethodId > 0 || _selectedInstanceId > 0 || ExecutionId != null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry);
|
|
if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal))
|
|
{
|
|
_entry = EntryKind.Script;
|
|
var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite);
|
|
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
|
|
var script = await JS.InvokeAsync<string?>("localStorage.getItem", StorageScript);
|
|
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
|
|
{
|
|
_selectedSiteId = sid;
|
|
await LoadInstancesAsync();
|
|
if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid))
|
|
{
|
|
_selectedInstanceId = iid;
|
|
await LoadScriptsAsync();
|
|
if (_scripts.Any(s => string.Equals(s.CanonicalName, script, StringComparison.Ordinal)))
|
|
{
|
|
_selectedScript = script!;
|
|
await PredictAsync();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var method = await JS.InvokeAsync<string?>("localStorage.getItem", StorageMethod);
|
|
if (int.TryParse(method, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mid) && _methods.Any(m => m.Id == mid))
|
|
{
|
|
_selectedMethodId = mid;
|
|
await OnMethodChangedAsync();
|
|
}
|
|
}
|
|
|
|
StateHasChanged();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// localStorage unavailable (prerender, test host) — start clean.
|
|
}
|
|
}
|
|
|
|
private async Task SetEntryAsync(EntryKind entry)
|
|
{
|
|
if (_entry == entry) return;
|
|
_entry = entry;
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
// Inputs are per-entry-point, as in OnScriptChangedAsync: never carry a script's value or
|
|
// parameter tree (or its validity) across a switch of entry kind.
|
|
_scriptParams = new();
|
|
_scriptParamsValid = false;
|
|
_valueChangeValue = string.Empty;
|
|
await PersistAsync(StorageEntry, entry.ToString());
|
|
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
|
await OnMethodChangedAsync();
|
|
else if (_entry == EntryKind.Script && SelectedScript != null)
|
|
await PredictAsync();
|
|
}
|
|
|
|
private async Task OnMethodChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_inputFields = [];
|
|
_inputs.Clear();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
|
|
var method = _methods.FirstOrDefault(m => m.Id == _selectedMethodId);
|
|
if (method == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var schema = InboundApiSchema.Parse(method.ParameterDefinitions);
|
|
if (schema is { Type: "object" })
|
|
_inputFields = schema.Fields.ToList();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
_inputFields = [];
|
|
}
|
|
|
|
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
|
await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture));
|
|
await RestoreInputsAsync(method.Id);
|
|
await PredictAsync();
|
|
}
|
|
|
|
private async Task OnSiteChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
_selectedInstanceId = 0;
|
|
_selectedScript = string.Empty;
|
|
_scripts = [];
|
|
await LoadInstancesAsync();
|
|
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private async Task OnInstanceChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
_selectedScript = string.Empty;
|
|
await LoadScriptsAsync();
|
|
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private async Task OnScriptChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
// Inputs belong to the chosen script: a write kind's value and a call kind's parameter
|
|
// tree are both dropped so nothing carries across a change of script.
|
|
_scriptParams = new();
|
|
_scriptParamsValid = false;
|
|
_valueChangeValue = string.Empty;
|
|
await PersistAsync(StorageEntry, nameof(EntryKind.Script));
|
|
await PersistAsync(StorageScript, _selectedScript);
|
|
if (SelectedScript != null)
|
|
await PredictAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the selected site's instances and areas into the picker's two lists, holding
|
|
/// <see cref="_instancesLoading"/> for the whole round trip so the field cannot be typed
|
|
/// into while it is still showing the previous site's tree. The flag is cleared in a
|
|
/// <c>finally</c>: a repository fault must not leave the picker disabled forever.
|
|
/// </summary>
|
|
private async Task LoadInstancesAsync()
|
|
{
|
|
_instancesLoading = true;
|
|
try
|
|
{
|
|
if (_selectedSiteId <= 0)
|
|
{
|
|
_instances = [];
|
|
_areas = [];
|
|
return;
|
|
}
|
|
|
|
// Neither list is sorted here: the picker nests and orders both itself
|
|
// (InstanceTreeBuilder). The OrderBy this replaces dated from the flat <select>.
|
|
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).ToList();
|
|
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
|
}
|
|
finally
|
|
{
|
|
_instancesLoading = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The picker raises the id rather than binding a <select>, so the page applies it
|
|
/// before running the same change handler the old dropdown used.
|
|
/// </summary>
|
|
private async Task OnInstancePickedAsync(int id)
|
|
{
|
|
_selectedInstanceId = id;
|
|
await OnInstanceChangedAsync();
|
|
}
|
|
|
|
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
|
|
private async Task LoadScriptsAsync()
|
|
{
|
|
_scripts = _selectedInstanceId > 0
|
|
? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList()
|
|
: [];
|
|
}
|
|
|
|
private async Task PredictAsync()
|
|
{
|
|
try
|
|
{
|
|
_predicted = _entry == EntryKind.Method
|
|
? await FlowDebug.PredictMethodAsync(_selectedMethodId)
|
|
: await FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript);
|
|
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_predicted = null;
|
|
_error = "Prediction failed: " + ex.Message;
|
|
_errorCode = "PREDICTION_FAILED";
|
|
}
|
|
}
|
|
|
|
private void SetMode(FlowMode mode)
|
|
{
|
|
if (_mode == mode) return;
|
|
// Without Deployer there is no Live half to switch to (arch-review F3).
|
|
if (mode == FlowMode.Live && !_canLive) return;
|
|
CancelPendingRefresh();
|
|
_mode = mode;
|
|
}
|
|
|
|
private async Task RunAsync()
|
|
{
|
|
if (!CanRun) return;
|
|
CancelPendingRefresh();
|
|
_running = true;
|
|
_state = FlowState.Running;
|
|
_error = null;
|
|
_errorCode = null;
|
|
_graph = null;
|
|
_raw = null;
|
|
_selected = null;
|
|
_runNumber++;
|
|
|
|
try
|
|
{
|
|
FlowDebugOutcome outcome;
|
|
if (_entry == EntryKind.Method)
|
|
{
|
|
await PersistInputsAsync(_selectedMethodId);
|
|
outcome = await FlowDebug.RunMethodAsync(_selectedMethodId, BuildParameters());
|
|
}
|
|
else
|
|
{
|
|
// A write kind is fired by writing its trigger attribute; every other kind is a
|
|
// direct traced call carrying the parameter form's values.
|
|
var script = SelectedScript!;
|
|
outcome = script.IsWriteKind
|
|
? await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, null, _valueChangeValue)
|
|
: await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, BuildScriptParameters(), null);
|
|
}
|
|
|
|
ApplyOutcome(outcome);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = ex.Message;
|
|
_errorCode = "RUN_FAILED";
|
|
_state = FlowState.Failed;
|
|
}
|
|
finally
|
|
{
|
|
_running = false;
|
|
}
|
|
|
|
StartPendingRefresh();
|
|
}
|
|
|
|
/// <summary>Begins polling when a completed Live run left outbound work in flight. Never in Replay mode.</summary>
|
|
private void StartPendingRefresh()
|
|
{
|
|
if (_mode != FlowMode.Live || PendingCount == 0)
|
|
return;
|
|
StartRefreshLoop();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts (or restarts) the bounded refresh loop while <see cref="ShouldKeepPolling"/> holds — after a
|
|
/// Live run with Pending nodes, and after a Retry/Discard relay in either mode.
|
|
/// </summary>
|
|
private void StartRefreshLoop()
|
|
{
|
|
if (_raw is null || _graph is null || !ShouldKeepPolling)
|
|
return;
|
|
CancelPendingRefresh(keepWatch: true);
|
|
var cts = new CancellationTokenSource();
|
|
_refreshCts = cts;
|
|
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
|
}
|
|
|
|
private async Task RefreshPendingLoopAsync(Guid root, CancellationTokenSource cts)
|
|
{
|
|
var ct = cts.Token;
|
|
_refreshing = true;
|
|
var deadline = DateTime.UtcNow + PendingRefreshWindow;
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
|
{
|
|
await Delay(PendingRefreshInterval, ct);
|
|
var outcome = await FlowDebug.ReplayAsync(root, ct);
|
|
if (ct.IsCancellationRequested) return;
|
|
MergeRefreshedGraph(outcome);
|
|
await InvokeAsync(StateHasChanged);
|
|
if (!ShouldKeepPolling) return;
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// New run, mode switch or dispose — the loop is simply abandoned.
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Best-effort: the manual Refresh button stays available.
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(_refreshCts, cts))
|
|
{
|
|
_refreshing = false;
|
|
_refreshCts = null;
|
|
_watchedStepId = null;
|
|
cts.Dispose();
|
|
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Manual refresh — one Replay read of the run's tree; works in both modes.</summary>
|
|
private async Task RefreshOutcomesAsync()
|
|
{
|
|
if (!CanRefresh) return;
|
|
var root = _raw!.RootExecutionId;
|
|
_running = true;
|
|
try
|
|
{
|
|
MergeRefreshedGraph(await FlowDebug.ReplayAsync(root));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = "Refresh failed: " + ex.Message;
|
|
_errorCode = "REFRESH_FAILED";
|
|
}
|
|
finally
|
|
{
|
|
_running = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds the re-read tree INTO the run's known events (union by event id, re-read rows win) and
|
|
/// rebuilds the graph from the union, keeping the selection by event id. A refresh therefore only
|
|
/// ever adds rows: a Live run carries its site rows inline, while the same rows reach central's
|
|
/// audit store by telemetry seconds later — a Replay read taken in between lacks them, and
|
|
/// replacing the graph wholesale made an already-drawn node (a resolved cached DB write, on the
|
|
/// rig 2026-09-06) vanish until the next manual refresh. The run's own <c>_raw</c> timing, root
|
|
/// and the state chip are deliberately left alone: pending outbound work is not a failed run, and
|
|
/// the run's Started/Total are the truthful numbers.
|
|
/// </summary>
|
|
private void MergeRefreshedGraph(FlowDebugOutcome outcome)
|
|
{
|
|
if (!outcome.Success || outcome.Graph == null || _graph == null || _raw == null)
|
|
return;
|
|
var selectedId = _selected?.EventId;
|
|
var merged = MergeEvents(_raw.Events, outcome.Raw?.Events);
|
|
var truncated = _raw.Truncated || outcome.Raw?.Truncated == true;
|
|
// D7: the inline copy's cap is only worth reporting while the page still holds that
|
|
// incomplete copy. A refresh MERGES rows, so the moment it brings back even one ScriptLog
|
|
// row the run did not already have, the page is no longer short of lines and the flag
|
|
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
|
|
// 5-30 s later, so a refresh that adds nothing proves nothing.
|
|
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
|
|
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
|
|
// member with no consumer.
|
|
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
|
|
&& CountLogRows(merged) <= CountLogRows(_raw.Events);
|
|
_raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated };
|
|
_graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated);
|
|
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
|
|
}
|
|
|
|
/// <summary>Number of <c>ScriptTrace.ScriptLog</c> rows in <paramref name="events"/> — the log lines a refresh can add to.</summary>
|
|
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
|
|
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
|
|
|
|
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
|
|
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
|
|
{
|
|
var byId = new Dictionary<Guid, AuditEvent>();
|
|
foreach (var e in fresh ?? []) byId[e.EventId] = e;
|
|
foreach (var e in known ?? []) byId.TryAdd(e.EventId, e);
|
|
return byId.Values.ToList();
|
|
}
|
|
|
|
private void CancelPendingRefresh(bool keepWatch = false)
|
|
{
|
|
if (!keepWatch)
|
|
_watchedStepId = null;
|
|
var cts = _refreshCts;
|
|
if (cts is null) return;
|
|
_refreshCts = null;
|
|
_refreshing = false;
|
|
try { cts.Cancel(); } catch (ObjectDisposedException) { }
|
|
}
|
|
|
|
// ── Node actions ────────────────────────────────────────────────────────
|
|
|
|
private Task OnRetryStepAsync(FlowStep step) => ActOnStepAsync(step, retry: true);
|
|
|
|
private Task OnDiscardStepAsync(FlowStep step) => ActOnStepAsync(step, retry: false);
|
|
|
|
/// <summary>
|
|
/// The parked-node Retry callback, left unset (<c>default</c>) without the Deployer role so
|
|
/// <c>FlowStepDetail</c>'s <c>OnRetry.HasDelegate</c> check hides the button (arch-review F3).
|
|
/// </summary>
|
|
private EventCallback<FlowStep> RetryCallback
|
|
=> _canLive ? EventCallback.Factory.Create<FlowStep>(this, OnRetryStepAsync) : default;
|
|
|
|
/// <summary>The parked-node Discard callback; unset without Deployer, as <see cref="RetryCallback"/>.</summary>
|
|
private EventCallback<FlowStep> DiscardCallback
|
|
=> _canLive ? EventCallback.Factory.Create<FlowStep>(this, OnDiscardStepAsync) : default;
|
|
|
|
/// <summary>
|
|
/// Retry / Discard a Parked outbound node: site-scope re-check → confirm → relay through the
|
|
/// service → toast → re-poll Replay until the node leaves Parked. Mirrors the Site Calls /
|
|
/// Notification report pages' own action paths (same guards, same wording).
|
|
/// </summary>
|
|
private async Task ActOnStepAsync(FlowStep step, bool retry)
|
|
{
|
|
// _canLive: relaying Retry/Discard to the site is a Deployer act. The buttons are not
|
|
// rendered without it (the callbacks are left unset), but the gate is repeated here so
|
|
// the split does not rest on markup alone (arch-review F3).
|
|
if (!_canLive || _actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr)
|
|
return;
|
|
|
|
if (!await IsStepSiteAllowedAsync(step))
|
|
{
|
|
_toast.ShowError("You are not permitted to act on cached calls for this site.");
|
|
return;
|
|
}
|
|
|
|
var isNotify = step.Kind == FlowStepKind.Notify;
|
|
var shortId = corr.ToString("N")[..12];
|
|
var noun = isNotify ? "notification" : "cached call";
|
|
var confirmed = retry
|
|
? await Dialog.ConfirmAsync(
|
|
isNotify ? "Retry notification" : "Retry cached call",
|
|
isNotify
|
|
? $"Re-queue notification {shortId} (\"{step.Title}\") for delivery?"
|
|
: $"Relay a retry of cached call {shortId} (\"{step.Title}\") to site {step.SiteId}?")
|
|
: await Dialog.ConfirmAsync(
|
|
isNotify ? "Discard notification" : "Discard cached call",
|
|
isNotify
|
|
? $"Permanently discard notification {shortId} (\"{step.Title}\")? This cannot be undone."
|
|
: $"Relay a discard of {noun} {shortId} (\"{step.Title}\") to site {step.SiteId}? This cannot be undone.",
|
|
danger: true,
|
|
confirmLabel: "Discard");
|
|
if (!confirmed)
|
|
return;
|
|
|
|
_actionInProgress = true;
|
|
FlowOutboundActionResult result;
|
|
try
|
|
{
|
|
result = retry
|
|
? await FlowDebug.RetryOutboundAsync(step)
|
|
: await FlowDebug.DiscardOutboundAsync(step);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result = new FlowOutboundActionResult(false, (retry ? "Retry failed: " : "Discard failed: ") + ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
_actionInProgress = false;
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
_toast.ShowSuccess(result.Message);
|
|
_watchedStepId = step.EventId;
|
|
StartRefreshLoop();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side re-check before relaying — the action must not fire for a site outside the caller's
|
|
/// permitted set even though the node is on screen. Notifications are central-owned (no site).
|
|
/// An unresolvable site is denied for a scoped caller.
|
|
/// </summary>
|
|
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
|
|
{
|
|
if (step.Kind == FlowStepKind.Notify)
|
|
return true;
|
|
if (await SiteScope.IsSystemWideAsync())
|
|
return true;
|
|
var site = _sites.FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
|
|
if (site is null)
|
|
{
|
|
try
|
|
{
|
|
site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
site = null;
|
|
}
|
|
}
|
|
if (site is null)
|
|
return false;
|
|
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose() => CancelPendingRefresh();
|
|
|
|
private async Task LoadReplayAsync()
|
|
{
|
|
if (!Guid.TryParse(_replayExecutionIdText, out var exec)) return;
|
|
CancelPendingRefresh();
|
|
_running = true;
|
|
_state = FlowState.Running;
|
|
_error = null;
|
|
_errorCode = null;
|
|
_graph = null;
|
|
_raw = null;
|
|
_selected = null;
|
|
_predicted = null;
|
|
|
|
try
|
|
{
|
|
ApplyOutcome(await FlowDebug.ReplayAsync(exec));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = ex.Message;
|
|
_errorCode = "REPLAY_FAILED";
|
|
_state = FlowState.Failed;
|
|
}
|
|
finally
|
|
{
|
|
_running = false;
|
|
}
|
|
}
|
|
|
|
private void ApplyOutcome(FlowDebugOutcome outcome)
|
|
{
|
|
if (!outcome.Success || outcome.Graph == null)
|
|
{
|
|
_error = outcome.Error ?? "The run failed.";
|
|
_errorCode = outcome.ErrorCode;
|
|
_state = FlowState.Failed;
|
|
return;
|
|
}
|
|
|
|
_graph = outcome.Graph;
|
|
_raw = outcome.Raw;
|
|
if (_raw is { Completed: false })
|
|
{
|
|
_error = _raw.Error;
|
|
_errorCode = _raw.ErrorCode;
|
|
_state = _raw.ErrorCode is "NO_TRIGGER_WITHIN_WINDOW" or "TIMEOUT" ? FlowState.TimedOut : FlowState.Failed;
|
|
}
|
|
else
|
|
{
|
|
_state = _graph.Steps.Any(s => s.Outcome == FlowStepOutcome.TimedOut) ? FlowState.TimedOut
|
|
: _graph.Steps.Any(s => s.Outcome == FlowStepOutcome.Failed) ? FlowState.Failed
|
|
: FlowState.Completed;
|
|
}
|
|
|
|
_selected = _graph.Steps.FirstOrDefault();
|
|
}
|
|
|
|
private void ResetResult()
|
|
{
|
|
CancelPendingRefresh();
|
|
_graph = null;
|
|
_raw = null;
|
|
_selected = null;
|
|
_error = null;
|
|
_errorCode = null;
|
|
}
|
|
|
|
private void OnSelectStep(FlowStep step) => _selected = step;
|
|
|
|
private string InputValue(string name) => _inputs.TryGetValue(name, out var v) ? v : string.Empty;
|
|
|
|
private void SetInput(string name, string? value)
|
|
{
|
|
if (value == null) _inputs.Remove(name);
|
|
else _inputs[name] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the request body from the typed inputs, coercing by the schema's declared field
|
|
/// type (integers/numbers/booleans become JSON scalars; objects/lists are parsed as JSON;
|
|
/// everything else is a string). Empty inputs are omitted so a non-required field is not
|
|
/// sent as an empty string.
|
|
/// </summary>
|
|
internal JsonElement? BuildParameters()
|
|
{
|
|
if (_inputFields.Count == 0)
|
|
return null;
|
|
|
|
var obj = new JsonObject();
|
|
foreach (var f in _inputFields)
|
|
{
|
|
if (!_inputs.TryGetValue(f.Name, out var raw) || string.IsNullOrEmpty(raw))
|
|
continue;
|
|
obj[f.Name] = Coerce(f.Schema.Type, raw);
|
|
}
|
|
|
|
return JsonSerializer.SerializeToElement(obj);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
|
|
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
|
|
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
|
|
/// </summary>
|
|
internal JsonElement? BuildScriptParameters()
|
|
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
|
|
|
|
private static JsonNode? Coerce(string? type, string raw)
|
|
{
|
|
switch (InboundApiSchema.NormalizeType(type))
|
|
{
|
|
case "integer":
|
|
return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) ? JsonValue.Create(l) : JsonValue.Create(raw);
|
|
case "number":
|
|
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? JsonValue.Create(d) : JsonValue.Create(raw);
|
|
case "boolean":
|
|
return bool.TryParse(raw, out var b) ? JsonValue.Create(b) : JsonValue.Create(raw);
|
|
case "object":
|
|
case "array":
|
|
case "list":
|
|
try
|
|
{
|
|
return JsonNode.Parse(raw);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return JsonValue.Create(raw);
|
|
}
|
|
default:
|
|
return JsonValue.Create(raw);
|
|
}
|
|
}
|
|
|
|
private async Task PersistAsync(string key, string value)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("localStorage.setItem", key, value);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Best-effort convenience only.
|
|
}
|
|
}
|
|
|
|
private async Task PersistInputsAsync(int methodId)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
|
JsonSerializer.Serialize(_inputs));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
|
|
private async Task RestoreInputsAsync(int methodId)
|
|
{
|
|
try
|
|
{
|
|
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture));
|
|
if (string.IsNullOrEmpty(json)) return;
|
|
var saved = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
|
if (saved == null) return;
|
|
foreach (var (k, v) in saved)
|
|
if (_inputFields.Any(f => f.Name == k))
|
|
_inputs[k] = v;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
}
|