206 lines
8.7 KiB
C#
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);
|
|
}
|
|
}
|