feat(central-ui): Flow Debugger script entry — site/instance/script picker, value field or ParameterValueForm by trigger kind
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
+38
-18
@@ -24,8 +24,8 @@
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -70,14 +70,14 @@
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Attribute</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-attribute-select"
|
||||
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm">Script</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-script-select"
|
||||
@bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
|
||||
<option value="">— choose —</option>
|
||||
@foreach (var a in _attributes)
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<option value="@a">@a</option>
|
||||
<option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@@ -193,7 +193,7 @@
|
||||
|
||||
<div class="row g-3">
|
||||
@* ── Inputs (Live only) ────────────────────────────────────────────── *@
|
||||
@if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0))
|
||||
@if (_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0))
|
||||
{
|
||||
<div class="col-lg-3">
|
||||
<div class="card fd-inputs" data-test="flow-inputs">
|
||||
@@ -214,13 +214,33 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (SelectedScript is { } script)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label>
|
||||
<input class="form-control form-control-sm" data-test="flow-input-value"
|
||||
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
|
||||
</div>
|
||||
@* A write kind is fired by writing its own trigger attribute — the operator
|
||||
supplies only the value. Every other kind is called directly with the
|
||||
parameters its definitions declare. *@
|
||||
@if (script.IsWriteKind)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
|
||||
<input class="form-control form-control-sm" data-test="flow-input-value"
|
||||
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
|
||||
<div class="form-text">@script.TriggerType trigger — writing the attribute fires the script.</div>
|
||||
</div>
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
|
||||
{
|
||||
<div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div data-test="flow-script-params">
|
||||
<ParameterValueForm ParameterDefinitions="@script.ParameterDefinitions"
|
||||
Values="_scriptParams"
|
||||
ValuesChanged="@(v => _scriptParams = v)"
|
||||
ValidChanged="@(ok => _scriptParamsValid = ok)" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +248,7 @@
|
||||
}
|
||||
|
||||
@* ── Flow panel ────────────────────────────────────────────────────── *@
|
||||
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
|
||||
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
|
||||
<div class="fd-flow-tools">
|
||||
<div class="small text-muted">
|
||||
@if (_graph == null && _predicted != null)
|
||||
@@ -254,8 +274,8 @@
|
||||
@* Retry / Discard relay a command to the site — only wired for a Deployer, so the
|
||||
buttons never render for an auditor (arch-review F3). *@
|
||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
|
||||
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
|
||||
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
|
||||
ActionInProgress="_actionInProgress" />
|
||||
</div>
|
||||
|
||||
+76
-40
@@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security;
|
||||
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
|
||||
/// 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>
|
||||
@@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
{
|
||||
/// <summary>An inbound API method.</summary>
|
||||
Method,
|
||||
/// <summary>A value-change trigger on an instance attribute.</summary>
|
||||
ValueChange,
|
||||
/// <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>
|
||||
@@ -68,7 +71,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
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 StorageScript = "flowDebugger.script";
|
||||
private const string StorageInputsPrefix = "flowDebugger.inputs.";
|
||||
|
||||
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
|
||||
@@ -83,11 +86,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// <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>
|
||||
/// <summary>Drill-in: pre-select an instance (script 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: 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; }
|
||||
@@ -109,15 +112,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private List<ApiMethod> _methods = [];
|
||||
private List<Site> _sites = [];
|
||||
private List<Instance> _instances = [];
|
||||
private List<string> _attributes = [];
|
||||
private List<FlowScriptOption> _scripts = [];
|
||||
|
||||
private int _selectedMethodId;
|
||||
private int _selectedSiteId;
|
||||
private int _selectedInstanceId;
|
||||
private string _selectedAttribute = string.Empty;
|
||||
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);
|
||||
|
||||
@@ -159,9 +170,13 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
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
|
||||
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
|
||||
: SelectedScript is { } s && (s.IsWriteKind
|
||||
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
||||
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
||||
|
||||
private string StateLabel => _state switch
|
||||
{
|
||||
@@ -222,17 +237,19 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
if (InstanceId is int iid && iid > 0)
|
||||
{
|
||||
_entry = EntryKind.ValueChange;
|
||||
_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 LoadAttributesAsync();
|
||||
if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute))
|
||||
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)
|
||||
{
|
||||
_selectedAttribute = Attribute;
|
||||
_selectedScript = named.CanonicalName;
|
||||
await PredictAsync();
|
||||
}
|
||||
}
|
||||
@@ -266,12 +283,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
try
|
||||
{
|
||||
var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry);
|
||||
if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal))
|
||||
if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal))
|
||||
{
|
||||
_entry = EntryKind.ValueChange;
|
||||
_entry = EntryKind.Script;
|
||||
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);
|
||||
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;
|
||||
@@ -279,10 +296,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
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))
|
||||
await LoadScriptsAsync();
|
||||
if (_scripts.Any(s => string.Equals(s.CanonicalName, script, StringComparison.Ordinal)))
|
||||
{
|
||||
_selectedAttribute = attr;
|
||||
_selectedScript = script!;
|
||||
await PredictAsync();
|
||||
}
|
||||
}
|
||||
@@ -316,7 +333,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
await PersistAsync(StorageEntry, entry.ToString());
|
||||
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
||||
await OnMethodChangedAsync();
|
||||
else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty)
|
||||
else if (_entry == EntryKind.Script && SelectedScript != null)
|
||||
await PredictAsync();
|
||||
}
|
||||
|
||||
@@ -355,8 +372,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
_selectedInstanceId = 0;
|
||||
_selectedAttribute = string.Empty;
|
||||
_attributes = [];
|
||||
_selectedScript = string.Empty;
|
||||
_scripts = [];
|
||||
await LoadInstancesAsync();
|
||||
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
@@ -366,17 +383,25 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
ResetResult();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
_selectedAttribute = string.Empty;
|
||||
await LoadAttributesAsync();
|
||||
_selectedScript = string.Empty;
|
||||
await LoadScriptsAsync();
|
||||
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private async Task OnAttributeChangedAsync()
|
||||
private async Task OnScriptChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString());
|
||||
await PersistAsync(StorageAttribute, _selectedAttribute);
|
||||
await PredictAsync();
|
||||
_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()
|
||||
@@ -386,14 +411,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task LoadAttributesAsync()
|
||||
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
|
||||
private async Task LoadScriptsAsync()
|
||||
{
|
||||
_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();
|
||||
_scripts = _selectedInstanceId > 0
|
||||
? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task PredictAsync()
|
||||
@@ -402,7 +425,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
{
|
||||
_predicted = _entry == EntryKind.Method
|
||||
? await FlowDebug.PredictMethodAsync(_selectedMethodId)
|
||||
: await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute);
|
||||
: await FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript);
|
||||
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -445,7 +468,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue);
|
||||
// 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);
|
||||
@@ -801,6 +829,14 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user