fix(ui): Alarm Summary poll defers row rebuilds to the live path while IsLive (plan R2-07 T8)
This commit is contained in:
@@ -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.
|
- **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.
|
- **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).
|
- **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.
|
- **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)
|
### Parked Message Management (Deployment Role)
|
||||||
|
|||||||
@@ -298,9 +298,20 @@
|
|||||||
{
|
{
|
||||||
return;
|
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;
|
_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();
|
RecomputeVisibleRows();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -348,10 +359,11 @@
|
|||||||
// aggregated alarm set changes. We rebuild _rows/_rollup/_visibleRows from the
|
// aggregated alarm set changes. We rebuild _rows/_rollup/_visibleRows from the
|
||||||
// immutable live snapshot — but deliberately DO NOT touch _notReporting, since
|
// immutable live snapshot — but deliberately DO NOT touch _notReporting, since
|
||||||
// the alarm-only live cache can't compute it.
|
// the alarm-only live cache can't compute it.
|
||||||
// • The 15s poll (RefreshAsync) still runs untouched: it is the authority for
|
// • The 15s poll (RefreshAsync) is the authority for _notReporting and the
|
||||||
// _notReporting and the safety net when the cache is not live (pre-seed or a
|
// full-rebuild safety net ONLY while the cache is not live (pre-seed or a
|
||||||
// degraded/failed stream — IsLive == false). When live, the poll simply
|
// degraded/failed stream — IsLive == false); when live it deliberately leaves
|
||||||
// re-affirms the same snapshot; the live path just makes updates arrive sooner.
|
// _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
|
// • Both paths mutate shared state only via the Blazor dispatcher (the poll via
|
||||||
// its InvokeAsync callback, the live delta via OnLiveChanged's InvokeAsync), so
|
// its InvokeAsync callback, the live delta via OnLiveChanged's InvokeAsync), so
|
||||||
// they are serialized and never race. Each rebuild is an idempotent snapshot, so
|
// they are serialized and never race. Each rebuild is an idempotent snapshot, so
|
||||||
|
|||||||
@@ -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<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
|
||||||
|
new List<AlarmSummaryRow>
|
||||||
|
{
|
||||||
|
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<AlarmStateChanged>
|
||||||
|
{
|
||||||
|
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']"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
|
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
|
||||||
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
|
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
|
||||||
|
|||||||
Reference in New Issue
Block a user