0ffd4bdbea
Comment/XML-doc changes only, no code logic: - strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from comments, keeping the explanatory rationale - add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/> - replace misused <inheritdoc/> with real docs; where <inheritdoc/> was combined with useful remarks, move the rationale into // comments - drop stale replicationActor <param> on DeploymentManagerActor ctor Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365). Build 0 errors / 0 warnings.
1123 lines
45 KiB
C#
1123 lines
45 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
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!;
|
|
/// <summary>
|
|
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
|
|
/// escape hatch validates against the same resolved shape the form renders.
|
|
/// </summary>
|
|
[Inject] private ISchemaLibraryQueryService SchemaLibrary { 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 = [];
|
|
|
|
/// <summary>
|
|
/// The selected site's instances, feeding the <c>InstancePicker</c> alongside
|
|
/// <see cref="_areas"/>. 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 (<see cref="LoadInstancesAsync"/>) and never rebuilt on a render path.
|
|
/// </summary>
|
|
private List<Instance> _instances = [];
|
|
|
|
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
|
|
private List<Area> _areas = [];
|
|
|
|
/// <summary>
|
|
/// True while <see cref="LoadInstancesAsync"/> 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 <see cref="_selectedSiteId"/> 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).
|
|
/// </summary>
|
|
private bool _instancesLoading;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Method entry: the chosen method's resolved root schema (null when it declares no
|
|
/// parameters or the definitions do not parse) and its top-level fields — the card renders
|
|
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
|
|
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
|
|
/// body is that tree serialized as-is: no per-field text coercion on the page any more.
|
|
/// </summary>
|
|
private InboundApiSchema? _methodSchema;
|
|
private List<InboundApiSchemaField> _inputFields = [];
|
|
private Dictionary<string, object?> _methodParams = new();
|
|
private bool _methodParamsValid;
|
|
|
|
/// <summary>
|
|
/// Raw-JSON escape hatch for the whole method payload: paste the exact body an
|
|
/// external caller would send. The text is parsed and schema-validated on every edit;
|
|
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
|
|
/// of truth in both modes.
|
|
/// </summary>
|
|
private bool _methodRawMode;
|
|
private string _methodRawText = string.Empty;
|
|
private string? _methodRawError;
|
|
|
|
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>
|
|
/// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
|
|
/// carries any. <c>error</c> and <c>warn</c> are counted by name (ordinal, case-insensitive —
|
|
/// the level rides the audit row as free text); everything else, <c>debug</c> included, is
|
|
/// "info", so the three counts always add up to <c>Total</c>. Lines the builder could not place
|
|
/// on a step are NOT here — they are reported separately as
|
|
/// <see cref="FlowGraph.UnattachedLogLines"/>.
|
|
/// </summary>
|
|
/// <param name="graph">The flow graph to count log lines in, or null.</param>
|
|
/// <returns>The error, warning, info and total log-line counts.</returns>
|
|
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);
|
|
|
|
/// <summary>Status-strip text for <see cref="LogCounts"/>.</summary>
|
|
private string LogSummary
|
|
{
|
|
get
|
|
{
|
|
var counts = LogCounts;
|
|
return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
|
|
}
|
|
}
|
|
|
|
/// <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 && (_inputFields.Count == 0 || _methodParamsValid)
|
|
: 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;
|
|
ResetMethodInputs();
|
|
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();
|
|
ResetMethodInputs();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
|
|
var method = _methods.FirstOrDefault(m => m.Id == _selectedMethodId);
|
|
if (method == null)
|
|
return;
|
|
|
|
_methodSchema = await ResolveMethodSchemaAsync(method.ParameterDefinitions);
|
|
if (_methodSchema is { Type: "object" })
|
|
_inputFields = _methodSchema.Fields.ToList();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the selected site's instances and areas into the picker's two lists, holding
|
|
/// <see cref="_instancesLoading"/> 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
|
|
/// <c>finally</c>: a repository fault must not leave the picker disabled forever.
|
|
/// </summary>
|
|
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 <select>.
|
|
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).ToList();
|
|
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
|
}
|
|
finally
|
|
{
|
|
_instancesLoading = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The picker raises the id rather than binding a <select>, so the page applies it
|
|
/// before running the same change handler the old dropdown used.
|
|
/// </summary>
|
|
private async Task OnInstancePickedAsync(int id)
|
|
{
|
|
_selectedInstanceId = id;
|
|
await OnInstanceChangedAsync();
|
|
}
|
|
|
|
/// <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;
|
|
// D7: the inline copy's cap is only worth reporting while the page still holds that
|
|
// incomplete copy. A refresh MERGES rows, so the moment it brings back even one ScriptLog
|
|
// row the run did not already have, the page is no longer short of lines and the flag
|
|
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
|
|
// 5-30 s later, so a refresh that adds nothing proves nothing.
|
|
// Read the OUTCOME's own flag — FlowDebugService copies the raw result's
|
|
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
|
|
// member with no consumer.
|
|
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
|
|
&& CountLogRows(merged) <= CountLogRows(_raw.Events);
|
|
_raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated };
|
|
_graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated);
|
|
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
|
|
}
|
|
|
|
/// <summary>Number of <c>ScriptTrace.ScriptLog</c> rows in <paramref name="events"/> — the log lines a refresh can add to.</summary>
|
|
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
|
|
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
|
|
|
|
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
|
|
/// <param name="known">Events already held by the page, or null.</param>
|
|
/// <param name="fresh">Newly read Replay events, or null.</param>
|
|
/// <returns>The merged event list.</returns>
|
|
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
|
|
{
|
|
var byId = new Dictionary<Guid, AuditEvent>();
|
|
foreach (var e in fresh ?? []) byId[e.EventId] = e;
|
|
foreach (var e in known ?? []) byId.TryAdd(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).
|
|
/// An unresolvable site is denied for a scoped caller.
|
|
/// </summary>
|
|
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
|
|
{
|
|
if (step.Kind == FlowStepKind.Notify)
|
|
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 false;
|
|
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
|
}
|
|
|
|
/// <summary>Cancels any pending outcome refresh when the component is disposed.</summary>
|
|
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;
|
|
|
|
/// <summary>Drops every per-method input: the schema, the value tree, its validity and the raw editor.</summary>
|
|
private void ResetMethodInputs()
|
|
{
|
|
_methodSchema = null;
|
|
_inputFields = [];
|
|
_methodParams = new();
|
|
_methodParamsValid = false;
|
|
_methodRawMode = false;
|
|
_methodRawText = string.Empty;
|
|
_methodRawError = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a method's parameter definitions with <c>$ref:lib:</c> resolution — the same
|
|
/// resolution <c>ParameterValueForm</c> applies — so the page's raw-JSON validation and the
|
|
/// form agree on the shape. Null when the method declares nothing or the JSON is malformed.
|
|
/// </summary>
|
|
private async Task<InboundApiSchema?> ResolveMethodSchemaAsync(string? definitions)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(definitions))
|
|
return null;
|
|
|
|
Func<string, string?>? resolver = null;
|
|
if (InboundApiSchema.MightContainRef(definitions))
|
|
{
|
|
var map = await SchemaLibrary.GetSchemaMapAsync();
|
|
resolver = name => map.TryGetValue(name, out var json) ? json : null;
|
|
}
|
|
|
|
try
|
|
{
|
|
return InboundApiSchema.ParseWithRefs(definitions, resolver).Schema;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>JSON Schema text for the raw editor's completion/hover; permissive when there is no schema.</summary>
|
|
private string MethodRawJsonSchema => _methodSchema is null ? "{}" : ParameterValueForm.ToJsonSchema(_methodSchema);
|
|
|
|
private void OnMethodParamsChanged(Dictionary<string, object?> values)
|
|
{
|
|
_methodParams = values;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Form ⇄ Raw JSON. Entering raw mode seeds the editor from the current value tree so
|
|
/// the operator edits what the form would have sent; leaving it re-mounts the form over the
|
|
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
|
|
/// on, so it mounts fresh and re-reports validity from the tree).
|
|
/// </summary>
|
|
private void SetMethodRawMode(bool raw)
|
|
{
|
|
if (_methodRawMode == raw) return;
|
|
_methodRawMode = raw;
|
|
_methodRawError = null;
|
|
if (raw)
|
|
{
|
|
_methodRawText = _methodParams.Count == 0
|
|
? "{}"
|
|
: JsonSerializer.Serialize(_methodParams, RawJsonWriteOptions);
|
|
_methodParamsValid = ValidateMethodTree(JsonSerializer.SerializeToElement(_methodParams)).Count == 0;
|
|
}
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions RawJsonWriteOptions = new() { WriteIndented = true };
|
|
|
|
/// <summary>
|
|
/// Raw editor edit: parse, require a JSON object, validate against the resolved schema.
|
|
/// A document that fails at any stage leaves the previous tree untouched and disables Run —
|
|
/// a failed parse is never sent, as a string or otherwise.
|
|
/// </summary>
|
|
private void SetMethodRawJson(string? text)
|
|
{
|
|
_methodRawText = text ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(_methodRawText))
|
|
{
|
|
_methodRawError = "Enter the request body as a JSON object.";
|
|
_methodParamsValid = false;
|
|
return;
|
|
}
|
|
|
|
JsonElement root;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(_methodRawText);
|
|
root = doc.RootElement.Clone();
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_methodRawError = "JSON parse error: " + ex.Message;
|
|
_methodParamsValid = false;
|
|
return;
|
|
}
|
|
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
{
|
|
_methodRawError = "The request body must be a JSON object.";
|
|
_methodParamsValid = false;
|
|
return;
|
|
}
|
|
|
|
var errors = ValidateMethodTree(root);
|
|
if (errors.Count > 0)
|
|
{
|
|
_methodRawError = string.Join(" ", errors);
|
|
_methodParamsValid = false;
|
|
return;
|
|
}
|
|
|
|
_methodRawError = null;
|
|
_methodParams = ParameterValueForm.JsonElementToObject(root) as Dictionary<string, object?> ?? new();
|
|
_methodParamsValid = true;
|
|
}
|
|
|
|
private List<string> ValidateMethodTree(JsonElement tree)
|
|
{
|
|
var errors = new List<string>();
|
|
_methodSchema?.Validate(tree, string.Empty, errors);
|
|
return errors;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The request body for a method-entry run: the value tree as-is (scalars as
|
|
/// bool/long/double/string, objects as dictionaries, lists as lists — exactly what
|
|
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
|
|
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
|
|
/// </summary>
|
|
/// <returns>The serialized parameters, or null when the method declares none.</returns>
|
|
internal JsonElement? BuildParameters()
|
|
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
|
|
|
|
/// <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>
|
|
/// <returns>The serialized parameters, or null when the form produced nothing.</returns>
|
|
internal JsonElement? BuildScriptParameters()
|
|
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
|
|
|
|
private async Task PersistAsync(string key, string value)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("localStorage.setItem", key, value);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Best-effort convenience only.
|
|
}
|
|
}
|
|
|
|
/// <summary>Persists the method's value tree (not per-field strings) as a per-method convenience.</summary>
|
|
private async Task PersistInputsAsync(int methodId)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
|
JsonSerializer.Serialize(_methodParams));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores a persisted value tree only when it still validates against the method's current
|
|
/// schema — a stale tree (the schema changed, or the older flat string-per-field entry) is
|
|
/// dropped rather than painted into the form as errors.
|
|
/// </summary>
|
|
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;
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
|
|
var root = doc.RootElement.Clone();
|
|
if (ValidateMethodTree(root).Count > 0) return;
|
|
if (ParameterValueForm.JsonElementToObject(root) is Dictionary<string, object?> tree)
|
|
_methodParams = tree;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
}
|