@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 [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging @inject IInProcessBroadcaster ScriptLogs @implements IDisposable

Script log

@(_connected ? "live" : "disconnected")
Live tail of script-logs DPS topic, capped at @Capacity entries. Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9).
@if (VisibleRows.Count == 0) {
@if (_rows.Count == 0) { No script-log entries yet. Engine emit (F8/F9) is pending. } else { No entries match the current filter (@_rows.Count entries available). }
} else {
Showing @VisibleRows.Count of @_rows.Count
@foreach (var e in VisibleRows) { }
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 } @e.Message
} @code { private const int Capacity = 500; private readonly List _rows = new(); 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 List VisibleRows { get { IEnumerable 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(); } } 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. ScriptLogs.ConnectionStateChanged += OnConnectionStateChanged; ScriptLogs.Received += OnEntry; _connected = ScriptLogs.IsConnected; } 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(); }); private async Task ClearAsync() { _rows.Clear(); 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 void Dispose() { ScriptLogs.ConnectionStateChanged -= OnConnectionStateChanged; ScriptLogs.Received -= OnEntry; } }