using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; 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; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; /// /// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a /// value-change trigger), 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 value-change trigger on an instance attribute. ValueChange, } /// 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 StorageAttribute = "flowDebugger.attribute"; 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!; /// Drill-in: pre-select an inbound method. [SupplyParameterFromQuery] public int? MethodId { get; set; } /// Drill-in: pre-select an instance (value-change entry). [SupplyParameterFromQuery] public int? InstanceId { get; set; } /// Drill-in: pre-select the trigger attribute (with ). [SupplyParameterFromQuery] public string? Attribute { 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; private List _methods = []; private List _sites = []; private List _instances = []; private List _attributes = []; private int _selectedMethodId; private int _selectedSiteId; private int _selectedInstanceId; private string _selectedAttribute = string.Empty; private string _valueChangeValue = string.Empty; private string _replayExecutionIdText = string.Empty; 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; /// 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; private bool CanRefresh => !_running && _raw is not null; private bool CanRun => !_running && _mode == FlowMode.Live && ( _entry == EntryKind.Method ? _selectedMethodId > 0 : _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute)); 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() { _methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList(); _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync()); if (InstanceId is int iid && iid > 0) { _entry = EntryKind.ValueChange; var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid); if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId)) { _selectedSiteId = inst.SiteId; await LoadInstancesAsync(); _selectedInstanceId = inst.Id; await LoadAttributesAsync(); if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute)) { _selectedAttribute = Attribute; 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.ValueChange), StringComparison.Ordinal)) { _entry = EntryKind.ValueChange; var site = await JS.InvokeAsync("localStorage.getItem", StorageSite); var inst = await JS.InvokeAsync("localStorage.getItem", StorageInstance); var attr = await JS.InvokeAsync("localStorage.getItem", StorageAttribute); 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 LoadAttributesAsync(); if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr)) { _selectedAttribute = attr; 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; await PersistAsync(StorageEntry, entry.ToString()); if (_entry == EntryKind.Method && _selectedMethodId > 0) await OnMethodChangedAsync(); else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty) 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; _selectedAttribute = string.Empty; _attributes = []; await LoadInstancesAsync(); await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture)); } private async Task OnInstanceChangedAsync() { ResetResult(); _predicted = null; _state = FlowState.Idle; _selectedAttribute = string.Empty; await LoadAttributesAsync(); await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture)); } private async Task OnAttributeChangedAsync() { ResetResult(); await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString()); await PersistAsync(StorageAttribute, _selectedAttribute); await PredictAsync(); } private async Task LoadInstancesAsync() { _instances = _selectedSiteId > 0 ? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList() : []; } private async Task LoadAttributesAsync() { _attributes = []; var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId); if (inst == null) return; var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId); _attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); } private async Task PredictAsync() { try { _predicted = _entry == EntryKind.Method ? await FlowDebug.PredictMethodAsync(_selectedMethodId) : await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute); _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; 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 { outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue); } ApplyOutcome(outcome); } catch (Exception ex) { _error = ex.Message; _errorCode = "RUN_FAILED"; _state = FlowState.Failed; } finally { _running = false; } StartPendingRefresh(); } /// Begins polling when a completed Live run left outbound work in flight. Never in Replay mode. private void StartPendingRefresh() { if (_mode != FlowMode.Live || _raw is null || _graph is null || PendingCount == 0) return; 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 (PendingCount == 0) 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; cts.Dispose(); try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { } } } } /// Manual refresh — one Replay read of the run's tree; works in both modes. 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; } } /// /// Replaces the graph with the re-read tree and keeps the selection by event id. The run's own /// _raw (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. /// private void MergeRefreshedGraph(FlowDebugOutcome outcome) { if (!outcome.Success || outcome.Graph == null || _graph == null) return; var selectedId = _selected?.EventId; _graph = outcome.Graph; _selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected; } private void CancelPendingRefresh() { var cts = _refreshCts; if (cts is null) return; _refreshCts = null; _refreshing = false; try { cts.Cancel(); } catch (ObjectDisposedException) { } } /// 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; } /// /// 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. /// 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); } 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("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture)); if (string.IsNullOrEmpty(json)) return; var saved = JsonSerializer.Deserialize>(json); if (saved == null) return; foreach (var (k, v) in saved) if (_inputFields.Any(f => f.Name == k)) _inputs[k] = v; } catch (Exception) { } } }