@page "/alarms" @implements IAsyncDisposable @using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.MxGateway.Server.Alarms @* SEC-40: the alarm feed needs a real authenticated dashboard role, not the read-only environment bypasses (Authentication:Mode=Disabled, anonymous localhost) that satisfy the ViewerPolicy MapRazorComponents() applies. This page reads the gateway-central alarm monitor in process, exactly the surface AlarmsHub serves to external clients, so it carries the SAME policy as the hub — gating only the hub would leave the disclosure one navigation away. Alarm metadata stays galaxy-wide for an authenticated Viewer: the monitor has no per-session provenance, so the SEC-25 tag ACL has nothing to intersect. *@ @attribute [Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)] @inject IDashboardLiveDataService LiveData @inject IOptions GatewayOptions @inject IGatewayAlarmService AlarmService Dashboard Alarms

Alarms

@HeaderLine()
@_providerStatus.Label
@if (!GatewayOptions.Value.Alarms.Enabled) {
Alarm auto-subscribe is disabled (MxGateway:Alarms:Enabled is false). The dashboard session is not subscribed to any alarm provider, so this list will stay empty. Enable alarms in configuration and restart the gateway.
} @if (!string.IsNullOrWhiteSpace(_queryError)) {
Alarm query failed: @_queryError
} @* Warning, not danger: the rows below are all real and the monitor is healthy — only the completeness of the set is in doubt, so this must not read as "alarms are broken". *@ @if (_snapshotTruncated) {
Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond the cap are not listed. Alarms already known stay listed rather than clearing. Raise MxGateway:Alarms:MaxAlarmsPerFetch or narrow the subscription if this persists.
}
@* FilteredAlarms() is memoized on (alarm generation, filter inputs), so this card and the table below share one computation per change instead of filtering twice per render. *@

Filters

Active Alarms

@{ IReadOnlyList rows = FilteredAlarms(); } @if (rows.Count == 0) {
@if (_alarms.Count == 0) { No alarms are currently Active or ActiveAcked. } else { No alarms match the current filters. }
} else {
@foreach (DashboardActiveAlarm alarm in rows) { }
State Severity Alarm Reference Source Type Area Last Transition Operator
@StateText(alarm.State) @alarm.Severity @alarm.Reference @if (!string.IsNullOrWhiteSpace(alarm.Description)) {
@alarm.Description
}
@DashboardDisplay.Text(alarm.Source) @DashboardDisplay.Text(alarm.AlarmType) @DashboardDisplay.Text(alarm.Area) @(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-") @DashboardDisplay.Text(alarm.OperatorUser) @if (!string.IsNullOrWhiteSpace(alarm.OperatorComment)) {
@alarm.OperatorComment
}
}
Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds.
@code { private readonly List _alarms = []; private string? _queryError; private bool _snapshotTruncated; private int? _workerPid; private DateTimeOffset? _lastRefresh; private int _unackedCount; private int _ackedCount; private bool _showActive = true; private bool _showAcked; private string _areaFilter = string.Empty; private int _minSeverity; private int _maxSeverity = 1000; private string _search = string.Empty; // Bumped whenever _alarms is replaced, so the memoized view recomputes exactly then. private readonly DashboardAlarmView _view = new(); private long _alarmGeneration; // Upper bound on waiting for either background loop while disposing, mirroring // DashboardPageBase's snapshot-watch drain: both loops marshal renders through the // renderer's dispatcher and disposal can run on that same dispatcher, so the wait is // bounded rather than unconditional — an unconditional one hangs teardown for good on // a wedged dispatcher. private static readonly TimeSpan LoopDrainTimeout = TimeSpan.FromSeconds(5); private readonly CancellationTokenSource _cts = new(); private Task? _pollTask; private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy; private Task? _statusFeedTask; /// protected override void OnInitialized() { _pollTask = PollLoopAsync(); _statusFeedTask = StatusFeedLoopAsync(); } private string? ProviderStatusTitle() { return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason) ? _providerStatus.Reason : null; } // The badge tracks the central monitor directly rather than looping back through // /hubs/alarms: the alarm service is an in-process multi-subscriber fan-out, so a // server-rendered page needs no SignalR client, no loopback socket and no auth token. // This loop feeds the two gateway-status indicators — the provider badge and the // truncation banner — from the feed's own status frames, so both move as soon as the // monitor's verdict changes instead of on the next 3-second tick. Alarm rows still come // from the poll below, which also re-asserts the truncation verdict as its reconcile // baseline: both sources read the same monitor verdict, so they cannot disagree for // longer than one tick, and neither one is synthesized here. private async Task StatusFeedLoopAsync() { while (!_cts.IsCancellationRequested) { try { await foreach (AlarmFeedMessage message in AlarmService .StreamAsync(alarmFilterPrefix: null, _cts.Token) .ConfigureAwait(false)) { switch (message.PayloadCase) { case AlarmFeedMessage.PayloadOneofCase.ProviderStatus: await InvokeAsync(() => { _providerStatus = DashboardAlarmProviderStatus.FromFeed(message); StateHasChanged(); }).ConfigureAwait(false); break; // Every subscriber is primed with this frame at open, so a page that // attaches mid-truncation gets the caveat without waiting for an edge — // no page-side priming needed. case AlarmFeedMessage.PayloadOneofCase.SnapshotStatus: await InvokeAsync(() => { _snapshotTruncated = message.SnapshotStatus.Truncated; StateHasChanged(); }).ConfigureAwait(false); break; default: break; } } } catch (OperationCanceledException) { return; } catch { // The monitor drops a subscriber whose queue it cannot write to, completing // that stream with an error; short of cancellation or disposal that is the // only way this enumeration ends. A monitor restart is NOT one of them — it // keeps the channel and pushes the cleared status frames through it — so this // catch is the fell-behind case, recoverable by resubscribing. The badge and // banner hold their last values meanwhile, and the resubscribe is primed with // the current ones. } try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } } private string HeaderLine() { string refreshed = _lastRefresh is { } at ? $"refreshed {DashboardDisplay.DateTime(at)}" : "awaiting first refresh"; return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed; } private IReadOnlyList Areas() => _view.Areas(_alarmGeneration, _alarms); private IReadOnlyList FilteredAlarms() => _view.Filtered( _alarmGeneration, new DashboardAlarmFilterCriteria(_showActive, _showAcked, _areaFilter, _minSeverity, _maxSeverity, _search), _alarms); private static string StateText(AlarmConditionState state) { return state switch { AlarmConditionState.Active => "Active", AlarmConditionState.ActiveAcked => "Acked", AlarmConditionState.Inactive => "Inactive", _ => "Unknown", }; } private static string StateClass(AlarmConditionState state) { return state switch { AlarmConditionState.Active => "alarm-state-active", AlarmConditionState.ActiveAcked => "alarm-state-acked", _ => "alarm-state-other", }; } // Fault handling sits inside the loop, matching StatusFeedLoopAsync: a query or render // fault on one tick is transient (a provider blip, a momentarily unavailable session), so it // is surfaced on the page and retried on the next tick rather than ending polling for the // life of the page. Cancellation is the only exit. The loop method itself therefore cannot // fault, which is what DrainAsync in DisposeAsync relies on. private async Task PollLoopAsync() { if (!await PollOnceAsync().ConfigureAwait(false)) { return; } using PeriodicTimer timer = new(TimeSpan.FromSeconds(3)); while (true) { try { if (!await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) { return; } } catch (OperationCanceledException) { return; } if (!await PollOnceAsync().ConfigureAwait(false)) { return; } } } // Returns false only when cancellation has ended the poll; a non-cancellation fault returns // true so the caller waits for the next tick and tries again. private async Task PollOnceAsync() { try { await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); return true; } catch (OperationCanceledException) { return false; } catch (Exception ex) { await ReportPollFaultAsync(ex).ConfigureAwait(false); return true; } } private async Task ReportPollFaultAsync(Exception fault) { try { await InvokeAsync(() => { _queryError = fault.Message; StateHasChanged(); }).ConfigureAwait(false); } catch { // Reporting is best-effort: the fault being reported may itself be the teardown race // this catch-all exists for — an InvokeAsync against a disposed renderer — in which // case the dispatch fails the same way and there is no page left to show it on. The // poll loop keeps ticking either way and exits on the next cancellation check. } } private async Task RefreshAlarmsAsync() { DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token); _queryError = result.Error; // Kept alongside the feed's snapshot_status frame rather than replaced by it: this is // the reconcile baseline. Both read the same monitor verdict, so the poll can only // confirm what the frame already showed — but it also re-establishes the banner for a // page whose feed subscription is mid-resubscribe after the monitor dropped it. _snapshotTruncated = result.SnapshotTruncated; _workerPid = result.WorkerProcessId; _lastRefresh = DateTimeOffset.UtcNow; _alarms.Clear(); _alarms.AddRange(result.Alarms); _alarmGeneration++; _unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active); _ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked); StateHasChanged(); } /// public async ValueTask DisposeAsync() { await _cts.CancelAsync().ConfigureAwait(false); // Drained together, not one after the other: the wedged dispatcher this bound exists // for blocks both loops at once, so sequential drains would time out twice and make // the real bound 10 seconds. DrainAsync tolerates a null task. await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_statusFeedTask)) .ConfigureAwait(false); _cts.Dispose(); GC.SuppressFinalize(this); } // The accepted cost of the bound is an abandoned loop that keeps its alarm-service // subscription (and its poll timer) until it does unwind; the alternative — waiting // forever on a dispatcher that is not draining — wedges the circuit teardown itself. private static async Task DrainAsync(Task? loop) { if (loop is null) { return; } try { await loop.WaitAsync(LoopDrainTimeout).ConfigureAwait(false); } catch (TimeoutException) { } catch (OperationCanceledException) { } catch { // Other disposal-time errors are best-effort. } } }