@@ -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);
+ }
}