8e69118200
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
169 lines
7.0 KiB
Plaintext
169 lines
7.0 KiB
Plaintext
@page "/script-log"
|
|
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from
|
|
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
|
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
|
|
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
|
|
@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" @bind:after="RecomputeVisible">
|
|
<option value="">All levels</option>
|
|
<option value="Trace">Trace+</option>
|
|
<option value="Debug">Debug+</option>
|
|
<option value="Information">Information+</option>
|
|
<option value="Warning">Warning+</option>
|
|
<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" @bind:after="RecomputeVisible" />
|
|
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
|
|
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
|
|
</span>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_visible.Count == 0)
|
|
{
|
|
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
|
@if (_tail.Items.Count == 0)
|
|
{
|
|
<span>No script-log entries yet.</span>
|
|
}
|
|
else
|
|
{
|
|
<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 @_visible.Count of @_tail.Items.Count</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Level</th>
|
|
<th>Script</th>
|
|
<th>Context</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var e in _visible)
|
|
{
|
|
<tr>
|
|
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
|
<td><span class="chip @LevelChipClass(e.Level)">@e.Level</span></td>
|
|
<td><span class="mono small">@e.ScriptId</span></td>
|
|
<td class="text-muted small">
|
|
@if (!string.IsNullOrEmpty(e.VirtualTagId)) { <span>vtag=@e.VirtualTagId</span> }
|
|
@if (!string.IsNullOrEmpty(e.AlarmId)) { <span class="ms-1">alarm=@e.AlarmId</span> }
|
|
@if (!string.IsNullOrEmpty(e.EquipmentId)) { <span class="ms-1">eq=@e.EquipmentId</span> }
|
|
</td>
|
|
<td title="@e.Message"><span class="mono small">@DisplayText.Abbreviate(e.Message, 120)</span></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
</PageErrorBoundary>
|
|
|
|
@code {
|
|
private const int Capacity = 500;
|
|
|
|
// 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 = "";
|
|
|
|
private static readonly Dictionary<string, int> LevelRank = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5,
|
|
};
|
|
|
|
private void RecomputeVisible()
|
|
{
|
|
IEnumerable<ScriptLogEntry> q = _tail.Items;
|
|
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));
|
|
}
|
|
_visible = q.ToList();
|
|
}
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
// Live tail straight from the in-process broadcaster (fed by ScriptLogSignalRBridge off the
|
|
// '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;
|
|
}
|
|
|
|
private void OnConnectionStateChanged(bool connected) =>
|
|
InvokeAsync(() => { _connected = connected; 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()
|
|
{
|
|
_tail.Clear();
|
|
RecomputeVisible();
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private static string LevelChipClass(string level) => level switch
|
|
{
|
|
"Critical" or "Error" => "chip-alert",
|
|
"Warning" => "chip-caution",
|
|
"Information" => "chip-idle",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|