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