From 3c9122bc07966f9cc923c5c0b286b386ce454fa4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 02:21:41 -0400 Subject: [PATCH] feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13) --- .../Components/Layout/NavMenu.razor | 1 + .../Pages/Monitoring/AlarmSummary.razor | 383 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 8 + .../Services/AlarmSummaryService.cs | 164 ++++++++ .../CommunicationInstanceSnapshotClient.cs | 36 ++ .../Services/IAlarmSummaryService.cs | 86 ++++ .../Services/IInstanceSnapshotClient.cs | 24 ++ .../Services/AlarmSummaryServiceTests.cs | 170 ++++++++ 8 files changed, 872 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CommunicationInstanceSnapshotClient.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IInstanceSnapshotClient.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor index f8abf82b..4bd723a4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor @@ -91,6 +91,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor new file mode 100644 index 00000000..2619dbc4 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -0,0 +1,383 @@ +@page "/monitoring/alarms" +@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)] +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites +@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums +@implements IDisposable +@inject IAlarmSummaryService AlarmSummaryService +@inject ISiteRepository SiteRepository + +
+
+

Alarm Summary

+
+ @if (_selectedSiteId != null) + { + Auto-refresh: @(_autoRefreshSeconds)s + } + +
+
+ + @* ── Site picker ── *@ +
+
+
+
+ + +
+
+
+
+ + @if (_selectedSiteId == null) + { +
Select a site to view its current alarms.
+ } + else + { + @* ── Roll-up tiles ── *@ +
+
+
+
+

@_rollup.TotalActive

+ Active Alarms +
+
+
+
+
+
+

@_rollup.WorstSeverity

+ Worst Severity +
+
+
+
+
+
+

@_rollup.UnackedCount

+ Unacknowledged +
+
+
+
+
+
+

@_rows.Count

+ + Total Rows + @if (_rollup.CountsByKind.Count > 0) + { + · + @string.Join(" / ", _rollup.CountsByKind + .OrderBy(kv => kv.Key) + .Select(kv => $"{KindLabel(kv.Key)} {kv.Value}")) + + } + +
+
+
+
+ + @if (_notReporting.Count > 0) + { +
+ Not reporting (@_notReporting.Count): @string.Join(", ", _notReporting) +
+ } + + @* ── Filters ── *@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + @* ── Alarm table ── *@ + @if (_rows.Count == 0) + { +
No alarms reported across this site's enabled instances.
+ } + else + { + var filtered = FilteredRows().ToList(); +
Showing @filtered.Count of @_rows.Count
+
+ + + + + + + + + + + @foreach (var row in filtered) + { + + + + + + + } + +
Instance @SortGlyph("instance")Alarm @SortGlyph("name")State / KindSeverity @SortGlyph("severity")
@row.InstanceUniqueName@row.Alarm.AlarmName@row.Alarm.Condition.Severity
+
+ } + } +
+ +@code { + private IReadOnlyList _sites = Array.Empty(); + private int? _selectedSiteId; + + private IReadOnlyList _rows = Array.Empty(); + private IReadOnlyList _notReporting = Array.Empty(); + private AlarmRollup _rollup = new(0, 0, 0, new Dictionary()); + private bool _loading; + + private Timer? _refreshTimer; + private const int _autoRefreshSeconds = 15; + + // ── Client-side filters ── + private string _filterInstance = ""; + private string _filterKind = ""; + private string _filterState = ""; + private string _filterAck = ""; + private int? _filterMinSeverity; + private string _filterName = ""; + + // ── Sort ── + private string _sortKey = "severity"; + private bool _sortDescending = true; + + protected override async Task OnInitializedAsync() + { + try + { + _sites = await SiteRepository.GetAllSitesAsync(); + } + catch + { + // Non-fatal — the picker simply shows no sites. + } + } + + private async Task OnSiteChangedAsync(ChangeEventArgs e) + { + var raw = e.Value?.ToString(); + if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var siteId)) + { + _selectedSiteId = null; + _rows = Array.Empty(); + _notReporting = Array.Empty(); + _rollup = new AlarmRollup(0, 0, 0, new Dictionary()); + StopTimer(); + return; + } + + _selectedSiteId = siteId; + ClearFilters(); + await RefreshAsync(); + StartTimer(); + } + + private async Task RefreshAsync() + { + if (_selectedSiteId is not int siteId) + { + return; + } + + _loading = true; + try + { + var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId); + _rows = result.Alarms; + _notReporting = result.NotReportingInstances; + _rollup = AlarmSummaryService.ComputeRollup(_rows); + } + catch + { + // Best-effort: a transient fault leaves the prior snapshot on screen + // rather than blanking the page; the next poll / manual refresh retries. + } + finally + { + _loading = false; + } + } + + private void StartTimer() + { + StopTimer(); + _refreshTimer = new Timer(_ => + { + InvokeAsync(async () => + { + await RefreshAsync(); + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); + } + + private void StopTimer() + { + _refreshTimer?.Dispose(); + _refreshTimer = null; + } + + private IEnumerable DistinctInstances => + _rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase); + + private IEnumerable FilteredRows() + { + IEnumerable q = _rows; + + if (!string.IsNullOrEmpty(_filterInstance)) + { + q = q.Where(r => r.InstanceUniqueName == _filterInstance); + } + if (Enum.TryParse(_filterKind, out var kind)) + { + q = q.Where(r => r.Alarm.Kind == kind); + } + if (Enum.TryParse(_filterState, out var state)) + { + q = q.Where(r => r.Alarm.State == state); + } + if (_filterAck == "unacked") + { + q = q.Where(r => r.Alarm.Condition.Active && !r.Alarm.Condition.Acknowledged && r.Alarm.Kind != AlarmKind.Computed); + } + else if (_filterAck == "acked") + { + q = q.Where(r => r.Alarm.Condition.Acknowledged); + } + if (_filterMinSeverity is int min) + { + q = q.Where(r => r.Alarm.Condition.Severity >= min); + } + if (!string.IsNullOrWhiteSpace(_filterName)) + { + q = q.Where(r => r.Alarm.AlarmName.Contains(_filterName, StringComparison.OrdinalIgnoreCase)); + } + + return SortRows(q); + } + + private IEnumerable SortRows(IEnumerable rows) + { + Func key = _sortKey switch + { + "instance" => r => r.InstanceUniqueName, + "name" => r => r.Alarm.AlarmName, + _ => r => r.Alarm.Condition.Severity, + }; + return _sortDescending ? rows.OrderByDescending(key) : rows.OrderBy(key); + } + + private void SortBy(string key) + { + if (_sortKey == key) + { + _sortDescending = !_sortDescending; + } + else + { + _sortKey = key; + _sortDescending = key == "severity"; + } + } + + private string SortGlyph(string key) => + _sortKey != key ? "" : (_sortDescending ? "▼" : "▲"); + + private void ClearFilters() + { + _filterInstance = ""; + _filterKind = ""; + _filterState = ""; + _filterAck = ""; + _filterMinSeverity = null; + _filterName = ""; + } + + private static string KindLabel(AlarmKind kind) => kind switch + { + AlarmKind.NativeOpcUa => "OPC UA", + AlarmKind.NativeMxAccess => "MxAccess", + _ => "Computed" + }; + + public void Dispose() => StopTimer(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 4731c285..fd018889 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -78,6 +78,14 @@ public static class ServiceCollectionExtensions // connection). services.AddScoped(); + // Operator Alarm Summary (M7 T13): read-only page that aggregates the + // current alarms across a site's Enabled instances. The service fans out + // one debug snapshot per instance via IInstanceSnapshotClient — a thin + // facade over CommunicationService.RequestDebugSnapshotAsync (the same + // single-shot Ask the Debug View uses) — and flattens the alarm states. + services.AddScoped(); + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs new file mode 100644 index 00000000..c4d504c5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs @@ -0,0 +1,164 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +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; + +/// +/// Default implementation (M7 T13). Resolves +/// the site's Enabled instances, fans out one debug-snapshot fetch per instance +/// through the injected (capped at eight +/// concurrent fetches), and flattens every snapshot's alarm states into rows. +/// +/// +/// Best-effort by design: a per-instance fetch that throws, is cancelled by its +/// own timeout, or reports adds +/// the instance to instead +/// of failing the whole call — an operator with one unreachable site still sees +/// every other instance's alarms. Caller cancellation +/// ( on the supplied token) propagates. +/// +public sealed class AlarmSummaryService : IAlarmSummaryService +{ + /// Max concurrent per-instance snapshot fetches. + private const int MaxConcurrentFetches = 8; + + private readonly ITemplateEngineRepository _instanceRepo; + private readonly ISiteRepository _siteRepo; + private readonly IInstanceSnapshotClient _snapshotClient; + + /// + /// Initializes a new instance of the class. + /// + /// Repository used to enumerate the site's instances. + /// Repository used to resolve the site identifier string. + /// Single-shot per-instance snapshot client. + public AlarmSummaryService( + ITemplateEngineRepository instanceRepo, + ISiteRepository siteRepo, + IInstanceSnapshotClient snapshotClient) + { + _instanceRepo = instanceRepo ?? throw new ArgumentNullException(nameof(instanceRepo)); + _siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo)); + _snapshotClient = snapshotClient ?? throw new ArgumentNullException(nameof(snapshotClient)); + } + + /// + public async Task GetSiteAlarmsAsync( + int siteId, CancellationToken cancellationToken = default) + { + var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken); + if (site is null) + { + return new AlarmSummaryResult(Array.Empty(), Array.Empty()); + } + + var instances = await _instanceRepo.GetInstancesBySiteIdAsync(siteId, cancellationToken); + var enabled = instances.Where(i => i.State == InstanceState.Enabled).ToList(); + if (enabled.Count == 0) + { + return new AlarmSummaryResult(Array.Empty(), Array.Empty()); + } + + var rows = new ConcurrentBag(); + var notReporting = new ConcurrentBag(); + + using var gate = new SemaphoreSlim(MaxConcurrentFetches, MaxConcurrentFetches); + + var fetches = enabled.Select(instance => FetchInstanceAsync( + site.SiteIdentifier, instance.UniqueName, gate, rows, notReporting, cancellationToken)); + await Task.WhenAll(fetches); + + // Deterministic ordering: instance name, then alarm name, so the page's + // initial render and any test assertions are stable before client sorts. + var orderedRows = rows + .OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase) + .ToList(); + var orderedNotReporting = notReporting + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AlarmSummaryResult(orderedRows, orderedNotReporting); + } + + private async Task FetchInstanceAsync( + string siteIdentifier, + string instanceUniqueName, + SemaphoreSlim gate, + ConcurrentBag rows, + ConcurrentBag notReporting, + CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken); + try + { + var snapshot = await _snapshotClient.GetSnapshotAsync( + siteIdentifier, instanceUniqueName, cancellationToken); + + if (snapshot.InstanceNotFound) + { + notReporting.Add(instanceUniqueName); + return; + } + + foreach (var alarm in snapshot.AlarmStates) + { + rows.Add(new AlarmSummaryRow(instanceUniqueName, alarm)); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Caller-initiated cancel — propagate so the page can drop the response. + throw; + } + catch + { + // Any other fault (per-instance timeout, transport error, the snapshot + // Ask throwing) degrades this one instance to "not reporting" rather + // than failing the whole summary. + notReporting.Add(instanceUniqueName); + } + finally + { + gate.Release(); + } + } + + /// + public AlarmRollup ComputeRollup(IReadOnlyList rows) + { + ArgumentNullException.ThrowIfNull(rows); + + var totalActive = 0; + var worstSeverity = 0; + var unackedCount = 0; + var countsByKind = new Dictionary(); + + foreach (var row in rows) + { + var alarm = row.Alarm; + countsByKind[alarm.Kind] = countsByKind.GetValueOrDefault(alarm.Kind) + 1; + + if (alarm.State == AlarmState.Active) + { + totalActive++; + if (alarm.Condition.Severity > worstSeverity) + { + worstSeverity = alarm.Condition.Severity; + } + } + + if (alarm.Condition.Active + && !alarm.Condition.Acknowledged + && alarm.Kind != AlarmKind.Computed) + { + unackedCount++; + } + } + + return new AlarmRollup(totalActive, worstSeverity, unackedCount, countsByKind); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CommunicationInstanceSnapshotClient.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CommunicationInstanceSnapshotClient.cs new file mode 100644 index 00000000..1e2bba51 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CommunicationInstanceSnapshotClient.cs @@ -0,0 +1,36 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Communication; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default — a thin facade over the +/// existing single-shot +/// Ask (the same +/// Deployer-gated snapshot path the CLI debug snapshot command and the +/// Debug View use). Each call issues one +/// with a fresh correlation id. +/// +public sealed class CommunicationInstanceSnapshotClient : IInstanceSnapshotClient +{ + private readonly CommunicationService _communication; + + /// + /// Initializes a new instance of the class. + /// + /// Central-side cluster communication service. + public CommunicationInstanceSnapshotClient(CommunicationService communication) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + } + + /// + public Task GetSnapshotAsync( + string siteIdentifier, + string instanceUniqueName, + CancellationToken cancellationToken = default) + { + var request = new DebugSnapshotRequest(instanceUniqueName, Guid.NewGuid().ToString("N")); + return _communication.RequestDebugSnapshotAsync(siteIdentifier, request, cancellationToken); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs new file mode 100644 index 00000000..2d7628e3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs @@ -0,0 +1,86 @@ +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; + +/// +/// Read-only operator service that aggregates the current alarm picture across +/// all Enabled instances of a single site (M7 T13 — Operator Alarm Summary). +/// +/// +/// +/// There is no central alarm store. The summary is assembled at query time by +/// fanning out one request per Enabled instance +/// (via the injected , which delegates to +/// the existing single-shot +/// +/// Ask) and flattening every snapshot's +/// into s. The fan-out is best-effort: an instance +/// whose snapshot fetch throws, times out, or reports +/// is recorded in +/// and never aborts the +/// whole call. +/// +/// +/// 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. +/// +/// +public interface IAlarmSummaryService +{ + /// + /// Fetches and aggregates the current alarms across every Enabled instance of + /// the given site. + /// + /// The site primary key. + /// Cancellation token. + /// + /// An with one + /// per active/mirrored alarm condition plus the unique names of any instances + /// whose snapshot could not be obtained. + /// + Task GetSiteAlarmsAsync(int siteId, CancellationToken cancellationToken = default); + + /// + /// Pure roll-up over a set of s. Exposed so the + /// page (and tests) can recompute the headline tiles without re-querying. + /// + /// The alarm rows to summarize. + /// The aggregated roll-up. + AlarmRollup ComputeRollup(IReadOnlyList rows); +} + +/// The result of a site alarm-summary query. +/// One row per alarm condition reported across the site's Enabled instances. +/// +/// Unique names of Enabled instances whose snapshot could not be obtained +/// (fetch threw, timed out, or returned ). +/// +public sealed record AlarmSummaryResult( + IReadOnlyList Alarms, + IReadOnlyList NotReportingInstances); + +/// +/// One alarm condition paired with the instance it belongs to. The +/// carries everything the +/// AlarmStateBadges component needs to render. +/// +/// Unique name of the owning instance. +/// The alarm condition (state / kind / severity / level / native sub-state). +public sealed record AlarmSummaryRow( + string InstanceUniqueName, + AlarmStateChanged Alarm); + +/// +/// Pure point-in-time roll-up over a set of s. +/// +/// Count of rows whose is . +/// Highest among active rows; 0 when none active. +/// Active, unacknowledged native conditions (Kind != Computed). +/// Per- row counts (only kinds with at least one row appear). +public sealed record AlarmRollup( + int TotalActive, + int WorstSeverity, + int UnackedCount, + IReadOnlyDictionary CountsByKind); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IInstanceSnapshotClient.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IInstanceSnapshotClient.cs new file mode 100644 index 00000000..b570b3bb --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IInstanceSnapshotClient.cs @@ -0,0 +1,24 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Single-shot per-instance debug-snapshot client. A thin seam over the existing +/// +/// Ask so can fan out snapshot fetches while +/// staying unit-testable (the implementation is substituted in tests). +/// +public interface IInstanceSnapshotClient +{ + /// + /// Requests one debug snapshot for the given instance on the given site. + /// + /// The site's SiteIdentifier string (not the numeric site id). + /// The instance's unique name. + /// Cancellation token. + /// The instance's current debug snapshot, including its alarm states. + Task GetSnapshotAsync( + string siteIdentifier, + string instanceUniqueName, + CancellationToken cancellationToken = default); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs new file mode 100644 index 00000000..6afe3a32 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs @@ -0,0 +1,170 @@ +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// Unit tests for (M7 T13 — Operator Alarm +/// Summary). The snapshot client and instance repository are substituted so the +/// fan-out + best-effort degradation + roll-up math are exercised without any +/// cluster traffic. +/// +public class AlarmSummaryServiceTests +{ + private const int SiteId = 7; + private const string SiteIdentifier = "plant-a"; + + private static readonly DateTimeOffset Now = + new(2026, 6, 18, 12, 0, 0, TimeSpan.Zero); + + private readonly ITemplateEngineRepository _instanceRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly IInstanceSnapshotClient _snapshotClient = Substitute.For(); + + private AlarmSummaryService CreateSut() => + new(_instanceRepo, _siteRepo, _snapshotClient); + + private static Instance Instance(int id, string uniqueName, InstanceState state) => + new(uniqueName) { Id = id, SiteId = SiteId, State = state }; + + private static Site Site() => + new("Plant A", SiteIdentifier) { Id = SiteId }; + + /// Builds a native-style alarm with explicit condition flags. + private static AlarmStateChanged NativeAlarm( + string instance, string name, AlarmState state, int severity, + bool active, bool acked, AlarmKind kind = AlarmKind.NativeOpcUa) => + new(instance, name, state, severity, Now) + { + Kind = kind, + Condition = new AlarmConditionState( + Active: active, Acknowledged: acked, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: severity), + }; + + private static AlarmStateChanged ComputedAlarm( + string instance, string name, AlarmState state, int priority) => + new(instance, name, state, priority, Now) { Kind = AlarmKind.Computed }; + + private static DebugViewSnapshot Snapshot(string instance, params AlarmStateChanged[] alarms) => + new(instance, Array.Empty(), alarms, Now); + + [Fact] + public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne() + { + _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any()).Returns(Site()); + + var enabledA = Instance(1, "inst-a", InstanceState.Enabled); + var enabledB = Instance(2, "inst-b", InstanceState.Enabled); + var enabledC = Instance(3, "inst-c", InstanceState.Enabled); + var disabled = Instance(4, "inst-d", InstanceState.Disabled); + var notDeployed = Instance(5, "inst-e", InstanceState.NotDeployed); + + _instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()) + .Returns(new List { enabledA, enabledB, enabledC, disabled, notDeployed }); + + // inst-a: 2 alarms (one active native sev 800 unacked, one computed normal) + _snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-a", Arg.Any()) + .Returns(Snapshot("inst-a", + NativeAlarm("inst-a", "TankHi", AlarmState.Active, 800, active: true, acked: false), + ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200))); + + // inst-b: 1 alarm (active native sev 500 acked) + _snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-b", Arg.Any()) + .Returns(Snapshot("inst-b", + NativeAlarm("inst-b", "ValveStuck", AlarmState.Active, 500, active: true, acked: true))); + + // inst-c: snapshot fetch throws → not-reporting + _snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-c", Arg.Any()) + .Returns(_ => throw new TimeoutException("site silent")); + + var sut = CreateSut(); + + var result = await sut.GetSiteAlarmsAsync(SiteId); + + // Aggregated alarm count = sum of the two reporting instances (2 + 1). + Assert.Equal(3, result.Alarms.Count); + Assert.Equal(2, result.Alarms.Count(r => r.InstanceUniqueName == "inst-a")); + Assert.Single(result.Alarms, r => r.InstanceUniqueName == "inst-b"); + + // The throwing instance is recorded, and only it. + Assert.Equal(new[] { "inst-c" }, result.NotReportingInstances); + + // Disabled / not-deployed instances are never fetched. + await _snapshotClient.DidNotReceive() + .GetSnapshotAsync(SiteIdentifier, "inst-d", Arg.Any()); + await _snapshotClient.DidNotReceive() + .GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any()); + } + + [Fact] + public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting() + { + _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any()).Returns(Site()); + _instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()) + .Returns(new List { Instance(1, "ghost", InstanceState.Enabled) }); + + _snapshotClient.GetSnapshotAsync(SiteIdentifier, "ghost", Arg.Any()) + .Returns(new DebugViewSnapshot( + "ghost", Array.Empty(), + Array.Empty(), Now, InstanceNotFound: true)); + + var result = await CreateSut().GetSiteAlarmsAsync(SiteId); + + Assert.Empty(result.Alarms); + Assert.Equal(new[] { "ghost" }, result.NotReportingInstances); + } + + [Fact] + public void ComputeRollup_ComputesWorstSeverityActiveAndUnackedAndKindCounts() + { + var rows = new List + { + // active native, sev 800, unacked → counts toward unacked + new("inst-a", NativeAlarm("inst-a", "TankHi", AlarmState.Active, 800, active: true, acked: false)), + // active native, sev 500, acked → not unacked + new("inst-b", NativeAlarm("inst-b", "ValveStuck", AlarmState.Active, 500, active: true, acked: true)), + // active mxaccess, sev 300, unacked → unacked + new("inst-b", NativeAlarm("inst-b", "MtrTrip", AlarmState.Active, 300, active: true, acked: false, kind: AlarmKind.NativeMxAccess)), + // computed normal (not active) → ignored by active/unacked, severity 200 not counted + new("inst-a", ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200)), + // computed active sev 999 — active, but computed so NOT unacked + new("inst-c", ComputedAlarm("inst-c", "HiHi", AlarmState.Active, 999)), + }; + + var rollup = CreateSut().ComputeRollup(rows); + + // Active = the 3 native actives + the active computed = 4 + Assert.Equal(4, rollup.TotalActive); + // Worst severity among active = 999 (the active computed) + Assert.Equal(999, rollup.WorstSeverity); + // Unacked = active && !acked && kind != Computed = TankHi + MtrTrip = 2 + Assert.Equal(2, rollup.UnackedCount); + // Kind counts: NativeOpcUa 2, NativeMxAccess 1, Computed 2 + Assert.Equal(2, rollup.CountsByKind[AlarmKind.NativeOpcUa]); + Assert.Equal(1, rollup.CountsByKind[AlarmKind.NativeMxAccess]); + Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]); + } + + [Fact] + public void ComputeRollup_NoActiveRows_WorstSeverityZero() + { + var rows = new List + { + new("inst-a", NativeAlarm("inst-a", "Cleared", AlarmState.Normal, 700, active: false, acked: true)), + }; + + var rollup = CreateSut().ComputeRollup(rows); + + Assert.Equal(0, rollup.TotalActive); + Assert.Equal(0, rollup.WorstSeverity); + Assert.Equal(0, rollup.UnackedCount); + } +}