From 925c869826a7fe722535035bea87f75d61593b3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:57:09 -0400 Subject: [PATCH] fix(ui): Alarm Summary poll defers row rebuilds to the live path while IsLive (plan R2-07 T8) --- docs/requirements/Component-CentralUI.md | 2 +- .../Pages/Monitoring/AlarmSummary.razor | 24 +++++++++---- .../Monitoring/AlarmSummaryRenderTests.cs | 34 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 5853d690..43e23753 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -191,7 +191,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **Live updates** — the page is driven by a **transient, per-site central live alarm cache** (`ISiteAlarmLiveCache`, owned by the Communication component; see [Component-Communication](Component-Communication.md)). On site select the page subscribes to the cache; the cache runs one shared, reference-counted per-site aggregator that **seeds** from the snapshot fan-out and then stays warm on a single **site-wide, alarm-only** `SubscribeSite` gRPC stream (seed-then-stream, dedup by `(InstanceUniqueName, AlarmName, SourceReference)`). Applied deltas raise an in-process change event (mirroring `IDeploymentStatusNotifier`) that the Blazor circuit pushes to the browser via `StateHasChanged()` — no new SignalR hub. `AlarmSummaryService.BuildFromLiveAlarms` rebuilds the roll-up + rows from the cache's current alarm set. The cache is **purely in-memory on the active central node** — there is still **no persisted central alarm store**; on a NodeA↔NodeB failover the new active node re-seeds from scratch. - **View** — roll-up tiles (total active, worst severity, unacked count, per-`AlarmKind` counts) plus a flat, sortable, filterable table. Filters cover instance, `AlarmKind` (Computed / NativeOpcUa / NativeMxAccess), state, acked/unacked, severity threshold, and name search. - **Read-only** — there are no ack / shelve / suppress controls (native alarms remain read-only by design). -- **Refresh** — manual refresh button plus the 15s poll timer (mirroring the Health dashboard), now retained as a **fallback + `NotReporting` authority** behind the live cache: when the cache reports `IsLive`, the page renders live-cache state; when a stream is unhealthy or a site has not yet seeded, the poll keeps the page fresh so a stream failure never blanks it. (Aggregated live stream **delivered 2026-07-10** — see `docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`.) +- **Refresh** — manual refresh button plus the 15s poll timer (mirroring the Health dashboard), now retained as a **fallback + `NotReporting` authority** behind the live cache: when the cache reports `IsLive`, the page renders live-cache state; when a stream is unhealthy or a site has not yet seeded, the poll keeps the page fresh so a stream failure never blanks it. When live, the poll updates only the `NotReporting` list and leaves the row set to the delta path, so a slow fan-out can never momentarily revert a fresher live delta (R2 N5). (Aggregated live stream **delivered 2026-07-10** — see `docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`.) - **Reuse** — the alarm badge/formatter markup is factored out of Debug View into a shared `AlarmStateBadges` component consumed by both Debug View and this page. ### Parked Message Management (Deployment Role) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index b3987202..2d683e37 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -298,9 +298,20 @@ { return; } - _rows = result.Alarms; + + // _notReporting is the poll's unique authority — the alarm-only live cache + // cannot compute it — so it is always refreshed. _notReporting = result.NotReportingInstances; - _rollup = AlarmSummaryService.ComputeRollup(_rows); + + // 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 @@ -348,10 +359,11 @@ // 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) still runs untouched: it is the authority for - // _notReporting and the safety net when the cache is not live (pre-seed or a - // degraded/failed stream — IsLive == false). When live, the poll simply - // re-affirms the same snapshot; the live path just makes updates arrive sooner. + // • 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs index 5d253e0b..03fc9679 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs @@ -244,6 +244,40 @@ public class AlarmSummaryRenderTests : BunitContext }); } + // ── arch-review R2 N5: while live, the poll updates only _notReporting ────── + + [Fact] + public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows() + { + // Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance. + _summary.GetSiteAlarmsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => Task.FromResult(new AlarmSummaryResult( + new List + { + new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)), + }, + new[] { "OfflineInstance" }))); + + var cut = RenderWithSiteSelected(); + + // Go live with a fresher delta: Gamma only (arch-review R2 N5). + _liveCache.PushAlarms(new List + { + new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), + }); + cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut))); + + // A poll now completes: it owns _notReporting but must NOT rebuild the rows. + cut.Find("[data-test='alarm-summary-refresh']").Click(); + + cut.WaitForAssertion(() => + { + Assert.Contains("OfflineInstance", cut.Markup); // notReporting refreshed + Assert.Equal("Gamma", FirstRowInstance(cut)); // live rows NOT regressed + Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']")); + }); + } + /// /// Controllable in-memory fake of : records /// subscribe/dispose counts and lets a test push a live snapshot (which flips