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).
}
-
+
+ _listView = false">Swimlane
+ _listView = true">Vertical trace
+
+
_zoom = Math.Max(0.4, _zoom - 0.1)" aria-label="Zoom out">−
_zoom = 1.0" aria-label="Fit">Fit
_zoom = Math.Min(2.0, _zoom + 0.1)" aria-label="Zoom in">+
-
+ @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. *@
+
+
+
+
+ @if (Rows().Count == 0)
+ {
+
No steps
+ }
+ else
+ {
+
+
+
+ #
+ +Time
+ Lane
+ Kind
+ Step
+ Outcome
+ Duration
+ Logs
+
+
+
+ @foreach (var (step, index) in Rows())
+ {
+ var s = step;
+ var logs = FlowGraphView.LogCounts(s);
+ OnSelect.InvokeAsync(s)" @onkeydown="e => OnKeyAsync(e, s)">
+ @(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());
+ }
+}