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;
///
/// 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
/// Idle → Predicted → Running → Completed | Failed | TimedOut. The server's
/// STANDBY_NODE refusal renders as an inline alert with the message verbatim.
///
public partial class FlowDebugger : ComponentBase, IDisposable
{
/// Entry point kinds.
public enum EntryKind
{
/// An inbound API method.
Method,
///
/// 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.
///
Script,
}
/// Live vs Replay.
public enum FlowMode
{
/// Run it now.
Live,
/// Rebuild a past flow from the audit store.
Replay,
}
/// Page state.
public enum FlowState
{
/// Nothing chosen.
Idle,
/// Predicted graph shown, not yet run.
Predicted,
/// A run or replay is in flight.
Running,
/// Run finished (or replay loaded).
Completed,
/// Run refused or failed.
Failed,
/// Run timed out / no trigger within the window.
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!;
/// Drill-in: pre-select an inbound method.
[SupplyParameterFromQuery] public int? MethodId { get; set; }
/// Drill-in: pre-select an instance (script entry).
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
/// Drill-in: pre-select the script (with ).
[SupplyParameterFromQuery] public string? Script { get; set; }
/// Drill-in: replay this execution on first render.
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
private EntryKind _entry = EntryKind.Method;
private FlowMode _mode = FlowMode.Live;
private FlowState _state = FlowState.Idle;
///
/// True when the principal holds and may therefore start a
/// Live run (and relay Retry/Discard on a parked node). The page itself opens to the
/// wider 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).
///
private bool _canLive;
private List _methods = [];
private List _sites = [];
///
/// The selected site's instances, feeding the InstancePicker alongside
/// . 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 () and never rebuilt on a render path.
///
private List _instances = [];
/// The selected site's areas; the picker nests them into its tree. See .
private List _areas = [];
///
/// True while 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 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).
///
private bool _instancesLoading;
private List _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;
/// The chosen script's option row, or null when nothing (or something stale) is selected.
private FlowScriptOption? SelectedScript
=> _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
/// Call-kind parameter values from ParameterValueForm, and whether it currently parses.
private Dictionary _scriptParams = new();
private bool _scriptParamsValid;
private List _inputFields = [];
private readonly Dictionary _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;
/// Delay seam for the pending-refresh loop (tests inject an immediate delay).
internal Func Delay { get; set; } = Task.Delay;
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
///
/// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
/// carries any. error and warn are counted by name (ordinal, case-insensitive —
/// the level rides the audit row as free text); everything else, debug included, is
/// "info", so the three counts always add up to Total. Lines the builder could not place
/// on a step are NOT here — they are reported separately as
/// .
///
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);
/// Status-strip text for .
private string LogSummary
{
get
{
var counts = LogCounts;
return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
}
}
/// Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.
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",
};
///
/// 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).
///
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);
///
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();
}
}
///
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("localStorage.getItem", StorageEntry);
if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal))
{
_entry = EntryKind.Script;
var site = await JS.InvokeAsync("localStorage.getItem", StorageSite);
var inst = await JS.InvokeAsync("localStorage.getItem", StorageInstance);
var script = await JS.InvokeAsync("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("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();
}
///
/// Loads the selected site's instances and areas into the picker's two lists, holding
/// 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
/// finally: a repository fault must not leave the picker disabled forever.
///
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