perf(adminui): coalesce /script-log and /alerts renders with a ring buffer (R04 P7)

Each received event used to Insert(0) into a List and render the full page.
LiveTailBuffer queues events from the bridge thread into a bounded newest-first
ring; CoalescingRenderScheduler (TimeProvider-driven, drained on dispose)
flushes and renders at most once per 200 ms window. ScriptLog's filtered rows
are recomputed on flush/filter change instead of three times per render.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:28:23 -04:00
parent 348ffaec1c
commit 8e69118200
5 changed files with 426 additions and 47 deletions
@@ -12,6 +12,7 @@
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail
@implements IAsyncDisposable
<PageErrorBoundary>
@@ -29,7 +30,7 @@
Shows the most-recent @Capacity entries since the page opened; reload for a fresh window.
</section>
@if (_rows.Count == 0)
@if (_tail.Items.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
No alarms in the current window. The table will populate as soon as a
@@ -39,7 +40,7 @@
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Recent transitions (@_rows.Count)</div>
<div class="panel-head">Recent transitions (@_tail.Items.Count)</div>
<div class="table-wrap">
<table class="data-table">
<thead>
@@ -58,7 +59,7 @@ else
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
@foreach (var e in _tail.Items)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
@@ -137,7 +138,9 @@ else
@code {
private const int Capacity = 200;
private readonly List<AlarmTransitionEvent> _rows = new();
// Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7).
private readonly LiveTailBuffer<AlarmTransitionEvent> _tail = new(Capacity);
private CoalescingRenderScheduler? _render;
private bool _connected;
// Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls.
@@ -172,6 +175,8 @@ else
// Subscribe to the health signal BEFORE reading IsConnected so a state change between the
// read and the subscribe can't be missed (TOCTOU) — mirrors DriverStatusPanel's
// SnapshotChanged-then-TryGet ordering.
_render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow,
() => InvokeAsync(FlushTail));
Alarms.ConnectionStateChanged += OnConnectionStateChanged;
Alarms.Received += OnAlarm;
_connected = Alarms.IsConnected;
@@ -275,15 +280,18 @@ else
null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
}
private void OnAlarm(AlarmTransitionEvent evt) =>
// Marshal both the mutation and the re-render onto the circuit sync context so this can't
// race ClearAsync (which runs there) over the shared _rows list.
InvokeAsync(() =>
{
_rows.Insert(0, evt);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
StateHasChanged();
});
// Bridge-actor thread: queue only. The first transition of a window arms one render ~200 ms
// later, so an alarm storm costs one render per window per tab instead of one per transition.
private void OnAlarm(AlarmTransitionEvent evt)
{
if (_tail.Add(evt)) _render?.Schedule();
}
// Render context: drain the queue into the ring and render once.
private void FlushTail()
{
if (_tail.Flush()) StateHasChanged();
}
// Raised by the broadcaster (on the bridge actor's thread) whenever the upstream DPS
// subscription's health flips. Marshal onto the circuit sync context like OnAlarm so the live
@@ -293,7 +301,7 @@ else
private async Task ClearAsync()
{
_rows.Clear();
_tail.Clear();
await InvokeAsync(StateHasChanged);
}
@@ -314,5 +322,6 @@ else
Alarms.ConnectionStateChanged -= OnConnectionStateChanged;
Alarms.Received -= OnAlarm;
if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
if (_render is not null) await _render.DisposeAsync();
}
}
@@ -6,13 +6,14 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
@implements IDisposable
@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log</h4>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter">
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter" @bind:after="RecomputeVisible">
<option value="">All levels</option>
<option value="Trace">Trace+</option>
<option value="Debug">Debug+</option>
@@ -21,7 +22,7 @@
<option value="Error">Error+</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:200px"
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" />
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" @bind:after="RecomputeVisible" />
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
</span>
@@ -29,23 +30,23 @@
</div>
</div>
@if (VisibleRows.Count == 0)
@if (_visible.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
@if (_rows.Count == 0)
@if (_tail.Items.Count == 0)
{
<span>No script-log entries yet.</span>
}
else
{
<span>No entries match the current filter (@_rows.Count entries available).</span>
<span>No entries match the current filter (@_tail.Items.Count entries available).</span>
}
</section>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Showing @VisibleRows.Count of @_rows.Count</div>
<div class="panel-head">Showing @_visible.Count of @_tail.Items.Count</div>
<div class="table-wrap">
<table class="data-table">
<thead>
@@ -58,7 +59,7 @@ else
</tr>
</thead>
<tbody>
@foreach (var e in VisibleRows)
@foreach (var e in _visible)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
@@ -82,7 +83,11 @@ else
@code {
private const int Capacity = 500;
private readonly List<ScriptLogEntry> _rows = new();
// Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7).
private readonly LiveTailBuffer<ScriptLogEntry> _tail = new(Capacity);
private CoalescingRenderScheduler? _render;
// The filtered rows, recomputed on flush / filter change — not on every render.
private IReadOnlyList<ScriptLogEntry> _visible = Array.Empty<ScriptLogEntry>();
private bool _connected;
private string _levelFilter = "";
private string _scriptFilter = "";
@@ -92,22 +97,19 @@ else
["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5,
};
private List<ScriptLogEntry> VisibleRows
private void RecomputeVisible()
{
get
IEnumerable<ScriptLogEntry> q = _tail.Items;
if (!string.IsNullOrWhiteSpace(_levelFilter)
&& LevelRank.TryGetValue(_levelFilter, out var minRank))
{
IEnumerable<ScriptLogEntry> q = _rows;
if (!string.IsNullOrWhiteSpace(_levelFilter)
&& LevelRank.TryGetValue(_levelFilter, out var minRank))
{
q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank);
}
if (!string.IsNullOrWhiteSpace(_scriptFilter))
{
q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank);
}
if (!string.IsNullOrWhiteSpace(_scriptFilter))
{
q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase));
}
_visible = q.ToList();
}
protected override void OnInitialized()
@@ -116,6 +118,8 @@ else
// 'script-logs' DPS topic). Blazor Server can't self-connect a SignalR HubConnection behind
// a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
// Subscribe the state-change event BEFORE reading IsConnected so no transition is missed.
_render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow,
() => InvokeAsync(FlushTail));
ScriptLogs.ConnectionStateChanged += OnConnectionStateChanged;
ScriptLogs.Received += OnEntry;
_connected = ScriptLogs.IsConnected;
@@ -124,19 +128,25 @@ else
private void OnConnectionStateChanged(bool connected) =>
InvokeAsync(() => { _connected = connected; StateHasChanged(); });
private void OnEntry(ScriptLogEntry entry) =>
// Marshal both the mutation and the re-render onto the circuit sync context so this can't
// race ClearAsync (which runs there) over the shared _rows list.
InvokeAsync(() =>
{
_rows.Insert(0, entry);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
StateHasChanged();
});
// Bridge-actor thread: queue only. The first entry of a window arms one render ~200 ms later, so
// a chatty script costs one render per window per tab instead of one per log line.
private void OnEntry(ScriptLogEntry entry)
{
if (_tail.Add(entry)) _render?.Schedule();
}
// Render context: drain the queue into the ring and render once.
private void FlushTail()
{
if (!_tail.Flush()) return;
RecomputeVisible();
StateHasChanged();
}
private async Task ClearAsync()
{
_rows.Clear();
_tail.Clear();
RecomputeVisible();
await InvokeAsync(StateHasChanged);
}
@@ -148,9 +158,11 @@ else
_ => "chip-idle",
};
public void Dispose()
public async ValueTask DisposeAsync()
{
ScriptLogs.ConnectionStateChanged -= OnConnectionStateChanged;
ScriptLogs.Received -= OnEntry;
// Drains an in-flight window and suppresses later ones, so no flush renders a gone component.
if (_render is not null) await _render.DisposeAsync();
}
}