@page "/alerts" @* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms) and the AB CIP ALMD bridge. *@ @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts @inject IInProcessBroadcaster Alarms @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @inject IAdminOperationsClient AdminOps @implements IAsyncDisposable

Alerts

@(_connected ? "live" : "disconnected")
Live alarm transitions from the cluster's alerts DPS topic. Shows the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources: ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
@if (_rows.Count == 0) {
No alarms in the current window. The table will populate as soon as a ScriptedAlarmActor or driver alarm bridge publishes a transition.
} else {
Recent transitions (@_rows.Count)
@if (_canOperate) { } @foreach (var e in _rows) { @if (_canOperate) { @* DriverOperator-gated Acknowledge / Shelve / Unshelve. Each routes through the AdminOperationsActor singleton, which republishes onto the cluster 'alarm-commands' topic; the owning node applies it (ownership filter). *@ } }
Time Alarm Equipment Kind Severity User MessageActions
@e.TimestampUtc.ToString("HH:mm:ss.fff") @e.AlarmId
@e.AlarmName
@e.EquipmentPath @e.TransitionKind @e.Severity @e.User @e.Message
@if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null) { @_opResultMessage }
} @code { private const int Capacity = 200; private readonly List _rows = new(); private bool _connected; // Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls. private bool _canOperate; // Per-row action state. Only one alarm action is in flight at a time; the busy/result // fields are keyed by AlarmId so the spinner + result chip attach to the right row. private string _busyAlarmId = ""; private string _opResultAlarmId = ""; private string? _opResultMessage; private bool _opResultOk; // Auto-clear timer for the per-row result chip (mirrors DriverStatusPanel): the chip is set in // ShowOpResult and cleared 8 s later so it doesn't persist until the next action. private System.Threading.Timer? _opResultClearTimer; private object? _opResultClearToken; // Per-row timed-shelve duration (minutes). Keyed by AlarmId so each row's number input is // independent — binding every row to one shared field would couple all the inputs together. private const int DefaultShelveMinutes = 5; private readonly Dictionary _shelveMinutes = new(); private int GetShelveMinutes(string alarmId) => _shelveMinutes.TryGetValue(alarmId, out var m) ? m : DefaultShelveMinutes; private void SetShelveMinutes(string alarmId, int minutes) => _shelveMinutes[alarmId] = minutes < 1 ? 1 : minutes; protected override async Task OnInitializedAsync() { // Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the // 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection // behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead. // 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. Alarms.ConnectionStateChanged += OnConnectionStateChanged; Alarms.Received += OnAlarm; _connected = Alarms.IsConnected; // Check DriverOperator authorization so the per-row action controls only render for // permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a // mid-session token refresh lands in the published command + audit accurately. var auth = await AuthState.GetAuthenticationStateAsync(); var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); _canOperate = authResult.Succeeded; } private async Task AcknowledgeAsync(string alarmId) { _busyAlarmId = alarmId; _opResultMessage = null; StateHasChanged(); try { var user = await GetCurrentUserNameAsync(); using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)); var result = await AdminOps.AcknowledgeAlarmAsync( alarmId, user, comment: null, cts.Token); ShowOpResult(alarmId, result.Ok, result.Ok ? "Ack sent" : (result.Message ?? "Failed")); } catch (Exception ex) { ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message); } finally { _busyAlarmId = ""; StateHasChanged(); } } // Thin wrapper for the per-row "Shelve (timed)" button: shelves until now + the row's chosen // minutes. OneShot/Unshelve call ShelveAsync directly with the default null unshelve-at. private Task ShelveTimedAsync(string alarmId) => ShelveAsync(alarmId, ShelveKind.Timed, DateTime.UtcNow.AddMinutes(GetShelveMinutes(alarmId))); private async Task ShelveAsync(string alarmId, ShelveKind kind, DateTime? unshelveAtUtc = null) { _busyAlarmId = alarmId; _opResultMessage = null; StateHasChanged(); try { var user = await GetCurrentUserNameAsync(); using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)); var result = await AdminOps.ShelveAlarmAsync( alarmId, user, kind, unshelveAtUtc: unshelveAtUtc, comment: null, cts.Token); var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve"; ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed")); } catch (Exception ex) { ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message); } finally { _busyAlarmId = ""; StateHasChanged(); } } /// /// Re-reads the AuthenticationState at call time so the operator name forwarded to the /// command + audit reflects the current claims-principal (survives token refresh during a /// long-lived circuit). Returns "unknown" if no Name claim is present. /// private async Task GetCurrentUserNameAsync() { var auth = await AuthState.GetAuthenticationStateAsync(); return auth.User.Identity?.Name ?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown"; } private void ShowOpResult(string alarmId, bool ok, string message) { _opResultAlarmId = alarmId; _opResultOk = ok; _opResultMessage = message; StateHasChanged(); // Auto-clear the result chip after 8 s (mirrors DriverStatusPanel). System.Threading.Timer // is used (not System.Timers.Timer) so DisposeAsync can drain any in-flight callback. var token = _opResultClearToken = new object(); _opResultClearTimer?.Dispose(); _opResultClearTimer = new System.Threading.Timer(_ => InvokeAsync(() => { // Ignore a stale fire: a newer action superseded this timer's chip. (Timer.Dispose does // not drain an already-queued callback, so without this a stale timer could clear a // freshly-set result from a different row.) if (!ReferenceEquals(_opResultClearToken, token)) return; _opResultMessage = null; _opResultAlarmId = ""; StateHasChanged(); }), 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(); }); // 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 // pill re-renders safely. private void OnConnectionStateChanged(bool connected) => InvokeAsync(() => { _connected = connected; StateHasChanged(); }); private async Task ClearAsync() { _rows.Clear(); await InvokeAsync(StateHasChanged); } private static string KindChipClass(string kind) => kind switch { "Activated" => "chip-alert", "Cleared" => "chip-ok", "Acknowledged" or "Confirmed" => "chip-caution", "Shelved" or "Disabled" => "chip-idle", _ => "chip-idle", }; public async ValueTask DisposeAsync() { // Unsubscribe BOTH broadcaster events first so the singleton can't invoke a handler on a // disposed component, then drain the auto-clear timer (its async dispose awaits any in-flight // callback so it can't call StateHasChanged on a gone component). Alarms.ConnectionStateChanged -= OnConnectionStateChanged; Alarms.Received -= OnAlarm; if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync(); } }