feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)

This commit is contained in:
Joseph Doherty
2026-06-18 02:21:41 -04:00
parent 6a6f8949b9
commit 3c9122bc07
8 changed files with 872 additions and 0 deletions
@@ -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();
}