diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor new file mode 100644 index 00000000..1c159372 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor @@ -0,0 +1,177 @@ +@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment +@using System.Globalization +@using System.Text.Json +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging +@inject NavigationManager Navigation + +@* Flow Debugger step detail (design 2026-09-05 §4): tabs Input · Output · + Attribute writes · Timing plus a "Where this ran" column with drill-ins. *@ + +
+
+ @if (Step is null) + { +
Select a step in the flow to see its input, output and timing.
+ } + else + { +
+
+
@Step.Kind.ToString().ToUpperInvariant() · @Step.Lane
+
@Step.Title
+ @if (!string.IsNullOrEmpty(Step.Subtitle)) + { +
@Step.Subtitle
+ } +
+ @Step.Outcome +
+ +
+
+ +
+ @switch (_tab) + { + case "Input": +
@Pretty(Step.InputJson)
+ break; + case "Output": + @if (!string.IsNullOrEmpty(Step.ErrorMessage)) + { +
@Step.ErrorMessage
+ } +
@Pretty(Step.OutputJson)
+ break; + case "Writes": + @if (Step.Extra?.Writes is { Count: > 0 } writes) + { + + + + @foreach (var w in writes) + { + + + + + + } + +
AttributeWrittenResult
@w.Attribute@(w.Written ?? "—")@(w.Success ? "ok" : (w.Error ?? "failed"))
+ } + else if (Step.Kind == FlowStepKind.AttributeWait && Step.Extra is { } wx) + { +
+
Attribute
@wx.WaitAttribute
+
Target
@wx.WaitTarget
+
Timeout
@(wx.WaitTimeoutMs is int ms ? ms + " ms" : "—")
+
Outcome
@wx.WaitOutcome
+
Observed
@(wx.ObservedValue ?? "—")
+
+ } + else + { +
No attribute writes on this step.
+ } + break; + case "Timing": +
+
Occurred (UTC)
@Step.OccurredAtUtc.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)
+
Duration
@(Step.DurationMs is int d ? d.ToString(CultureInfo.InvariantCulture) + " ms" : "—")
+
Execution
@(Step.ExecutionId?.ToString() ?? "—")
+
Parent
@(Step.ParentExecutionId?.ToString() ?? "—")
+
+ break; + } +
+
+ +
+
Where this ran
+
+
Site / node
@(Join(Step.SiteId, Step.Node) ?? "central")
+
Instance
@(Step.Instance ?? "—")
+
Target
@(Step.Target ?? "—")
+ @if (Step.Extra?.TriggerKind is { } tk) + { +
Trigger
@tk@(Step.Extra.TriggerAttribute is { } ta ? " · " + ta : "")
+ } +
+
+ @if (SiteDbId is int sid && InstanceDbId is int iid) + { + Open in Debug View + } + @if (Step.ExecutionId is { } exec) + { + Audit rows (@AuditRowCount) + } +
+
+
+ } +
+
+ +@code { + private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"]; + + /// The selected step; null renders the placeholder. + [Parameter] public FlowStep? Step { get; set; } + + /// The whole graph, used to count sibling audit rows of the step's execution. + [Parameter] public FlowGraph? Graph { get; set; } + + /// Site database id for the Debug View drill-in (known for value-change entries). + [Parameter] public int? SiteDbId { get; set; } + + /// Instance database id for the Debug View drill-in (known for value-change entries). + [Parameter] public int? InstanceDbId { get; set; } + + private string _tab = "Input"; + + private int AuditRowCount + => Step?.ExecutionId is { } exec && Graph != null + ? Graph.Steps.Count(s => s.ExecutionId == exec && s.Kind != FlowStepKind.Response) + : 0; + + private string OutcomeBadge => Step?.Outcome switch + { + FlowStepOutcome.Ok => "bg-success", + FlowStepOutcome.Failed => "bg-danger", + FlowStepOutcome.TimedOut => "bg-warning text-dark", + FlowStepOutcome.NotTraced => "bg-secondary", + _ => "bg-light text-dark", + }; + + private static string? Join(string? a, string? b) + => string.IsNullOrEmpty(a) ? b : string.IsNullOrEmpty(b) ? a : $"{a} / {b}"; + + /// Pretty-prints JSON when the text is JSON; otherwise returns it as-is (or an em-dash). + internal static string Pretty(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return "—"; + try + { + using var doc = JsonDocument.Parse(text); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch (JsonException) + { + return text; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor index 96b668e4..5f5ae47e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor @@ -47,6 +47,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor new file mode 100644 index 00000000..9159ecdb --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -0,0 +1,226 @@ +@page "/deployment/flow-debugger" +@using ZB.MOM.WW.ScadaBridge.Security +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] + +Flow Debugger + +
+

Flow Debugger

+ + @* ── Toolbar ─────────────────────────────────────────────────────────── *@ +
+
+ +
+ + +
+
+ + @if (_mode == FlowMode.Live) + { + @if (_entry == EntryKind.Method) + { +
+ + +
+ } + else + { +
+ + +
+
+ + +
+
+ + +
+ } + } + else + { +
+ + +
+ } + +
+ +
+ + +
+
+ +
+ @if (_mode == FlowMode.Live) + { + + } + else + { + + } +
+
+ + @* ── Inline alerts ───────────────────────────────────────────────────── *@ + @if (_error != null) + { + + } + + @* ── Status strip ─────────────────────────────────────────────────────── *@ +
+ @StateLabel + @if (_runNumber > 0) + { + Run#@_runNumber + } + @if (_raw != null) + { + Started@_raw.StartedAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff")Z + Total@((int)(_raw.FinishedAtUtc - _raw.StartedAtUtc).TotalMilliseconds) ms + } + @if (_graph != null) + { + Steps@_graph.Steps.Count + Boundaries@_graph.Edges.Count(e => e.CrossesBoundary) + Writes@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite) + } + @if (_raw != null) + { + + Root + @_raw.RootExecutionId + + } + @if (_graph?.Truncated == true) + { + event list truncated + } +
+ +
+ @* ── Inputs (Live only) ────────────────────────────────────────────── *@ + @if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0)) + { +
+
+
+
Inputs
+ @if (_entry == EntryKind.Method) + { + @foreach (var f in _inputFields) + { + var field = f; +
+ + +
+ } + } + else + { +
+ + +
+ } +
+
+
+ } + + @* ── Flow panel ────────────────────────────────────────────────────── *@ +
+
+
+ @if (_graph == null && _predicted != null) + { + Predicted from script syntax — dashed nodes depend on inputs or live site state. + } + else if (SiteNotTraced) + { + Site not traced — the site returned no trace rows for the routed call (older site build or an untraced call). + } +
+
+ + + +
+
+ +
+ + @* ── Detail panel ──────────────────────────────────────────────────── *@ +
+ +
+
+
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 new file mode 100644 index 00000000..8c8c5ed0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -0,0 +1,555 @@ +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; + +/// +/// 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 +/// Idle → Predicted → Running → Completed | Failed | TimedOut. The server's +/// STANDBY_NODE refusal renders as an inline alert with the message verbatim. +/// +public partial class FlowDebugger : ComponentBase +{ + /// Entry point kinds. + public enum EntryKind + { + /// An inbound API method. + Method, + /// A value-change trigger on an instance attribute. + ValueChange, + } + + /// Live vs Replay. + public enum FlowMode + { + /// Run it now. + Live, + /// Rebuild a past flow from the audit store. + Replay, + } + + /// Page state. + public enum FlowState + { + /// Nothing chosen. + Idle, + /// Predicted graph shown, not yet run. + Predicted, + /// A run or replay is in flight. + Running, + /// Run finished (or replay loaded). + Completed, + /// Run refused or failed. + Failed, + /// Run timed out / no trigger within the window. + 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!; + + /// Drill-in: pre-select an inbound method. + [SupplyParameterFromQuery] public int? MethodId { get; set; } + + /// Drill-in: pre-select an instance (value-change entry). + [SupplyParameterFromQuery] public int? InstanceId { get; set; } + + /// Drill-in: pre-select the trigger attribute (with ). + [SupplyParameterFromQuery] public string? Attribute { get; set; } + + /// Drill-in: replay this execution on first render. + [SupplyParameterFromQuery] public Guid? ExecutionId { get; set; } + + private EntryKind _entry = EntryKind.Method; + private FlowMode _mode = FlowMode.Live; + private FlowState _state = FlowState.Idle; + + private List _methods = []; + private List _sites = []; + private List _instances = []; + private List _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 _inputFields = []; + private readonly Dictionary _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; + + 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", + }; + + /// + /// 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). + /// + 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); + + /// + 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(); + } + } + + /// + 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("localStorage.getItem", StorageEntry); + if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal)) + { + _entry = EntryKind.ValueChange; + var site = await JS.InvokeAsync("localStorage.getItem", StorageSite); + var inst = await JS.InvokeAsync("localStorage.getItem", StorageInstance); + var attr = await JS.InvokeAsync("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("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 async Task RunAsync() + { + if (!CanRun) return; + _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; + } + } + + private async Task LoadReplayAsync() + { + if (!Guid.TryParse(_replayExecutionIdText, out var exec)) return; + _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() + { + _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; + } + + /// + /// 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. + /// + 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("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture)); + if (string.IsNullOrEmpty(json)) return; + var saved = JsonSerializer.Deserialize>(json); + if (saved == null) return; + foreach (var (k, v) in saved) + if (_inputFields.Any(f => f.Name == k)) + _inputs[k] = v; + } + catch (Exception) + { + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css new file mode 100644 index 00000000..d3a67aae --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css @@ -0,0 +1,71 @@ +/* Flow Debugger page — toolbar / status strip / flow / detail (design 2026-09-05 §4). */ + +.fd-status { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + align-items: center; + font-size: 0.85rem; + padding: 0.4rem 0.75rem; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--bs-body-bg, #fff); +} + +.fd-status .fd-label { + color: var(--ink-faint); + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-right: 0.3rem; +} + +.fd-status .fd-mono { font-family: var(--mono); } + +.fd-chip { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--rule-strong); + font-size: 0.75rem; + font-weight: 600; +} +.fd-chip.idle { color: var(--ink-soft); } +.fd-chip.predicted { color: var(--accent); border-color: var(--accent); border-style: dashed; } +.fd-chip.running { color: var(--accent); border-color: var(--accent); } +.fd-chip.completed { color: var(--ok); border-color: var(--ok); } +.fd-chip.failed { color: var(--bs-danger, #c0392b); border-color: var(--bs-danger, #c0392b); } +.fd-chip.timed-out { color: var(--warn); border-color: var(--warn); } + +.fd-flow-tools { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} + +.fd-inputs .form-control-sm { font-family: var(--mono); } + +::deep .fsd-eyebrow { + color: var(--ink-faint); + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +::deep .fsd-pane { + border: 1px solid var(--rule); + border-top: none; + padding: 0.6rem 0.75rem; + min-height: 6rem; +} + +::deep .fsd-json { + font-family: var(--mono); + font-size: 0.75rem; + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 18rem; + overflow: auto; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs new file mode 100644 index 00000000..fb278090 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -0,0 +1,212 @@ +using System.Security.Claims; +using System.Text.Json; +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.Audit; +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.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; +using FlowDebuggerPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.FlowDebugger; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// bUnit tests for the Flow Debugger page (design 2026-09-05 §4): render, prediction on +/// method choice, Run with typed inputs, the STANDBY_NODE alert, replay via +/// ?executionId=, and the "site not traced" label. +/// +public class FlowDebuggerPageTests : BunitContext +{ + private readonly IFlowDebugService _flow = Substitute.For(); + private readonly IInboundApiRepository _inboundApi = Substitute.For(); + private readonly ITemplateEngineRepository _templates = Substitute.For(); + private readonly ISiteRepository _sites = Substitute.For(); + + private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;") + { + Id = 4, + ParameterDefinitions = """{"type":"object","properties":{"MachineCode":{"type":"string"},"Qty":{"type":"integer"}},"required":["MachineCode"]}""", + }; + + private static PredictedFlow ThreeNodePrediction() => new( + [ + new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null), + new("n2", FlowStepKind.RouteCall, FlowLane.Central, "Route.To(code)", true, null), + new("n3", FlowStepKind.Response, FlowLane.Central, "Response", false, null), + ], + [new("n1", "n2", false), new("n2", "n3", false)]); + + private static FlowDebugOutcome GraphOutcome(bool withSite, bool withChildId) + { + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var t0 = DateTime.UtcNow; + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, + requestSummary: "{}", responseSummary: "{}"), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90, + extra: withChildId ? new ScriptTraceExtra { ChildExecutionId = child }.ToJson() : null), + }; + if (withSite) + events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80)); + + var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1)); + return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null); + } + + private void RegisterServices(string role = "Deployer") + { + JSInterop.Mode = JSRuntimeMode.Loose; + + _inboundApi.GetAllApiMethodsAsync(Arg.Any()) + .Returns(Task.FromResult>([Delmia])); + _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()).Returns(Delmia); + _sites.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>([new Site("Plant A", "site-a") { Id = 1 }])); + _flow.PredictMethodAsync(4, Arg.Any()).Returns(ThreeNodePrediction()); + + Services.AddSingleton(_flow); + Services.AddSingleton(_inboundApi); + Services.AddSingleton(_templates); + Services.AddSingleton(_sites); + + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role)], "TestCookie"); + var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(stubAuth); + Services.AddScoped(_ => new SiteScopeService(stubAuth)); + Services.AddAuthorizationCore(); + } + + private sealed class StubAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState _state; + public StubAuthStateProvider(AuthenticationState state) => _state = state; + public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); + } + + [Fact] + public void Renders_ForDeployer_WithToolbarAndEmptyFlow() + { + RegisterServices(); + + var cut = Render(); + + Assert.Contains("Flow Debugger", cut.Markup); + Assert.NotNull(cut.Find("[data-test='flow-toolbar']")); + Assert.NotNull(cut.Find("[data-test='flow-graph-empty']")); + Assert.Equal("Not run", cut.Find("[data-test='flow-state']").TextContent.Trim()); + Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } + + [Fact] + public void ChoosingAMethod_ShowsThePredictedGraph_AndTheInputsForm() + { + RegisterServices(); + var cut = Render(); + + cut.Find("select[data-test='flow-method-select']").Change("4"); + + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count)); + Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim()); + Assert.NotNull(cut.Find("[data-test='flow-predicted-note']")); + Assert.NotNull(cut.Find("input[data-test='flow-input-MachineCode']")); + Assert.NotNull(cut.Find("input[data-test='flow-input-Qty']")); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } + + [Fact] + public void Run_SendsTypedInputs_AndRendersTheReturnedGraph() + { + RegisterServices(); + JsonElement? sent = null; + _flow.RunMethodAsync(4, Arg.Do(p => sent = p), Arg.Any()) + .Returns(GraphOutcome(withSite: true, withChildId: true)); + var cut = Render(); + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + + cut.Find("input[data-test='flow-input-MachineCode']").Input("Z28061"); + cut.Find("input[data-test='flow-input-Qty']").Input("3"); + cut.Find("button[data-test='flow-run']").Click(); + + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + Assert.NotNull(sent); + Assert.Equal("Z28061", sent!.Value.GetProperty("MachineCode").GetString()); + Assert.Equal(JsonValueKind.Number, sent.Value.GetProperty("Qty").ValueKind); + Assert.Equal(3, sent.Value.GetProperty("Qty").GetInt32()); + // 3 events + synthesized response = 4 real steps; the prediction's unmatched nodes ghost. + Assert.Equal("4", cut.Find("[data-test='flow-step-count']").TextContent.Trim()); + Assert.True(cut.FindAll("g[data-test='flow-node']").Count >= 4); + Assert.NotNull(cut.Find("[data-test='flow-root-execution']")); + // The first step is auto-selected into the detail panel. + Assert.Contains("POST /api/DelmiaRecipeDownload", cut.Find("[data-test='flow-step-title']").TextContent); + } + + [Fact] + public void Run_StandbyNodeRefusal_ShowsInlineAlertVerbatim() + { + RegisterServices(); + _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()) + .Returns(FlowDebugOutcome.Fail("This node is standby; run the flow on the active central node.", "STANDBY_NODE")); + var cut = Render(); + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + + cut.Find("button[data-test='flow-run']").Click(); + + var alert = cut.WaitForElement("[data-test='flow-error']"); + Assert.Equal("STANDBY_NODE", alert.GetAttribute("data-code")); + Assert.Contains("This node is standby; run the flow on the active central node.", alert.TextContent); + Assert.Contains("alert-warning", alert.ClassName); + Assert.Equal("Failed", cut.Find("[data-test='flow-state']").TextContent.Trim()); + } + + [Fact] + public void ExecutionIdQueryParameter_TriggersReplayOnFirstRender() + { + RegisterServices(); + var exec = Guid.NewGuid(); + _flow.ReplayAsync(exec, Arg.Any()).Returns(GraphOutcome(withSite: true, withChildId: true)); + var nav = Services.GetRequiredService(); + nav.NavigateTo($"/deployment/flow-debugger?executionId={exec}"); + + var cut = Render(); + + cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(exec, Arg.Any())); + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + Assert.Equal(exec.ToString(), cut.Find("input[data-test='flow-execution-id']").GetAttribute("value")); + Assert.Contains("btn-primary", cut.Find("button[data-test='flow-mode-replay']").ClassName); + } + + [Fact] + public void Run_WithNoSiteStepsAndNoChildId_ShowsSiteNotTracedLabel() + { + RegisterServices(); + _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()) + .Returns(GraphOutcome(withSite: false, withChildId: false)); + var cut = Render(); + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + + cut.Find("button[data-test='flow-run']").Click(); + + cut.WaitForElement("[data-test='flow-site-not-traced']"); + } +}