feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)
This commit is contained in:
@@ -91,6 +91,7 @@
|
||||
<NavRailItem Href="/monitoring/health" Text="Health Dashboard" />
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="monitoringContext">
|
||||
<NavRailItem Href="/monitoring/alarms" Text="Alarm Summary" />
|
||||
<NavRailItem Href="/monitoring/event-logs" Text="Event Logs" />
|
||||
<NavRailItem Href="/monitoring/parked-messages" Text="Parked Messages" />
|
||||
</Authorized>
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="container-fluid mt-3" data-test="alarm-summary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Alarm Summary</h4>
|
||||
<div class="d-flex align-items-center">
|
||||
@if (_selectedSiteId != null)
|
||||
{
|
||||
<span class="text-muted small me-2">Auto-refresh: @(_autoRefreshSeconds)s</span>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAsync"
|
||||
disabled="@(_selectedSiteId == null || _loading)" data-test="alarm-summary-refresh">
|
||||
@(_loading ? "Refreshing…" : "Refresh")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Site picker ── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-site">Site</label>
|
||||
<select id="as-site" class="form-select form-select-sm" style="min-width: 200px;"
|
||||
value="@(_selectedSiteId?.ToString() ?? "")" @onchange="OnSiteChangedAsync"
|
||||
data-test="alarm-summary-site">
|
||||
<option value="">Select site…</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_selectedSiteId == null)
|
||||
{
|
||||
<div class="alert alert-info" data-test="alarm-summary-empty">Select a site to view its current alarms.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Roll-up tiles ── *@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100 @(_rollup.TotalActive > 0 ? "border-danger" : "")">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @(_rollup.TotalActive > 0 ? "text-danger" : "")" data-test="rollup-active">@_rollup.TotalActive</h3>
|
||||
<small class="text-muted">Active Alarms</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0" data-test="rollup-severity">@_rollup.WorstSeverity</h3>
|
||||
<small class="text-muted">Worst Severity</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100 @(_rollup.UnackedCount > 0 ? "border-warning" : "")">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @(_rollup.UnackedCount > 0 ? "text-warning" : "")" data-test="rollup-unacked">@_rollup.UnackedCount</h3>
|
||||
<small class="text-muted">Unacknowledged</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0" data-test="rollup-total">@_rows.Count</h3>
|
||||
<small class="text-muted">
|
||||
Total Rows
|
||||
@if (_rollup.CountsByKind.Count > 0)
|
||||
{
|
||||
<span> ·
|
||||
@string.Join(" / ", _rollup.CountsByKind
|
||||
.OrderBy(kv => kv.Key)
|
||||
.Select(kv => $"{KindLabel(kv.Key)} {kv.Value}"))
|
||||
</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_notReporting.Count > 0)
|
||||
{
|
||||
<div class="text-muted small mb-2" data-test="alarm-summary-not-reporting">
|
||||
Not reporting (@_notReporting.Count): @string.Join(", ", _notReporting)
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Filters ── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-instance">Instance</label>
|
||||
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance">
|
||||
<option value="">All</option>
|
||||
@foreach (var name in DistinctInstances)
|
||||
{
|
||||
<option value="@name">@name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-kind">Kind</label>
|
||||
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind">
|
||||
<option value="">All</option>
|
||||
<option value="@AlarmKind.Computed">Computed</option>
|
||||
<option value="@AlarmKind.NativeOpcUa">OPC UA</option>
|
||||
<option value="@AlarmKind.NativeMxAccess">MxAccess</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-state">State</label>
|
||||
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState">
|
||||
<option value="">All</option>
|
||||
<option value="@AlarmState.Active">Active</option>
|
||||
<option value="@AlarmState.Normal">Normal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-ack">Ack</label>
|
||||
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck">
|
||||
<option value="">Any</option>
|
||||
<option value="unacked">Unacked</option>
|
||||
<option value="acked">Acked</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-sev">Min severity</label>
|
||||
<input id="as-f-sev" type="number" min="0" max="1000" step="50"
|
||||
class="form-control form-control-sm" style="width: 110px;"
|
||||
@bind="_filterMinSeverity" />
|
||||
</div>
|
||||
<div class="col-auto flex-grow-1">
|
||||
<label class="form-label small mb-1" for="as-f-name">Name search</label>
|
||||
<input id="as-f-name" type="text" class="form-control form-control-sm"
|
||||
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Alarm table ── *@
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-success" data-test="alarm-summary-no-alarms">No alarms reported across this site's enabled instances.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var filtered = FilteredRows().ToList();
|
||||
<div class="text-muted small mb-2">Showing @filtered.Count of @_rows.Count</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th role="button" @onclick='() => SortBy("instance")'>Instance @SortGlyph("instance")</th>
|
||||
<th role="button" @onclick='() => SortBy("name")'>Alarm @SortGlyph("name")</th>
|
||||
<th>State / Kind</th>
|
||||
<th role="button" class="text-end" @onclick='() => SortBy("severity")'>Severity @SortGlyph("severity")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in filtered)
|
||||
{
|
||||
<tr data-test="alarm-summary-row">
|
||||
<td class="font-monospace small">@row.InstanceUniqueName</td>
|
||||
<td>@row.Alarm.AlarmName</td>
|
||||
<td><AlarmStateBadges Alarm="row.Alarm" /></td>
|
||||
<td class="text-end font-monospace">@row.Alarm.Condition.Severity</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<Site> _sites = Array.Empty<Site>();
|
||||
private int? _selectedSiteId;
|
||||
|
||||
private IReadOnlyList<AlarmSummaryRow> _rows = Array.Empty<AlarmSummaryRow>();
|
||||
private IReadOnlyList<string> _notReporting = Array.Empty<string>();
|
||||
private AlarmRollup _rollup = new(0, 0, 0, new Dictionary<AlarmKind, int>());
|
||||
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<AlarmSummaryRow>();
|
||||
_notReporting = Array.Empty<string>();
|
||||
_rollup = new AlarmRollup(0, 0, 0, new Dictionary<AlarmKind, int>());
|
||||
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<string> DistinctInstances =>
|
||||
_rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private IEnumerable<AlarmSummaryRow> FilteredRows()
|
||||
{
|
||||
IEnumerable<AlarmSummaryRow> q = _rows;
|
||||
|
||||
if (!string.IsNullOrEmpty(_filterInstance))
|
||||
{
|
||||
q = q.Where(r => r.InstanceUniqueName == _filterInstance);
|
||||
}
|
||||
if (Enum.TryParse<AlarmKind>(_filterKind, out var kind))
|
||||
{
|
||||
q = q.Where(r => r.Alarm.Kind == kind);
|
||||
}
|
||||
if (Enum.TryParse<AlarmState>(_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<AlarmSummaryRow> SortRows(IEnumerable<AlarmSummaryRow> rows)
|
||||
{
|
||||
Func<AlarmSummaryRow, object> 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();
|
||||
}
|
||||
@@ -78,6 +78,14 @@ public static class ServiceCollectionExtensions
|
||||
// connection).
|
||||
services.AddScoped<IBindingTester, BindingTester>();
|
||||
|
||||
// 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<IInstanceSnapshotClient, CommunicationInstanceSnapshotClient>();
|
||||
services.AddScoped<IAlarmSummaryService, AlarmSummaryService>();
|
||||
|
||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IInstanceSnapshotClient"/> — a thin facade over the
|
||||
/// existing single-shot
|
||||
/// <see cref="CommunicationService.RequestDebugSnapshotAsync"/> Ask (the same
|
||||
/// Deployer-gated snapshot path the CLI <c>debug snapshot</c> command and the
|
||||
/// Debug View use). Each call issues one <see cref="DebugSnapshotRequest"/>
|
||||
/// with a fresh correlation id.
|
||||
/// </summary>
|
||||
public sealed class CommunicationInstanceSnapshotClient : IInstanceSnapshotClient
|
||||
{
|
||||
private readonly CommunicationService _communication;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CommunicationInstanceSnapshotClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side cluster communication service.</param>
|
||||
public CommunicationInstanceSnapshotClient(CommunicationService communication)
|
||||
{
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<DebugViewSnapshot> GetSnapshotAsync(
|
||||
string siteIdentifier,
|
||||
string instanceUniqueName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new DebugSnapshotRequest(instanceUniqueName, Guid.NewGuid().ToString("N"));
|
||||
return _communication.RequestDebugSnapshotAsync(siteIdentifier, request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only operator service that aggregates the current alarm picture across
|
||||
/// all Enabled instances of a single site (M7 T13 — Operator Alarm Summary).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// There is no central alarm store. The summary is assembled at query time by
|
||||
/// fanning out one <see cref="DebugViewSnapshot"/> request per Enabled instance
|
||||
/// (via the injected <see cref="IInstanceSnapshotClient"/>, which delegates to
|
||||
/// the existing single-shot
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.RequestDebugSnapshotAsync"/>
|
||||
/// Ask) and flattening every snapshot's <see cref="DebugViewSnapshot.AlarmStates"/>
|
||||
/// into <see cref="AlarmSummaryRow"/>s. The fan-out is best-effort: an instance
|
||||
/// whose snapshot fetch throws, times out, or reports
|
||||
/// <see cref="DebugViewSnapshot.InstanceNotFound"/> is recorded in
|
||||
/// <see cref="AlarmSummaryResult.NotReportingInstances"/> and never aborts the
|
||||
/// whole call.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAlarmSummaryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches and aggregates the current alarms across every Enabled instance of
|
||||
/// the given site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site primary key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="AlarmSummaryResult"/> with one <see cref="AlarmSummaryRow"/>
|
||||
/// per active/mirrored alarm condition plus the unique names of any instances
|
||||
/// whose snapshot could not be obtained.
|
||||
/// </returns>
|
||||
Task<AlarmSummaryResult> GetSiteAlarmsAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pure roll-up over a set of <see cref="AlarmSummaryRow"/>s. Exposed so the
|
||||
/// page (and tests) can recompute the headline tiles without re-querying.
|
||||
/// </summary>
|
||||
/// <param name="rows">The alarm rows to summarize.</param>
|
||||
/// <returns>The aggregated roll-up.</returns>
|
||||
AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows);
|
||||
}
|
||||
|
||||
/// <summary>The result of a site alarm-summary query.</summary>
|
||||
/// <param name="Alarms">One row per alarm condition reported across the site's Enabled instances.</param>
|
||||
/// <param name="NotReportingInstances">
|
||||
/// Unique names of Enabled instances whose snapshot could not be obtained
|
||||
/// (fetch threw, timed out, or returned <see cref="DebugViewSnapshot.InstanceNotFound"/>).
|
||||
/// </param>
|
||||
public sealed record AlarmSummaryResult(
|
||||
IReadOnlyList<AlarmSummaryRow> Alarms,
|
||||
IReadOnlyList<string> NotReportingInstances);
|
||||
|
||||
/// <summary>
|
||||
/// One alarm condition paired with the instance it belongs to. The
|
||||
/// <see cref="AlarmStateChanged"/> carries everything the
|
||||
/// <c>AlarmStateBadges</c> component needs to render.
|
||||
/// </summary>
|
||||
/// <param name="InstanceUniqueName">Unique name of the owning instance.</param>
|
||||
/// <param name="Alarm">The alarm condition (state / kind / severity / level / native sub-state).</param>
|
||||
public sealed record AlarmSummaryRow(
|
||||
string InstanceUniqueName,
|
||||
AlarmStateChanged Alarm);
|
||||
|
||||
/// <summary>
|
||||
/// Pure point-in-time roll-up over a set of <see cref="AlarmSummaryRow"/>s.
|
||||
/// </summary>
|
||||
/// <param name="TotalActive">Count of rows whose <see cref="AlarmStateChanged.State"/> is <see cref="AlarmState.Active"/>.</param>
|
||||
/// <param name="WorstSeverity">Highest <see cref="Commons.Types.Alarms.AlarmConditionState.Severity"/> among active rows; 0 when none active.</param>
|
||||
/// <param name="UnackedCount">Active, unacknowledged native conditions (<c>Kind != Computed</c>).</param>
|
||||
/// <param name="CountsByKind">Per-<see cref="AlarmKind"/> row counts (only kinds with at least one row appear).</param>
|
||||
public sealed record AlarmRollup(
|
||||
int TotalActive,
|
||||
int WorstSeverity,
|
||||
int UnackedCount,
|
||||
IReadOnlyDictionary<AlarmKind, int> CountsByKind);
|
||||
@@ -0,0 +1,24 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-shot per-instance debug-snapshot client. A thin seam over the existing
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.RequestDebugSnapshotAsync"/>
|
||||
/// Ask so <see cref="AlarmSummaryService"/> can fan out snapshot fetches while
|
||||
/// staying unit-testable (the implementation is substituted in tests).
|
||||
/// </summary>
|
||||
public interface IInstanceSnapshotClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests one debug snapshot for the given instance on the given site.
|
||||
/// </summary>
|
||||
/// <param name="siteIdentifier">The site's <c>SiteIdentifier</c> string (not the numeric site id).</param>
|
||||
/// <param name="instanceUniqueName">The instance's unique name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The instance's current debug snapshot, including its alarm states.</returns>
|
||||
Task<DebugViewSnapshot> GetSnapshotAsync(
|
||||
string siteIdentifier,
|
||||
string instanceUniqueName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AlarmSummaryService"/> (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.
|
||||
/// </summary>
|
||||
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<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IInstanceSnapshotClient _snapshotClient = Substitute.For<IInstanceSnapshotClient>();
|
||||
|
||||
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 };
|
||||
|
||||
/// <summary>Builds a native-style alarm with explicit condition flags.</summary>
|
||||
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<AttributeValueChanged>(), alarms, Now);
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne()
|
||||
{
|
||||
_siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>()).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<CancellationToken>())
|
||||
.Returns(new List<Instance> { 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<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns<DebugViewSnapshot>(_ => 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<CancellationToken>());
|
||||
await _snapshotClient.DidNotReceive()
|
||||
.GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting()
|
||||
{
|
||||
_siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns(Site());
|
||||
_instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance> { Instance(1, "ghost", InstanceState.Enabled) });
|
||||
|
||||
_snapshotClient.GetSnapshotAsync(SiteIdentifier, "ghost", Arg.Any<CancellationToken>())
|
||||
.Returns(new DebugViewSnapshot(
|
||||
"ghost", Array.Empty<AttributeValueChanged>(),
|
||||
Array.Empty<AlarmStateChanged>(), 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<AlarmSummaryRow>
|
||||
{
|
||||
// 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<AlarmSummaryRow>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user