Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs
T
Joseph Doherty b91ed3c840 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
2026-07-10 12:25:36 -04:00

109 lines
5.5 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Read-only operator service that aggregates the current alarm picture across
/// all Enabled instances of a single site (Operator Alarm Summary).
/// </summary>
/// <remarks>
/// <para>
/// There is no central alarm store. The summary is assembled at query time by
/// fanning out one <see cref="DebugViewSnapshot"/> request per Enabled instance
/// (via the injected <see cref="IInstanceSnapshotClient"/>, which delegates to
/// the existing single-shot
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.RequestDebugSnapshotAsync"/>
/// Ask) and flattening every snapshot's <see cref="DebugViewSnapshot.AlarmStates"/>
/// into <see cref="AlarmSummaryRow"/>s. The fan-out is best-effort: an instance
/// whose snapshot fetch throws, times out, or reports
/// <see cref="DebugViewSnapshot.InstanceNotFound"/> is recorded in
/// <see cref="AlarmSummaryResult.NotReportingInstances"/> and never aborts the
/// whole call.
/// </para>
/// <para>
/// The page is read-only — there are no ack / shelve / write operations. All
/// filtering and roll-up math happens client-side from the returned rows.
/// </para>
/// </remarks>
public interface IAlarmSummaryService
{
/// <summary>
/// Fetches and aggregates the current alarms across every Enabled instance of
/// the given site.
/// </summary>
/// <param name="siteId">The site primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// An <see cref="AlarmSummaryResult"/> with one <see cref="AlarmSummaryRow"/>
/// per active/mirrored alarm condition plus the unique names of any instances
/// whose snapshot could not be obtained.
/// </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.
/// </summary>
/// <param name="rows">The alarm rows to summarize.</param>
/// <returns>The aggregated roll-up.</returns>
AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows);
}
/// <summary>The result of a site alarm-summary query.</summary>
/// <param name="Alarms">One row per alarm condition reported across the site's Enabled instances.</param>
/// <param name="NotReportingInstances">
/// Unique names of Enabled instances whose snapshot could not be obtained
/// (fetch threw, timed out, or returned <see cref="DebugViewSnapshot.InstanceNotFound"/>).
/// </param>
public sealed record AlarmSummaryResult(
IReadOnlyList<AlarmSummaryRow> Alarms,
IReadOnlyList<string> NotReportingInstances);
/// <summary>
/// One alarm condition paired with the instance it belongs to. The
/// <see cref="AlarmStateChanged"/> carries everything the
/// <c>AlarmStateBadges</c> component needs to render.
/// </summary>
/// <param name="InstanceUniqueName">Unique name of the owning instance.</param>
/// <param name="Alarm">The alarm condition (state / kind / severity / level / native sub-state).</param>
public sealed record AlarmSummaryRow(
string InstanceUniqueName,
AlarmStateChanged Alarm);
/// <summary>
/// Pure point-in-time roll-up over a set of <see cref="AlarmSummaryRow"/>s.
/// </summary>
/// <param name="TotalActive">Count of rows whose <see cref="AlarmStateChanged.State"/> is <see cref="AlarmState.Active"/>.</param>
/// <param name="WorstSeverity">Highest <see cref="Commons.Types.Alarms.AlarmConditionState.Severity"/> among active rows; 0 when none active.</param>
/// <param name="UnackedCount">Active, unacknowledged native conditions (<c>Kind != Computed</c>).</param>
/// <param name="CountsByKind">Per-<see cref="AlarmKind"/> row counts (only kinds with at least one row appear).</param>
public sealed record AlarmRollup(
int TotalActive,
int WorstSeverity,
int UnackedCount,
IReadOnlyDictionary<AlarmKind, int> CountsByKind);