@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 ScriptLogs @using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail @implements IAsyncDisposable

Script log

@(_connected ? "live" : "disconnected")
@if (_visible.Count == 0) {
@if (_tail.Items.Count == 0) { No script-log entries yet. } else { No entries match the current filter (@_tail.Items.Count entries available). }
} else {
Showing @_visible.Count of @_tail.Items.Count
@foreach (var e in _visible) { }
Time Level Script Context Message
@e.TimestampUtc.ToString("HH:mm:ss.fff") @e.Level @e.ScriptId @if (!string.IsNullOrEmpty(e.VirtualTagId)) { vtag=@e.VirtualTagId } @if (!string.IsNullOrEmpty(e.AlarmId)) { alarm=@e.AlarmId } @if (!string.IsNullOrEmpty(e.EquipmentId)) { eq=@e.EquipmentId } @DisplayText.Abbreviate(e.Message, 120)
}
@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 _tail = new(Capacity); private CoalescingRenderScheduler? _render; // The filtered rows, recomputed on flush / filter change — not on every render. private IReadOnlyList _visible = Array.Empty(); private bool _connected; private string _levelFilter = ""; private string _scriptFilter = ""; private static readonly Dictionary LevelRank = new(StringComparer.OrdinalIgnoreCase) { ["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5, }; private void RecomputeVisible() { IEnumerable 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(); } }