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 = []; private List _instances = []; 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; /// 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(); } private async Task LoadInstancesAsync() { _instances = _selectedSiteId > 0 ? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList() : []; } /// The instance's scripts, from its flattened configuration (canonical names, effective triggers). 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(); } /// Begins polling when a completed Live run left outbound work in flight. Never in Replay mode. private void StartPendingRefresh() { if (_mode != FlowMode.Live || PendingCount == 0) return; StartRefreshLoop(); } /// /// Starts (or restarts) the bounded refresh loop while holds — after a /// Live run with Pending nodes, and after a Retry/Discard relay in either mode. /// 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) { } } } } /// 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; } } /// /// 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 _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 || _raw == null) return; var selectedId = _selected?.EventId; var merged = MergeEvents(_raw.Events, outcome.Raw?.Events); var truncated = _raw.Truncated || outcome.Raw?.Truncated == true; _raw = _raw with { Events = merged, Truncated = truncated }; _graph = FlowGraphBuilder.Build(merged, truncated); _selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected; } /// Union of two audit-row lists by ; a row present in both takes the fresh copy. internal static IReadOnlyList MergeEvents(IReadOnlyList? known, IReadOnlyList? fresh) { var byId = new Dictionary(); foreach (var e in known ?? []) byId[e.EventId] = e; foreach (var e in fresh ?? []) byId[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); /// /// The parked-node Retry callback, left unset (default) without the Deployer role so /// FlowStepDetail's OnRetry.HasDelegate check hides the button (arch-review F3). /// private EventCallback RetryCallback => _canLive ? EventCallback.Factory.Create(this, OnRetryStepAsync) : default; /// The parked-node Discard callback; unset without Deployer, as . private EventCallback DiscardCallback => _canLive ? EventCallback.Factory.Create(this, OnDiscardStepAsync) : default; /// /// 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). /// 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); } } /// /// 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). /// A site the page cannot resolve is allowed through, as on the Site Calls page. /// private async Task IsStepSiteAllowedAsync(FlowStep step) { if (step.Kind == FlowStepKind.Notify || string.IsNullOrEmpty(step.SiteId)) 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 true; return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id); } /// 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); } /// /// The call-kind script's parameters as a flat JSON object, or null when the form produced /// nothing. ParameterValueForm already emits the coerced value tree (scalars as /// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is. /// 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("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) { } } }