feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAlarmSummaryService"/> implementation (M7 T13). 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 AlarmRollup ComputeRollup(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user