c3b4068de2
After a successful Live run whose graph has Pending outbound steps the page re-reads the tree through the existing Replay command every 5 s for up to 2 min (page constants), merging by replacing the graph and keeping the selection by event id; never in Replay mode; cancelled by a new run, a mode switch or Dispose. Status strip gains a Pending count, a 'Refresh outcomes' button (both modes) and an 'outcomes still arriving' note while polling. The run's own timing and the Completed chip are left alone — pending outbound work is not a failed run. Plan 2026-09-06 D5. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
677 lines
24 KiB
C#
677 lines
24 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.JSInterop;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
|
|
|
/// <summary>
|
|
/// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a
|
|
/// value-change trigger), see the predicted swimlane, run it live with tracing on the active
|
|
/// central node, or replay a past execution from the audit store. State machine
|
|
/// <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 value-change trigger on an instance attribute.</summary>
|
|
ValueChange,
|
|
}
|
|
|
|
/// <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 StorageAttribute = "flowDebugger.attribute";
|
|
private const string StorageInputsPrefix = "flowDebugger.inputs.";
|
|
|
|
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
|
|
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
|
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
|
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
|
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
|
|
|
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
|
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
|
|
|
/// <summary>Drill-in: pre-select an instance (value-change entry).</summary>
|
|
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
|
|
|
|
/// <summary>Drill-in: pre-select the trigger attribute (with <see cref="InstanceId"/>).</summary>
|
|
[SupplyParameterFromQuery] public string? Attribute { 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;
|
|
|
|
private List<ApiMethod> _methods = [];
|
|
private List<Site> _sites = [];
|
|
private List<Instance> _instances = [];
|
|
private List<string> _attributes = [];
|
|
|
|
private int _selectedMethodId;
|
|
private int _selectedSiteId;
|
|
private int _selectedInstanceId;
|
|
private string _selectedAttribute = string.Empty;
|
|
private string _valueChangeValue = string.Empty;
|
|
private string _replayExecutionIdText = string.Empty;
|
|
|
|
private List<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;
|
|
|
|
/// <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;
|
|
|
|
private bool CanRefresh => !_running && _raw is not null;
|
|
|
|
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
|
_entry == EntryKind.Method ? _selectedMethodId > 0
|
|
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
|
|
|
|
private string StateLabel => _state switch
|
|
{
|
|
FlowState.Idle => "Not run",
|
|
FlowState.Predicted => "Predicted",
|
|
FlowState.Running => "Running",
|
|
FlowState.Completed => "Completed",
|
|
FlowState.Failed => "Failed",
|
|
FlowState.TimedOut => "Timed out",
|
|
_ => _state.ToString(),
|
|
};
|
|
|
|
private string StateCss => _state switch
|
|
{
|
|
FlowState.Idle => "idle",
|
|
FlowState.Predicted => "predicted",
|
|
FlowState.Running => "running",
|
|
FlowState.Completed => "completed",
|
|
FlowState.Failed => "failed",
|
|
FlowState.TimedOut => "timed-out",
|
|
_ => "idle",
|
|
};
|
|
|
|
/// <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()
|
|
{
|
|
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
|
|
|
if (InstanceId is int iid && iid > 0)
|
|
{
|
|
_entry = EntryKind.ValueChange;
|
|
var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid);
|
|
if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId))
|
|
{
|
|
_selectedSiteId = inst.SiteId;
|
|
await LoadInstancesAsync();
|
|
_selectedInstanceId = inst.Id;
|
|
await LoadAttributesAsync();
|
|
if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute))
|
|
{
|
|
_selectedAttribute = Attribute;
|
|
await PredictAsync();
|
|
}
|
|
}
|
|
}
|
|
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
|
|
{
|
|
_entry = EntryKind.Method;
|
|
_selectedMethodId = mid;
|
|
await OnMethodChangedAsync();
|
|
}
|
|
|
|
if (ExecutionId is Guid exec && exec != Guid.Empty)
|
|
{
|
|
_mode = FlowMode.Replay;
|
|
_replayExecutionIdText = exec.ToString();
|
|
await LoadReplayAsync();
|
|
}
|
|
}
|
|
|
|
/// <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.ValueChange), StringComparison.Ordinal))
|
|
{
|
|
_entry = EntryKind.ValueChange;
|
|
var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite);
|
|
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
|
|
var attr = await JS.InvokeAsync<string?>("localStorage.getItem", StorageAttribute);
|
|
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
|
|
{
|
|
_selectedSiteId = sid;
|
|
await LoadInstancesAsync();
|
|
if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid))
|
|
{
|
|
_selectedInstanceId = iid;
|
|
await LoadAttributesAsync();
|
|
if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr))
|
|
{
|
|
_selectedAttribute = attr;
|
|
await PredictAsync();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var method = await JS.InvokeAsync<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;
|
|
await PersistAsync(StorageEntry, entry.ToString());
|
|
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
|
await OnMethodChangedAsync();
|
|
else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty)
|
|
await PredictAsync();
|
|
}
|
|
|
|
private async Task OnMethodChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_inputFields = [];
|
|
_inputs.Clear();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
|
|
var method = _methods.FirstOrDefault(m => m.Id == _selectedMethodId);
|
|
if (method == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var schema = InboundApiSchema.Parse(method.ParameterDefinitions);
|
|
if (schema is { Type: "object" })
|
|
_inputFields = schema.Fields.ToList();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
_inputFields = [];
|
|
}
|
|
|
|
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
|
await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture));
|
|
await RestoreInputsAsync(method.Id);
|
|
await PredictAsync();
|
|
}
|
|
|
|
private async Task OnSiteChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
_selectedInstanceId = 0;
|
|
_selectedAttribute = string.Empty;
|
|
_attributes = [];
|
|
await LoadInstancesAsync();
|
|
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private async Task OnInstanceChangedAsync()
|
|
{
|
|
ResetResult();
|
|
_predicted = null;
|
|
_state = FlowState.Idle;
|
|
_selectedAttribute = string.Empty;
|
|
await LoadAttributesAsync();
|
|
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private async Task OnAttributeChangedAsync()
|
|
{
|
|
ResetResult();
|
|
await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString());
|
|
await PersistAsync(StorageAttribute, _selectedAttribute);
|
|
await PredictAsync();
|
|
}
|
|
|
|
private async Task LoadInstancesAsync()
|
|
{
|
|
_instances = _selectedSiteId > 0
|
|
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()
|
|
: [];
|
|
}
|
|
|
|
private async Task LoadAttributesAsync()
|
|
{
|
|
_attributes = [];
|
|
var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId);
|
|
if (inst == null)
|
|
return;
|
|
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
|
_attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
|
}
|
|
|
|
private async Task PredictAsync()
|
|
{
|
|
try
|
|
{
|
|
_predicted = _entry == EntryKind.Method
|
|
? await FlowDebug.PredictMethodAsync(_selectedMethodId)
|
|
: await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute);
|
|
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_predicted = null;
|
|
_error = "Prediction failed: " + ex.Message;
|
|
_errorCode = "PREDICTION_FAILED";
|
|
}
|
|
}
|
|
|
|
private void SetMode(FlowMode mode)
|
|
{
|
|
if (_mode == mode) return;
|
|
CancelPendingRefresh();
|
|
_mode = mode;
|
|
}
|
|
|
|
private async Task RunAsync()
|
|
{
|
|
if (!CanRun) return;
|
|
CancelPendingRefresh();
|
|
_running = true;
|
|
_state = FlowState.Running;
|
|
_error = null;
|
|
_errorCode = null;
|
|
_graph = null;
|
|
_raw = null;
|
|
_selected = null;
|
|
_runNumber++;
|
|
|
|
try
|
|
{
|
|
FlowDebugOutcome outcome;
|
|
if (_entry == EntryKind.Method)
|
|
{
|
|
await PersistInputsAsync(_selectedMethodId);
|
|
outcome = await FlowDebug.RunMethodAsync(_selectedMethodId, BuildParameters());
|
|
}
|
|
else
|
|
{
|
|
outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue);
|
|
}
|
|
|
|
ApplyOutcome(outcome);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = ex.Message;
|
|
_errorCode = "RUN_FAILED";
|
|
_state = FlowState.Failed;
|
|
}
|
|
finally
|
|
{
|
|
_running = false;
|
|
}
|
|
|
|
StartPendingRefresh();
|
|
}
|
|
|
|
/// <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 || _raw is null || _graph is null || PendingCount == 0)
|
|
return;
|
|
var cts = new CancellationTokenSource();
|
|
_refreshCts = cts;
|
|
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
|
}
|
|
|
|
private async Task RefreshPendingLoopAsync(Guid root, CancellationTokenSource cts)
|
|
{
|
|
var ct = cts.Token;
|
|
_refreshing = true;
|
|
var deadline = DateTime.UtcNow + PendingRefreshWindow;
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
|
{
|
|
await Delay(PendingRefreshInterval, ct);
|
|
var outcome = await FlowDebug.ReplayAsync(root, ct);
|
|
if (ct.IsCancellationRequested) return;
|
|
MergeRefreshedGraph(outcome);
|
|
await InvokeAsync(StateHasChanged);
|
|
if (PendingCount == 0) return;
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// New run, mode switch or dispose — the loop is simply abandoned.
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Best-effort: the manual Refresh button stays available.
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(_refreshCts, cts))
|
|
{
|
|
_refreshing = false;
|
|
_refreshCts = null;
|
|
cts.Dispose();
|
|
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// Replaces the graph with the re-read tree and keeps the selection by event id. 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)
|
|
return;
|
|
var selectedId = _selected?.EventId;
|
|
_graph = outcome.Graph;
|
|
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
|
|
}
|
|
|
|
private void CancelPendingRefresh()
|
|
{
|
|
var cts = _refreshCts;
|
|
if (cts is null) return;
|
|
_refreshCts = null;
|
|
_refreshing = false;
|
|
try { cts.Cancel(); } catch (ObjectDisposedException) { }
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
}
|