Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs
T

206 lines
8.7 KiB
C#

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;
/// <summary>
/// Default <see cref="IAlarmSummaryService"/> implementation. Resolves
/// the site's Enabled instances, fans out one debug-snapshot fetch per instance
/// through the injected <see cref="IInstanceSnapshotClient"/> (capped at eight
/// concurrent fetches), and flattens every snapshot's alarm states into rows.
/// </summary>
/// <remarks>
/// Best-effort by design: a per-instance fetch that throws, is cancelled by its
/// own timeout, or reports <see cref="DebugViewSnapshot.InstanceNotFound"/> adds
/// the instance to <see cref="AlarmSummaryResult.NotReportingInstances"/> instead
/// of failing the whole call — an operator with one unreachable site still sees
/// every other instance's alarms. Caller cancellation
/// (<see cref="OperationCanceledException"/> on the supplied token) propagates.
/// </remarks>
public sealed class AlarmSummaryService : IAlarmSummaryService
{
/// <summary>Max concurrent per-instance snapshot fetches.</summary>
private const int MaxConcurrentFetches = 8;
private readonly ITemplateEngineRepository _instanceRepo;
private readonly ISiteRepository _siteRepo;
private readonly IInstanceSnapshotClient _snapshotClient;
/// <summary>
/// Initializes a new instance of the <see cref="AlarmSummaryService"/> class.
/// </summary>
/// <param name="instanceRepo">Repository used to enumerate the site's instances.</param>
/// <param name="siteRepo">Repository used to resolve the site identifier string.</param>
/// <param name="snapshotClient">Single-shot per-instance snapshot client.</param>
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));
}
/// <inheritdoc/>
public async Task<AlarmSummaryResult> GetSiteAlarmsAsync(
int siteId, CancellationToken cancellationToken = default)
{
var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken);
if (site is null)
{
return new AlarmSummaryResult(Array.Empty<AlarmSummaryRow>(), Array.Empty<string>());
}
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<AlarmSummaryRow>(), Array.Empty<string>());
}
var rows = new ConcurrentBag<AlarmSummaryRow>();
var notReporting = new ConcurrentBag<string>();
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<AlarmSummaryRow> rows,
ConcurrentBag<string> 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();
}
}
/// <inheritdoc/>
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> alarms) =>
BuildFromLiveAlarmsCore(alarms);
/// <inheritdoc/>
public AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows) => ComputeRollupCore(rows);
/// <summary>
/// Pure flattening of a live-cache snapshot into summary rows. Shared with
/// <see cref="SharedAlarmSummaryService"/>, which memoizes the fan-out but must produce
/// byte-identical rows on the live path — one implementation, no drift.
/// </summary>
/// <param name="alarms">The live-cache alarm snapshot.</param>
/// <returns>The flattened, deterministically ordered rows (never any not-reporting names).</returns>
internal static AlarmSummaryResult BuildFromLiveAlarmsCore(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();
// 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<string>());
}
/// <summary>
/// Pure roll-up over already-flattened rows. Shared with
/// <see cref="SharedAlarmSummaryService"/> for the same one-implementation reason as
/// <see cref="BuildFromLiveAlarmsCore"/>.
/// </summary>
/// <param name="rows">The rows to roll up.</param>
/// <returns>The active/worst-severity/unacked/by-kind roll-up.</returns>
internal static AlarmRollup ComputeRollupCore(IReadOnlyList<AlarmSummaryRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var totalActive = 0;
var worstSeverity = 0;
var unackedCount = 0;
var countsByKind = new Dictionary<AlarmKind, int>();
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);
}
}