@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 IDisposable

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; 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. Alarms.Received += OnAlarm; _connected = true; // 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(); } } private async Task ShelveAsync(string alarmId, ShelveKind kind) { _busyAlarmId = alarmId; _opResultMessage = null; StateHasChanged(); try { var user = await GetCurrentUserNameAsync(); // Timed shelve (with an unshelve-at datetime picker) is deferred — only OneShot + Unshelve // are surfaced here, so unshelveAtUtc is always null. TimedShelve is fully wired through the // singleton + AlarmCommand if a UI is added later. using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)); var result = await AdminOps.ShelveAlarmAsync( alarmId, user, kind, unshelveAtUtc: null, 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(); } 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(); }); 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 void Dispose() => Alarms.Received -= OnAlarm; }