@page "/monitoring/alarms" @attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)] @using Microsoft.AspNetCore.Components.Web.Virtualization @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 @using ZB.MOM.WW.ScadaBridge.Communication @implements IDisposable @inject IAlarmSummaryService AlarmSummaryService @inject ISiteRepository SiteRepository @inject ISiteAlarmLiveCache LiveAlarmCache

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) { @* The instance list is unbounded — a site with many silent instances would otherwise push a wall of names across the page. Clamp to two lines and keep the full list reachable via the title. *@ var notReportingList = string.Join(", ", _notReporting);
Not reporting (@_notReporting.Count): @notReportingList
} @* ── 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). *@ @* Row markup is identical on both paths — the same , the same cells, the same data-test hook. Only WHO enumerates it differs: a plain foreach for the ordinary case, and Virtualize once the flat table gets long enough that rendering every row per delta dominates the circuit (arch-review WP2.4). Virtualize needs spacers or the browser hoists its
s out of the table and the layout collapses. *@
@if (_visibleRows.Count <= VirtualizeThreshold) { @foreach (var row in _visibleRows) { @AlarmRow(row) } } else { @AlarmRow(row) }
Instance @SortGlyph("instance") Alarm @SortGlyph("name") State / Kind Severity @SortGlyph("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). // Concrete List because Virtualize binds ICollection, not IReadOnlyList. private List _visibleRows = new(); // Above this many visible rows the flat table switches to Virtualize. Below it the // plain foreach is cheaper than the virtualization machinery (and needs no JS), and // an operator filtered down to a handful of alarms should never pay for either. private const int VirtualizeThreshold = 150; // One row template, shared by the plain and virtualized paths so the two can never // drift visually. private RenderFragment AlarmRow => row => @ @row.InstanceUniqueName @row.Alarm.AlarmName @row.Alarm.Condition.Severity ; 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(); DisposeLiveSubscription(); return; } _selectedSiteId = siteId; ClearFilters(); // Subscribe to the shared live cache FIRST so the aggregator (and its // seed-then-stream) starts warming while the initial poll runs. SubscribeLive(siteId); await RefreshAsync(); StartTimer(); // If another circuit already warmed the cache for this site, the initial // poll may be staler than what's live — apply the live snapshot on top. if (LiveAlarmCache.IsLive(siteId)) { ApplyLiveSnapshot(siteId); } } private async Task RefreshAsync() { if (_selectedSiteId is not int siteId) { return; } _loading = true; try { var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId); // Stale-site guard (arch-review R2 N4): the operator may have switched sites // while this fan-out was in flight — drop the result rather than labeling // site A's alarms under site B's picker. Mirrors OnLiveAlarmsChanged (:374). if (_selectedSiteId != siteId) { return; } // _notReporting always comes from this call, live or not. While the cache is // serving the site the façade sources it from the aggregator's own fan-out (no // second fan-out is run); while it is cold the poll computes it. Either way the // shape and ordering are identical. _notReporting = result.NotReportingInstances; // While the cache is live, the live deltas own the row set: a poll whose fan-out // started BEFORE a delta must not land after it and momentarily revert the alarm // state (arch-review R2 N5). When not live (pre-seed / degraded stream / dead // aggregator — see R2 N6), the poll remains the full-rebuild safety net. if (!LiveAlarmCache.IsLive(siteId)) { _rows = result.Alarms; _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; } // ── Live cache (plan #10, Task 5) ────────────────────────────────────────── // The page is live-cache-first with the 15s poll kept as a fallback/safety net. // Reconciliation model: // • The live cache pushes onChanged deltas (near-real-time) whenever the site's // aggregated alarm set changes. We rebuild _rows/_rollup/_visibleRows from the // immutable live snapshot — but deliberately DO NOT touch _notReporting, since // the alarm-only live cache can't compute it. // • The 15s poll (RefreshAsync) is the authority for _notReporting and the // full-rebuild safety net ONLY while the cache is not live (pre-seed or a // degraded/failed stream — IsLive == false); when live it deliberately leaves // _rows to the delta path, so a slow fan-out can never revert a fresher live // delta (arch-review R2 N5). // • Both paths mutate shared state only via the Blazor dispatcher (the poll via // its InvokeAsync callback, the live delta via OnLiveChanged's InvokeAsync), so // they are serialized and never race. Each rebuild is an idempotent snapshot, so // a live rebuild immediately followed by a poll rebuild (or vice-versa) is safe. private IDisposable? _liveSubscription; private void SubscribeLive(int siteId) { DisposeLiveSubscription(); _liveSubscription = LiveAlarmCache.Subscribe(siteId, () => OnLiveAlarmsChanged(siteId)); } private void DisposeLiveSubscription() { _liveSubscription?.Dispose(); _liveSubscription = null; } // N7: set BEFORE teardown so a live callback racing Dispose is dropped both // before the InvokeAsync marshal and inside it (mirrors DebugView.razor). private volatile bool _disposed; // Raised on the aggregator's thread — marshal onto the circuit before touching state. private void OnLiveAlarmsChanged(int siteId) { if (_disposed) return; _ = InvokeAsync(() => { // Drop stale callbacks for a site we've since navigated away from, and // let the poll drive until the aggregator has actually seeded (so we never // clobber a good poll snapshot with an empty pre-seed list). if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId)) { return; } ApplyLiveSnapshot(siteId); StateHasChanged(); }); } // Rebuilds rows/rollup/visible-rows from the live snapshot. Leaves _notReporting // as the last poll computed it (the alarm-only cache can't know it). Idempotent. private void ApplyLiveSnapshot(int siteId) { var current = LiveAlarmCache.GetCurrentAlarms(siteId); var result = AlarmSummaryService.BuildFromLiveAlarms(current); _rows = result.Alarms; _rollup = AlarmSummaryService.ComputeRollup(_rows); RecomputeVisibleRows(); } 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() { _disposed = true; StopTimer(); DisposeLiveSubscription(); } }