Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor
T
Joseph Doherty 19ec656cdc feat(adminui): ScriptLog live-pill bound to broadcaster health
Subscribe ConnectionStateChanged before reading IsConnected (subscribe-then-read
idiom, matches DriverStatusPanel) so no transition is missed. Add
OnConnectionStateChanged handler that marshals to the circuit sync context via
InvokeAsync. Dispose unsubscribes both events.

Reconnect-overlay: App.razor loads _framework/blazor.web.js and contains no
custom #components-reconnect-modal element; .NET 10 Blazor's default reconnect
overlay is active automatically — no custom markup needed.

No unit tests; live-verify follows.
2026-06-11 09:37:19 -04:00

160 lines
6.4 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 [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
@implements IDisposable
<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">
<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" />
<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>
<section class="panel notice rise" style="animation-delay:.02s">
Live tail of <span class="mono">script-logs</span> DPS topic, capped at @Capacity entries.
Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9).
</section>
@if (VisibleRows.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
@if (_rows.Count == 0)
{
<span>No script-log entries yet. Engine emit (F8/F9) is pending.</span>
}
else
{
<span>No entries match the current filter (@_rows.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="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 VisibleRows)
{
<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><span class="mono small">@e.Message</span></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int Capacity = 500;
private readonly List<ScriptLogEntry> _rows = new();
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 List<ScriptLogEntry> VisibleRows
{
get
{
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();
}
}
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;
}
}