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 1ab17c31..536bdf1c 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 @@ -5,9 +5,11 @@ @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
@@ -259,13 +261,23 @@ _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() @@ -322,6 +334,64 @@ _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) 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. + // • 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; + } + + // Raised on the aggregator's thread — marshal onto the circuit before touching state. + private void OnLiveAlarmsChanged(int siteId) + { + _ = 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 (_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); @@ -434,5 +504,9 @@ _ => "Computed" }; - public void Dispose() => StopTimer(); + public void Dispose() + { + StopTimer(); + DisposeLiveSubscription(); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs index 9d2af872..0e2bcc9d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs @@ -127,6 +127,26 @@ public sealed class AlarmSummaryService : IAlarmSummaryService } } + /// + public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) + { + ArgumentNullException.ThrowIfNull(alarms); + + // Flatten one row per alarm (instance from the alarm itself, mirroring + // FetchInstanceAsync) and apply the SAME deterministic instance-then-name + // sort as GetSiteAlarmsAsync so the live and poll paths render identically. + var orderedRows = alarms + .Select(alarm => new AlarmSummaryRow(alarm.InstanceUniqueName, alarm)) + .OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // The live cache is alarm-only, so it cannot enumerate "not reporting" + // instances — left empty here; the periodic poll (GetSiteAlarmsAsync) + // remains the authority for that list. + return new AlarmSummaryResult(orderedRows, Array.Empty()); + } + /// public AlarmRollup ComputeRollup(IReadOnlyList rows) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs index 4719a88c..9ffe92ea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs @@ -42,6 +42,28 @@ public interface IAlarmSummaryService /// Task GetSiteAlarmsAsync(int siteId, CancellationToken cancellationToken = default); + /// + /// Builds an directly from an in-memory live + /// snapshot (plan #10, Task 5) — the current alarms served by + /// + /// — instead of fanning out per-instance snapshot Asks. Each + /// is flattened to one + /// (instance taken from + /// ) and ordered with the SAME + /// deterministic instance-then-alarm-name sort as + /// , so the two paths are interchangeable. + /// + /// + /// The live cache carries only alarm state, so it cannot know which Enabled + /// instances are silent-but-reporting versus not reporting at all; + /// is therefore always + /// empty on this path. The page keeps its periodic + /// poll as the authority for "not reporting" (and as a live-stream safety net). + /// + /// The current live alarm snapshot for a site. + /// An whose is empty. + AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms); + /// /// Pure roll-up over a set of s. Exposed so the /// page (and tests) can recompute the headline tiles without re-querying. 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 3a93a100..51cfefc6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Security; using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary; @@ -26,6 +27,7 @@ public class AlarmSummaryRenderTests : BunitContext { private readonly IAlarmSummaryService _summary = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly FakeSiteAlarmLiveCache _liveCache = new(); public AlarmSummaryRenderTests() { @@ -39,14 +41,33 @@ public class AlarmSummaryRenderTests : BunitContext _summary.GetSiteAlarmsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty()))); + // BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way + // the real service does, so the live-path render tests exercise real behavior. + _summary.BuildFromLiveAlarms(Arg.Any>()) + .Returns(ci => + { + var alarms = ci.Arg>(); + var built = alarms + .Select(a => new AlarmSummaryRow(a.InstanceUniqueName, a)) + .OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase) + .ToList(); + return new AlarmSummaryResult(built, Array.Empty()); + }); _summary.ComputeRollup(Arg.Any>()) .Returns(new AlarmRollup(2, 900, 0, new Dictionary())); Services.AddSingleton(_summary); _siteRepo.GetAllSitesAsync(Arg.Any()) - .Returns(Task.FromResult>(new List { new("Site 1", "site1") { Id = 1 } })); + .Returns(Task.FromResult>(new List + { + new("Site 1", "site1") { Id = 1 }, + new("Site 2", "site2") { Id = 2 }, + })); Services.AddSingleton(_siteRepo); + Services.AddSingleton(_liveCache); + var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), @@ -109,4 +130,133 @@ public class AlarmSummaryRenderTests : BunitContext private static string FirstRowInstance(IRenderedComponent cut) => cut.FindAll("tr[data-test='alarm-summary-row']")[0] .QuerySelector("td")!.TextContent.Trim(); + + // ── plan #10 Task 5: live-cache-first with poll fallback ─────────────────── + + [Fact] + public void SelectingSite_SubscribesToTheLiveCache() + { + RenderWithSiteSelected(); + + Assert.Equal(1, _liveCache.SubscribeCount); + Assert.Equal(1, _liveCache.LastSubscribedSite); + } + + [Fact] + public void PollFallbackRenders_WhenCacheNotLiveYet() + { + // Cache starts not-live, so the site select falls back to the poll's + // GetSiteAlarmsAsync snapshot (Zeta + Alpha). + Assert.False(_liveCache.IsLive(1)); + + var cut = RenderWithSiteSelected(); + + Assert.Equal(2, cut.FindAll("tr[data-test='alarm-summary-row']").Count); + Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default + } + + [Fact] + public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot() + { + var cut = RenderWithSiteSelected(); + + // Push a fresh live snapshot: a single new alarm on a new instance. + _liveCache.PushAlarms(new List + { + new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), + }); + + cut.WaitForAssertion(() => + { + var rows = cut.FindAll("tr[data-test='alarm-summary-row']"); + Assert.Single(rows); + Assert.Equal("Gamma", FirstRowInstance(cut)); + }); + } + + [Fact] + public void LeavingSite_UnsubscribesFromTheLiveCache() + { + var cut = RenderWithSiteSelected(); + Assert.Equal(0, _liveCache.DisposeCount); + + // Clearing the site picker tears the subscription down. + cut.Find("[data-test='alarm-summary-site']").Change(""); + + Assert.Equal(1, _liveCache.DisposeCount); + } + + [Fact] + public void ChangingSite_ResubscribesToTheNewSite() + { + var cut = RenderWithSiteSelected(); // site 1 + + cut.Find("[data-test='alarm-summary-site']").Change("2"); + + Assert.Equal(2, _liveCache.SubscribeCount); // one per site + Assert.Equal(1, _liveCache.DisposeCount); // old subscription disposed + Assert.Equal(2, _liveCache.LastSubscribedSite); + } + + [Fact] + public void DisposingComponent_UnsubscribesFromTheLiveCache() + { + var cut = RenderWithSiteSelected(); + + cut.Instance.Dispose(); + + Assert.True(_liveCache.DisposeCount >= 1); + } + + /// + /// Controllable in-memory fake of : records + /// subscribe/dispose counts and lets a test push a live snapshot (which flips + /// true and fires the stored onChanged), so the page's + /// live-delta path can be driven deterministically. + /// + private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache + { + private Action? _onChanged; + private IReadOnlyList _current = Array.Empty(); + private bool _live; + + public int SubscribeCount { get; private set; } + public int DisposeCount { get; private set; } + public int? LastSubscribedSite { get; private set; } + + public IDisposable Subscribe(int siteId, Action onChanged) + { + SubscribeCount++; + LastSubscribedSite = siteId; + _onChanged = onChanged; + return new Subscription(this); + } + + public IReadOnlyList GetCurrentAlarms(int siteId) => _current; + + public bool IsLive(int siteId) => _live; + + public void PushAlarms(IReadOnlyList alarms) + { + _current = alarms; + _live = true; + _onChanged?.Invoke(); + } + + private sealed class Subscription : IDisposable + { + private readonly FakeSiteAlarmLiveCache _owner; + private bool _disposed; + + public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _owner.DisposeCount++; + _owner._onChanged = null; + } + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs index 6afe3a32..56dd6f6d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs @@ -167,4 +167,40 @@ public class AlarmSummaryServiceTests Assert.Equal(0, rollup.WorstSeverity); Assert.Equal(0, rollup.UnackedCount); } + + // ── BuildFromLiveAlarms (plan #10, Task 5) ──────────────────────────────── + + [Fact] + public void BuildFromLiveAlarms_FlattensAndSorts_SameAsPollPath_WithEmptyNotReporting() + { + // Intentionally out-of-order input to prove the deterministic + // instance-then-alarm-name sort matches GetSiteAlarmsAsync. + var alarms = new List + { + NativeAlarm("zeta", "Zulu", AlarmState.Active, 900, active: true, acked: false), + NativeAlarm("alpha", "Bravo", AlarmState.Active, 500, active: true, acked: true), + NativeAlarm("alpha", "Alpha", AlarmState.Active, 700, active: true, acked: false), + }; + + var result = CreateSut().BuildFromLiveAlarms(alarms); + + // One row per alarm, instance taken from the alarm itself. + Assert.Equal(3, result.Alarms.Count); + Assert.Collection(result.Alarms, + r => Assert.Equal(("alpha", "Alpha"), (r.InstanceUniqueName, r.Alarm.AlarmName)), + r => Assert.Equal(("alpha", "Bravo"), (r.InstanceUniqueName, r.Alarm.AlarmName)), + r => Assert.Equal(("zeta", "Zulu"), (r.InstanceUniqueName, r.Alarm.AlarmName))); + + // The live path can't compute "not reporting" — always empty. + Assert.Empty(result.NotReportingInstances); + } + + [Fact] + public void BuildFromLiveAlarms_EmptySnapshot_YieldsNoRows() + { + var result = CreateSut().BuildFromLiveAlarms(Array.Empty()); + + Assert.Empty(result.Alarms); + Assert.Empty(result.NotReportingInstances); + } }