From 0ce6633270a10ec9e260bfccf1a996a43518501c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:31:11 -0500 Subject: [PATCH] feat(flow-debugger): vertical trace list view alongside the swimlane (ScadaBridge#53) Direction B: the same FlowGraph steps as a filterable list (lane column, lane filter, failures-only), toggled from the flow panel toolbar. The mockup canvas is not in the repo, so this is the minimal faithful version. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- .../Pages/Deployment/FlowDebugger.razor | 18 +++- .../Pages/Deployment/FlowDebugger.razor.cs | 2 + .../Components/Shared/FlowTraceList.razor | 73 ++++++++++++++ .../Components/Shared/FlowTraceList.razor.cs | 72 ++++++++++++++ .../Components/Shared/FlowTraceList.razor.css | 15 +++ .../Shared/FlowTraceListTests.cs | 96 +++++++++++++++++++ 6 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.css create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowTraceListTests.cs 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 index 5ea028d8..93f98cf4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -310,13 +310,27 @@ Site not traced — the site returned no trace rows for the routed call (older site build or an untraced call). } -
+
+ + +
+
- + @if (_listView) + { + @* Direction B: the same steps as a vertical list; a prediction-only graph has no real steps, so it shows in the swimlane only. *@ + + } + else + { + + } @* ── 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 index 14f33503..46e2fb4d 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 @@ -183,6 +183,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable private string? _errorCode; private int _runNumber; private double _zoom = 1.0; + /// True when the vertical trace list (Direction B, Gitea #53) replaces the swimlane. + private bool _listView; private bool _running; private bool _storageRestored; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor new file mode 100644 index 00000000..8abeffed --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor @@ -0,0 +1,73 @@ +@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging + +@* Flow Debugger vertical trace list (mockup Direction B, Gitea #53): the same FlowGraph steps as the + swimlane, one per row in display order, with the lane as a column. Meant for long flows and screen + readers. Pure view over FlowGraph — no layout maths. *@ + +
+
+ + +
+ + +
+ @Rows().Count of @(Graph?.Steps.Count ?? 0) steps +
+ + @if (Rows().Count == 0) + { +
No steps
+ } + else + { + + + + + + + + + + + + + + + @foreach (var (step, index) in Rows()) + { + var s = step; + var logs = FlowGraphView.LogCounts(s); + + + + + + + + + + + } + +
#+TimeLaneKindStepOutcomeDurationLogs
@(index + 1)@Offset(s)@s.Lane@KindLabel(s.Kind)@s.Title@FlowGraphView.PillText(s.Outcome)@(s.DurationMs is int ms ? ms + " ms" : "") + @if (logs.Errors + logs.Warnings + logs.Info > 0) + { + @(logs.Errors + logs.Warnings + logs.Info) + } +
+ } +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.cs new file mode 100644 index 00000000..43d9f45e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// Vertical trace list over a (Flow Debugger mockup Direction B, Gitea #53): the +/// steps in display order with the lane as a column, filterable by lane and to failures only. +/// +public partial class FlowTraceList : ComponentBase +{ + /// The real graph (from a run or a replay); null before a run. + [Parameter] public FlowGraph? Graph { get; set; } + + /// The selected step's event id. + [Parameter] public Guid? SelectedEventId { get; set; } + + /// Raised when a row is clicked or activated with Enter/Space. + [Parameter] public EventCallback OnSelect { get; set; } + + private string _laneFilter = ""; + private bool _failedOnly; + + private IReadOnlyList<(FlowStep Step, int Index)> Rows() + { + if (Graph == null) return Array.Empty<(FlowStep, int)>(); + // The ordinal is the step's position in the whole flow, so it stays stable while filtering. + IEnumerable<(FlowStep, int)> q = Graph.Steps.Select((s, i) => (s, i)); + if (Enum.TryParse(_laneFilter, out var lane)) q = q.Where(t => t.Item1.Lane == lane); + if (_failedOnly) q = q.Where(t => IsFailure(t.Item1)); + return q.ToList(); + } + + internal static bool IsFailure(FlowStep s) => s.Outcome is FlowStepOutcome.Failed or FlowStepOutcome.TimedOut; + + private string Offset(FlowStep s) + { + var first = Graph!.Steps[0].OccurredAtUtc; + var ms = (s.OccurredAtUtc - first).TotalMilliseconds; + return "+" + ms.ToString("0", CultureInfo.InvariantCulture) + " ms"; + } + + private async Task OnKeyAsync(KeyboardEventArgs e, FlowStep s) + { + if (e.Key is "Enter" or " ") await OnSelect.InvokeAsync(s); + } + + private static string PillClass(FlowStepOutcome outcome) => outcome switch + { + FlowStepOutcome.Ok => "ft-pill ok", + FlowStepOutcome.Pending => "ft-pill pending", + FlowStepOutcome.Parked => "ft-pill parked", + FlowStepOutcome.Failed or FlowStepOutcome.TimedOut => "ft-pill failed", + _ => "ft-pill", + }; + + internal static string KindLabel(FlowStepKind kind) => kind switch + { + FlowStepKind.InboundRequest => "Request", + FlowStepKind.RouteCall => "Route call", + FlowStepKind.ScriptRun => "Script run", + FlowStepKind.AttributeWrite => "Write", + FlowStepKind.AttributeWait => "Wait", + FlowStepKind.Response => "Response", + FlowStepKind.ApiCall => "API call", + FlowStepKind.DbCall => "Database", + FlowStepKind.Notify => "Notify", + _ => "Step", + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.css new file mode 100644 index 00000000..b0aa9add --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowTraceList.razor.css @@ -0,0 +1,15 @@ +/* Flow Debugger vertical trace list (Direction B) — same CSS variables as FlowGraphView. */ +.ft-wrap { border: 1px solid var(--rule); border-radius: 4px; background: var(--bs-body-bg, #fff); overflow: auto; } +.ft-tools { display: flex; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--rule); } +.ft-lane-select { width: auto; } +.ft-table th { font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-soft); } +.ft-row { cursor: pointer; } +.ft-row:hover, .ft-row:focus-visible { background: var(--active-bg); outline: none; } +.ft-row.selected { background: var(--active-bg); box-shadow: inset 3px 0 0 var(--accent); } +.ft-row.failed { box-shadow: inset 3px 0 0 var(--bs-danger, #c0392b); } +.ft-title { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 0.8rem; } +.ft-pill { font-size: 0.7rem; border: 1px solid var(--rule-strong); border-radius: 10px; padding: 0 6px; } +.ft-pill.ok { color: var(--bs-success, #2e7d32); } +.ft-pill.pending { color: var(--accent); } +.ft-pill.parked { color: var(--warn, var(--bs-warning, #e8920c)); } +.ft-pill.failed { color: var(--bs-danger, #c0392b); border-color: var(--bs-danger, #c0392b); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowTraceListTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowTraceListTests.cs new file mode 100644 index 00000000..835a1ee8 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowTraceListTests.cs @@ -0,0 +1,96 @@ +using Bunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// Tests for (Flow Debugger vertical trace list, Gitea #53). +public class FlowTraceListTests : BunitContext +{ + private static readonly DateTimeOffset T0 = new(2026, 11, 9, 12, 0, 0, TimeSpan.Zero); + + private static FlowStep Step(FlowStepKind kind, FlowLane lane, string title, int offsetMs, + FlowStepOutcome outcome = FlowStepOutcome.Ok, int? durationMs = 5, IReadOnlyList? logs = null) => + new(Guid.NewGuid(), null, null, kind, lane, title, null, T0.AddMilliseconds(offsetMs), durationMs, outcome, + null, null, null, null, null, null, null, null, Logs: logs); + + private static FlowGraph Graph(params FlowStep[] steps) => + new(steps, Array.Empty(), Predicted: false, Truncated: false); + + private static FlowGraph Sample() => Graph( + Step(FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/M", 0), + Step(FlowStepKind.ScriptRun, FlowLane.Site, "RunIt", 20, FlowStepOutcome.Failed, 80), + Step(FlowStepKind.AttributeWrite, FlowLane.Tags, "Inst.Flag", 30)); + + /// Every step renders as a row in display order with its lane and offset. + [Fact] + public void Render_ListsStepsInOrderWithLaneColumn() + { + var cut = Render(p => p.Add(c => c.Graph, Sample())); + + var rows = cut.FindAll("[data-test='flow-list-row']"); + Assert.Equal(3, rows.Count); + Assert.Equal(new[] { "Central", "Site", "Tags" }, rows.Select(r => r.GetAttribute("data-lane"))); + Assert.Contains("+20 ms", rows[1].TextContent); + Assert.Contains("RunIt", rows[1].TextContent); + Assert.Contains("Failed", rows[1].TextContent); + Assert.Contains("failed", rows[1].ClassList); + } + + /// The lane filter narrows the rows and the count line. + [Fact] + public void LaneFilter_NarrowsRows() + { + var cut = Render(p => p.Add(c => c.Graph, Sample())); + + cut.Find("[data-test='flow-list-lane-filter']").Change("Site"); + + var row = Assert.Single(cut.FindAll("[data-test='flow-list-row']")); + Assert.Equal("Site", row.GetAttribute("data-lane")); + Assert.Contains("1 of 3", cut.Find("[data-test='flow-list-count']").TextContent); + } + + /// The failures-only toggle keeps failed and timed-out steps. + [Fact] + public void FailuresOnly_KeepsFailedSteps() + { + var cut = Render(p => p.Add(c => c.Graph, Sample())); + + cut.Find("[data-test='flow-list-failed-only']").Change(true); + + var row = Assert.Single(cut.FindAll("[data-test='flow-list-row']")); + Assert.Equal("ScriptRun", row.GetAttribute("data-kind")); + } + + /// Clicking or pressing Enter on a row raises OnSelect; the selected row is marked. + [Fact] + public void ClickAndEnter_RaiseSelect_AndSelectedRowIsMarked() + { + var graph = Sample(); + var picked = new List(); + var cut = Render(p => p + .Add(c => c.Graph, graph) + .Add(c => c.SelectedEventId, graph.Steps[2].EventId) + .Add(c => c.OnSelect, s => picked.Add(s))); + + cut.FindAll("[data-test='flow-list-row']")[1].Click(); + cut.FindAll("[data-test='flow-list-row']")[0].KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" }); + + Assert.Equal(new[] { FlowStepKind.ScriptRun, FlowStepKind.InboundRequest }, picked.Select(s => s.Kind)); + var rows = cut.FindAll("[data-test='flow-list-row']"); + Assert.Equal("true", rows[2].GetAttribute("aria-selected")); + Assert.Contains("selected", rows[2].ClassList); + } + + /// No graph shows the empty note; log lines show a count. + [Fact] + public void EmptyGraph_ShowsNoSteps_AndLogsAreCounted() + { + Assert.NotNull(Render().Find("[data-test='flow-list-empty']")); + + var logs = new[] { new FlowLogLine(Guid.NewGuid(), null, T0, "error", "script", "boom", null, null, null, 1) }; + var cut = Render(p => p.Add(c => c.Graph, + Graph(Step(FlowStepKind.ScriptRun, FlowLane.Site, "S", 0, logs: logs)))); + Assert.Equal("1", cut.Find("[data-test='flow-list-logs']").TextContent.Trim()); + } +}