Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
T

912 lines
35 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 = [];
private List<Instance> _instances = [];
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>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();
}
private async Task LoadInstancesAsync()
{
_instances = _selectedSiteId > 0
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()
: [];
}
/// <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;
_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;
}
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes 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 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);
/// <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).
/// A site the page cannot resolve is allowed through, as on the Site Calls page.
/// </summary>
private async Task<bool> 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);
}
/// <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)
{
}
}
}