@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). *@
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
index 7fa894fa..ccacf1a1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
@@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
///
-/// 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
/// Idle → Predicted → Running → Completed | Failed | TimedOut. The server's
/// STANDBY_NODE refusal renders as an inline alert with the message verbatim.
///
@@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{
///
An inbound API method.
Method,
- ///
A value-change trigger on an instance attribute.
- ValueChange,
+ ///
+ /// A script on a site instance: a write-kind trigger (ValueChange / Conditional) is fired
+ /// by writing its monitored attribute, every other kind is called directly.
+ ///
+ Script,
}
///
Live vs Replay.
@@ -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
///
Drill-in: pre-select an inbound method.
[SupplyParameterFromQuery] public int? MethodId { get; set; }
- ///
Drill-in: pre-select an instance (value-change entry).
+ ///
Drill-in: pre-select an instance (script entry).
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
- ///
Drill-in: pre-select the trigger attribute (with ).
- [SupplyParameterFromQuery] public string? Attribute { get; set; }
+ ///
Drill-in: pre-select the script (with ).
+ [SupplyParameterFromQuery] public string? Script { get; set; }
///
Drill-in: replay this execution on first render.
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
@@ -109,15 +112,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private List
_methods = [];
private List _sites = [];
private List _instances = [];
- private List _attributes = [];
+ private List _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;
+ /// The chosen script's option row, or null when nothing (or something stale) is selected.
+ private FlowScriptOption? SelectedScript
+ => _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
+
+ /// Call-kind parameter values from ParameterValueForm, and whether it currently parses.
+ private Dictionary _scriptParams = new();
+ private bool _scriptParamsValid;
+
private List _inputFields = [];
private readonly Dictionary _inputs = new(StringComparer.Ordinal);
@@ -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("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("localStorage.getItem", StorageSite);
var inst = await JS.InvokeAsync("localStorage.getItem", StorageInstance);
- var attr = await JS.InvokeAsync("localStorage.getItem", StorageAttribute);
+ var script = await JS.InvokeAsync("localStorage.getItem", StorageScript);
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
{
_selectedSiteId = sid;
@@ -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()
+ /// The instance's scripts, from its flattened configuration (canonical names, effective triggers).
+ 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);
}
+ ///
+ /// The call-kind script's parameters as a flat JSON object, or null when the form produced
+ /// nothing. ParameterValueForm already emits the coerced value tree (scalars as
+ /// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
+ ///
+ internal JsonElement? BuildScriptParameters()
+ => _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
+
private static JsonNode? Coerce(string? type, string raw)
{
switch (InboundApiSchema.NormalizeType(type))
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
index 55c974b7..56569c73 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
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;
@@ -35,6 +36,7 @@ public class FlowDebuggerPageTests : BunitContext
private readonly ITemplateEngineRepository _templates = Substitute.For();
private readonly ISiteRepository _sites = Substitute.For();
private readonly IDialogService _dialog = Substitute.For();
+ private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
@@ -91,6 +93,10 @@ public class FlowDebuggerPageTests : BunitContext
_dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
_dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
Services.AddSingleton(_dialog);
+ // The script entry's Inputs card renders the shared ParameterValueForm, which injects
+ // the schema library to resolve {"$ref":"lib:Name"} nodes. Empty map = no refs.
+ _schemaLibrary.GetSchemaMapAsync(Arg.Any()).Returns(new Dictionary());
+ Services.AddSingleton(_schemaLibrary);
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
@@ -145,7 +151,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
- Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']"));
+ Assert.Empty(cut.FindAll("button[data-test='flow-entry-script']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
@@ -589,4 +595,100 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any()));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
+
+ // ---- Script entry (design 2026-09-08 §5): site → instance → script ----------------------
+
+ private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null);
+ private static readonly FlowScriptOption Manual = new("Manual", "Call", null,
+ """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
+ private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
+
+ private void WireInstance()
+ {
+ _templates.GetInstancesBySiteIdAsync(1, Arg.Any())
+ .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
+ _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
+ _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick]));
+ _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction());
+ }
+
+ [Fact]
+ public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
+ {
+ RegisterServices();
+ WireInstance();
+ var cut = Render();
+ cut.Find("button[data-test='flow-entry-script']").Click();
+ cut.Find("select[data-test='flow-site-select']").Change("1");
+ cut.Find("select[data-test='flow-instance-select']").Change("7");
+ var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
+ Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
+ Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
+ }
+
+ [Fact]
+ public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
+ {
+ RegisterServices();
+ WireInstance();
+ var cut = Render();
+ cut.Find("button[data-test='flow-entry-script']").Click();
+ cut.Find("select[data-test='flow-site-select']").Change("1");
+ cut.Find("select[data-test='flow-instance-select']").Change("7");
+ cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
+
+ Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
+ Assert.Empty(cut.FindAll("[data-test='flow-script-params']"));
+ Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
+ cut.Find("input[data-test='flow-input-value']").Input("true");
+ Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
+ }
+
+ [Fact]
+ public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
+ {
+ RegisterServices();
+ WireInstance();
+ _flow.RunScriptAsync(7, "Manual", Arg.Any(), null, Arg.Any()).Returns(GraphOutcome(true, true));
+ var cut = Render();
+ cut.Find("button[data-test='flow-entry-script']").Click();
+ cut.Find("select[data-test='flow-site-select']").Change("1");
+ cut.Find("select[data-test='flow-instance-select']").Change("7");
+ cut.Find("select[data-test='flow-script-select']").Change("Manual");
+
+ Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
+ Assert.Empty(cut.FindAll("input[data-test='flow-input-value']"));
+ cut.Find("#param-n").Input("3");
+ cut.Find("button[data-test='flow-run']").Click();
+ cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
+ _ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any());
+ }
+
+ [Fact]
+ public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
+ {
+ RegisterServices();
+ WireInstance();
+ var cut = Render();
+ cut.Find("button[data-test='flow-entry-script']").Click();
+ cut.Find("select[data-test='flow-site-select']").Change("1");
+ cut.Find("select[data-test='flow-instance-select']").Change("7");
+ cut.Find("select[data-test='flow-script-select']").Change("Tick");
+ Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
+ Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
+ }
+
+ [Fact]
+ public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
+ {
+ RegisterServices();
+ WireInstance();
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
+ _ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any());
+ }
}