@page "/monitoring/alarms" @attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)] @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @implements IDisposable @inject IAlarmSummaryService AlarmSummaryService @inject ISiteRepository SiteRepository

Alarm Summary

@if (_selectedSiteId != null) { Auto-refresh: @(_autoRefreshSeconds)s }
@* ── Site picker ── *@
@if (_selectedSiteId == null) {
Select a site to view its current alarms.
} else { @* ── Roll-up tiles ── *@

@_rollup.TotalActive

Active Alarms

@_rollup.WorstSeverity

Worst Severity

@_rollup.UnackedCount

Unacknowledged

@_rows.Count

Total Rows @if (_rollup.CountsByKind.Count > 0) { · @string.Join(" / ", _rollup.CountsByKind .OrderBy(kv => kv.Key) .Select(kv => $"{KindLabel(kv.Key)} {kv.Value}")) }
@if (_notReporting.Count > 0) {
Not reporting (@_notReporting.Count): @string.Join(", ", _notReporting)
} @* ── Filters ── *@
@* ── Alarm table ── *@ @if (_rows.Count == 0) {
No alarms reported across this site's enabled instances.
} else {
Showing @_visibleRows.Count of @_rows.Count
@* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0", aria-sort reflects the active sort direction, and Enter/Space toggle the sort (Space preventDefault suppresses page scroll). The same pattern should be applied to the other custom grids in the app; that fleet-wide sweep is deferred and logged (arch-review UA6). *@ @foreach (var row in _visibleRows) { }
Instance @SortGlyph("instance") Alarm @SortGlyph("name") State / Kind Severity @SortGlyph("severity")
@row.InstanceUniqueName @row.Alarm.AlarmName @row.Alarm.Condition.Severity
} }
@code { private IReadOnlyList _sites = Array.Empty(); private int? _selectedSiteId; private IReadOnlyList _rows = Array.Empty(); // P4 (arch-review): the filtered+sorted view is memoized rather than recomputed // on every render. RecomputeVisibleRows() refreshes it whenever the inputs change // (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort). private IReadOnlyList _visibleRows = Array.Empty(); private IReadOnlyList _notReporting = Array.Empty(); private AlarmRollup _rollup = new(0, 0, 0, new Dictionary()); private bool _loading; private Timer? _refreshTimer; private readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new(); private const int _autoRefreshSeconds = 15; // ── Client-side filters ── private string _filterInstance = ""; private string _filterKind = ""; private string _filterState = ""; private string _filterAck = ""; private int? _filterMinSeverity; private string _filterName = ""; // ── Sort ── private string _sortKey = "severity"; private bool _sortDescending = true; protected override async Task OnInitializedAsync() { try { _sites = await SiteRepository.GetAllSitesAsync(); } catch { // Non-fatal — the picker simply shows no sites. } } private async Task OnSiteChangedAsync(ChangeEventArgs e) { var raw = e.Value?.ToString(); if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var siteId)) { _selectedSiteId = null; _rows = Array.Empty(); _notReporting = Array.Empty(); _rollup = new AlarmRollup(0, 0, 0, new Dictionary()); StopTimer(); return; } _selectedSiteId = siteId; ClearFilters(); await RefreshAsync(); StartTimer(); } private async Task RefreshAsync() { if (_selectedSiteId is not int siteId) { return; } _loading = true; try { var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId); _rows = result.Alarms; _notReporting = result.NotReportingInstances; _rollup = AlarmSummaryService.ComputeRollup(_rows); RecomputeVisibleRows(); } catch { // Best-effort: a transient fault leaves the prior snapshot on screen // rather than blanking the page; the next poll / manual refresh retries. } finally { _loading = false; } } private void StartTimer() { StopTimer(); _refreshTimer = new Timer(_ => { if (!_pollGate.TryEnter()) return; InvokeAsync(async () => { try { await RefreshAsync(); StateHasChanged(); } finally { _pollGate.Exit(); } }); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); } private void StopTimer() { _refreshTimer?.Dispose(); _refreshTimer = null; } private IEnumerable DistinctInstances => _rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase); // P4: recompute the memoized filtered+sorted view from the current snapshot and // filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy — // NOT per render. private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList(); private IEnumerable FilteredRows() { IEnumerable q = _rows; if (!string.IsNullOrEmpty(_filterInstance)) { q = q.Where(r => r.InstanceUniqueName == _filterInstance); } if (Enum.TryParse(_filterKind, out var kind)) { q = q.Where(r => r.Alarm.Kind == kind); } if (Enum.TryParse(_filterState, out var state)) { q = q.Where(r => r.Alarm.State == state); } if (_filterAck == "unacked") { q = q.Where(r => r.Alarm.Condition.Active && !r.Alarm.Condition.Acknowledged && r.Alarm.Kind != AlarmKind.Computed); } else if (_filterAck == "acked") { q = q.Where(r => r.Alarm.Condition.Acknowledged); } if (_filterMinSeverity is int min) { q = q.Where(r => r.Alarm.Condition.Severity >= min); } if (!string.IsNullOrWhiteSpace(_filterName)) { q = q.Where(r => r.Alarm.AlarmName.Contains(_filterName, StringComparison.OrdinalIgnoreCase)); } return SortRows(q); } private IEnumerable SortRows(IEnumerable rows) { Func key = _sortKey switch { "instance" => r => r.InstanceUniqueName, "name" => r => r.Alarm.AlarmName, _ => r => r.Alarm.Condition.Severity, }; return _sortDescending ? rows.OrderByDescending(key) : rows.OrderBy(key); } private void SortBy(string key) { if (_sortKey == key) { _sortDescending = !_sortDescending; } else { _sortKey = key; _sortDescending = key == "severity"; } RecomputeVisibleRows(); } private string SortGlyph(string key) => _sortKey != key ? "" : (_sortDescending ? "▼" : "▲"); // UA6: aria-sort token for a sortable header — ascending/descending on the active // column, "none" otherwise (per the WAI-ARIA aria-sort value set). private string AriaSortFor(string key) => _sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending"); // UA6: whether the last header keydown should suppress the browser default. Bound to // @onkeydown:preventDefault (evaluated at render), so it takes effect on the next // Space keydown to stop the page scrolling when a header has keyboard focus. private bool _preventHeaderDefault; // UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort, // mirroring the pointer click. private void OnHeaderKeyDown(KeyboardEventArgs e, string key) { var isSpace = e.Key is " " or "Spacebar"; _preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone if (e.Key == "Enter" || isSpace) { SortBy(key); } } private void ClearFilters() { _filterInstance = ""; _filterKind = ""; _filterState = ""; _filterAck = ""; _filterMinSeverity = null; _filterName = ""; RecomputeVisibleRows(); } private static string KindLabel(AlarmKind kind) => kind switch { AlarmKind.NativeOpcUa => "OPC UA", AlarmKind.NativeMxAccess => "MxAccess", _ => "Computed" }; public void Dispose() => StopTimer(); }