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
This commit is contained in:
@@ -310,13 +310,27 @@
|
||||
<span data-test="flow-site-not-traced">Site not traced — the site returned no trace rows for the routed call (older site build or an untraced call).</span>
|
||||
}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom">
|
||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="View">
|
||||
<button type="button" class="btn @(_listView ? "btn-outline-secondary" : "btn-secondary")" data-test="flow-view-swimlane"
|
||||
aria-pressed="@(!_listView)" @onclick="() => _listView = false">Swimlane</button>
|
||||
<button type="button" class="btn @(_listView ? "btn-secondary" : "btn-outline-secondary")" data-test="flow-view-list"
|
||||
aria-pressed="@_listView" @onclick="() => _listView = true">Vertical trace</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom" hidden="@_listView">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Max(0.4, _zoom - 0.1)" aria-label="Zoom out">−</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = 1.0" aria-label="Fit">Fit</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Min(2.0, _zoom + 0.1)" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowGraphView Graph="_graph" Predicted="_predicted" SelectedEventId="_selected?.EventId" OnSelect="OnSelectStep" Zoom="_zoom" />
|
||||
@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. *@
|
||||
<FlowTraceList Graph="_graph" SelectedEventId="_selected?.EventId" OnSelect="OnSelectStep" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FlowGraphView Graph="_graph" Predicted="_predicted" SelectedEventId="_selected?.EventId" OnSelect="OnSelectStep" Zoom="_zoom" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Detail panel ──────────────────────────────────────────────────── *@
|
||||
|
||||
@@ -183,6 +183,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private string? _errorCode;
|
||||
private int _runNumber;
|
||||
private double _zoom = 1.0;
|
||||
/// <summary>True when the vertical trace list (Direction B, Gitea #53) replaces the swimlane.</summary>
|
||||
private bool _listView;
|
||||
private bool _running;
|
||||
private bool _storageRestored;
|
||||
|
||||
|
||||
@@ -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. *@
|
||||
|
||||
<div class="ft-wrap" data-test="flow-trace-list">
|
||||
<div class="ft-tools">
|
||||
<label class="small text-muted me-1" for="ft-lane-filter">Lane</label>
|
||||
<select id="ft-lane-filter" class="form-select form-select-sm ft-lane-select" data-test="flow-list-lane-filter"
|
||||
@bind="_laneFilter">
|
||||
<option value="">All</option>
|
||||
@foreach (var lane in Enum.GetValues<FlowLane>())
|
||||
{
|
||||
<option value="@lane">@lane</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-check form-check-inline ms-3 mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="ft-failed-only" data-test="flow-list-failed-only"
|
||||
checked="@_failedOnly" @onchange="e => { _failedOnly = (bool)(e.Value ?? false); }" />
|
||||
<label class="form-check-label small" for="ft-failed-only">Failures only</label>
|
||||
</div>
|
||||
<span class="small text-muted ms-auto" data-test="flow-list-count">@Rows().Count of @(Graph?.Steps.Count ?? 0) steps</span>
|
||||
</div>
|
||||
|
||||
@if (Rows().Count == 0)
|
||||
{
|
||||
<div class="ft-empty text-muted fst-italic p-3" data-test="flow-list-empty">No steps</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm ft-table mb-0" aria-label="Flow trace">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="text-end">#</th>
|
||||
<th scope="col">+Time</th>
|
||||
<th scope="col">Lane</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Step</th>
|
||||
<th scope="col">Outcome</th>
|
||||
<th scope="col" class="text-end">Duration</th>
|
||||
<th scope="col">Logs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var (step, index) in Rows())
|
||||
{
|
||||
var s = step;
|
||||
var logs = FlowGraphView.LogCounts(s);
|
||||
<tr class="ft-row @(s.EventId == SelectedEventId ? "selected" : "") @(IsFailure(s) ? "failed" : "")"
|
||||
data-test="flow-list-row" data-event-id="@s.EventId" data-lane="@s.Lane" data-kind="@s.Kind"
|
||||
tabindex="0" aria-selected="@(s.EventId == SelectedEventId ? "true" : "false")"
|
||||
@onclick="() => OnSelect.InvokeAsync(s)" @onkeydown="e => OnKeyAsync(e, s)">
|
||||
<td class="text-end text-muted">@(index + 1)</td>
|
||||
<td class="font-monospace small">@Offset(s)</td>
|
||||
<td><span class="badge bg-light text-dark">@s.Lane</span></td>
|
||||
<td class="small text-uppercase text-muted">@KindLabel(s.Kind)</td>
|
||||
<td class="ft-title" title="@s.Title">@s.Title</td>
|
||||
<td><span class="@PillClass(s.Outcome)" data-test="flow-list-outcome">@FlowGraphView.PillText(s.Outcome)</span></td>
|
||||
<td class="text-end font-monospace small">@(s.DurationMs is int ms ? ms + " ms" : "")</td>
|
||||
<td data-test="flow-list-logs">
|
||||
@if (logs.Errors + logs.Warnings + logs.Info > 0)
|
||||
{
|
||||
<span class="small" title="@FlowGraphView.LogTitle(logs)">@(logs.Errors + logs.Warnings + logs.Info)</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical trace list over a <see cref="FlowGraph"/> (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.
|
||||
/// </summary>
|
||||
public partial class FlowTraceList : ComponentBase
|
||||
{
|
||||
/// <summary>The real graph (from a run or a replay); null before a run.</summary>
|
||||
[Parameter] public FlowGraph? Graph { get; set; }
|
||||
|
||||
/// <summary>The selected step's event id.</summary>
|
||||
[Parameter] public Guid? SelectedEventId { get; set; }
|
||||
|
||||
/// <summary>Raised when a row is clicked or activated with Enter/Space.</summary>
|
||||
[Parameter] public EventCallback<FlowStep> 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<FlowLane>(_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",
|
||||
};
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user