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. 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 AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) => BuildFromLiveAlarmsCore(alarms); /// public AlarmRollup ComputeRollup(IReadOnlyList rows) => ComputeRollupCore(rows); /// /// Pure flattening of a live-cache snapshot into summary rows. Shared with /// , which memoizes the fan-out but must produce /// byte-identical rows on the live path — one implementation, no drift. /// /// The live-cache alarm snapshot. /// The flattened, deterministically ordered rows (never any not-reporting names). internal static AlarmSummaryResult BuildFromLiveAlarmsCore(IReadOnlyList 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(); // Alarm rows alone cannot name the instances that failed to answer, so this pure // flattening leaves the list empty. The names come from the fan-out that produced the // snapshot: while the live cache is serving a site, SharedAlarmSummaryService pairs // these rows with ISiteAlarmLiveCache.GetNotReportingInstances (the aggregator's own // seed/reconcile result); while it is cold, GetSiteAlarmsAsync's poll supplies both. return new AlarmSummaryResult(orderedRows, Array.Empty()); } /// /// Pure roll-up over already-flattened rows. Shared with /// for the same one-implementation reason as /// . /// /// The rows to roll up. /// The active/worst-severity/unacked/by-kind roll-up. internal static AlarmRollup ComputeRollupCore(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); } }