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:
@@ -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.
|
||||
|
||||
@@ -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<IAlarmSummaryService>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly FakeSiteAlarmLiveCache _liveCache = new();
|
||||
|
||||
public AlarmSummaryRenderTests()
|
||||
{
|
||||
@@ -39,14 +41,33 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
|
||||
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
|
||||
// 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<IReadOnlyList<AlarmStateChanged>>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var alarms = ci.Arg<IReadOnlyList<AlarmStateChanged>>();
|
||||
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<string>());
|
||||
});
|
||||
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
|
||||
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
|
||||
Services.AddSingleton(_summary);
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { new("Site 1", "site1") { Id = 1 } }));
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Site 1", "site1") { Id = 1 },
|
||||
new("Site 2", "site2") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(_siteRepo);
|
||||
|
||||
Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
@@ -109,4 +130,133 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> 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<AlarmStateChanged>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
|
||||
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
|
||||
/// <see cref="IsLive"/> true and fires the stored onChanged), so the page's
|
||||
/// live-delta path can be driven deterministically.
|
||||
/// </summary>
|
||||
private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache
|
||||
{
|
||||
private Action? _onChanged;
|
||||
private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
|
||||
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<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;
|
||||
|
||||
public bool IsLive(int siteId) => _live;
|
||||
|
||||
public void PushAlarms(IReadOnlyList<AlarmStateChanged> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlarmStateChanged>
|
||||
{
|
||||
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<AlarmStateChanged>());
|
||||
|
||||
Assert.Empty(result.Alarms);
|
||||
Assert.Empty(result.NotReportingInstances);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user