feat(alarm-summary): live-cache-driven updates with poll fallback (plan #10 T5)

Wire the operator Alarm Summary page to the transient per-site live alarm
cache (ISiteAlarmLiveCache, T4). Live-cache-first: on site select the page
subscribes and rebuilds rows/rollup from near-real-time onChanged deltas; the
15s poll is kept untouched as the authority for NotReporting and as the
safety net whenever the cache is not live (pre-seed or degraded stream). Both
paths mutate shared state only via the Blazor dispatcher, so they never race,
and each rebuild is an idempotent snapshot.

- IAlarmSummaryService.BuildFromLiveAlarms: flattens a live AlarmStateChanged
  snapshot to AlarmSummaryRows with the same deterministic instance-then-name
  sort as GetSiteAlarmsAsync; NotReporting always empty on the live path.
- AlarmSummary.razor: inject ISiteAlarmLiveCache; subscribe on select,
  re-subscribe on site change, unsubscribe on leave + Dispose.
- Tests: service BuildFromLiveAlarms flatten/sort/empty; page subscribe,
  poll-fallback render, onChanged rebuild, unsubscribe on leave/change/dispose.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 12:25:36 -04:00
parent 696a4ffea2
commit b91ed3c840
5 changed files with 304 additions and 2 deletions
@@ -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
<div class="container-fluid mt-3" data-test="alarm-summary">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -259,13 +261,23 @@
_notReporting = Array.Empty<string>();
_rollup = new AlarmRollup(0, 0, 0, new Dictionary<AlarmKind, int>());
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<string> 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();
}
}
@@ -127,6 +127,26 @@ public sealed class AlarmSummaryService : IAlarmSummaryService
}
}
/// <inheritdoc/>
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> 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<string>());
}
/// <inheritdoc/>
public AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows)
{
@@ -42,6 +42,28 @@ public interface IAlarmSummaryService
/// </returns>
Task<AlarmSummaryResult> GetSiteAlarmsAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>
/// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live
/// snapshot (plan #10, Task 5) — the current alarms served by
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/>
/// — instead of fanning out per-instance snapshot Asks. Each
/// <see cref="AlarmStateChanged"/> is flattened to one
/// <see cref="AlarmSummaryRow"/> (instance taken from
/// <see cref="AlarmStateChanged.InstanceUniqueName"/>) and ordered with the SAME
/// deterministic instance-then-alarm-name sort as
/// <see cref="GetSiteAlarmsAsync"/>, so the two paths are interchangeable.
/// </summary>
/// <remarks>
/// The live cache carries only alarm state, so it cannot know which Enabled
/// instances are silent-but-reporting versus not reporting at all;
/// <see cref="AlarmSummaryResult.NotReportingInstances"/> is therefore always
/// empty on this path. The page keeps its periodic <see cref="GetSiteAlarmsAsync"/>
/// poll as the authority for "not reporting" (and as a live-stream safety net).
/// </remarks>
/// <param name="alarms">The current live alarm snapshot for a site.</param>
/// <returns>An <see cref="AlarmSummaryResult"/> whose <see cref="AlarmSummaryResult.NotReportingInstances"/> is empty.</returns>
AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> alarms);
/// <summary>
/// Pure roll-up over a set of <see cref="AlarmSummaryRow"/>s. Exposed so the
/// page (and tests) can recompute the headline tiles without re-querying.