Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
T

342 lines
12 KiB
Plaintext

@page "/alarms"
@implements IAsyncDisposable
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
@inject DashboardHubConnectionFactory HubFactory
<PageTitle>Dashboard Alarms</PageTitle>
<div class="dashboard-page-header">
<div>
<h1>Alarms</h1>
<div class="text-secondary">@HeaderLine()</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge @_providerStatus.BadgeCssClass"
title="@ProviderStatusTitle()">
@_providerStatus.Label
</span>
</div>
</div>
@if (!GatewayOptions.Value.Alarms.Enabled)
{
<div class="alert alert-danger">
Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
dashboard session is not subscribed to any alarm provider, so this list will stay empty.
Enable alarms in configuration and restart the gateway.
</div>
}
@if (!string.IsNullOrWhiteSpace(_queryError))
{
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
}
<section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Filters</h2>
</div>
<div class="alarm-filters">
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showActive" />
<span>Active (unacked)</span>
</label>
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showAcked" />
<span>Acknowledged</span>
</label>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-area">Area</label>
<select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
<option value="">All areas</option>
@foreach (string area in Areas())
{
<option value="@area">@area</option>
}
</select>
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-min">Min severity</label>
<input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-max">Max severity</label>
<input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
</div>
<div class="alarm-filter-field alarm-filter-grow">
<label class="form-label" for="alarm-search">Search</label>
<input id="alarm-search" class="form-control form-control-sm"
placeholder="Reference, source or description…"
@bind="_search" @bind:event="oninput" />
</div>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Active Alarms</h2>
</div>
@{
IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
}
@if (rows.Count == 0)
{
<div class="empty-state">
@if (_alarms.Count == 0)
{
<span>No alarms are currently Active or ActiveAcked.</span>
}
else
{
<span>No alarms match the current filters.</span>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">State</th>
<th scope="col">Severity</th>
<th scope="col">Alarm Reference</th>
<th scope="col">Source</th>
<th scope="col">Type</th>
<th scope="col">Area</th>
<th scope="col">Last Transition</th>
<th scope="col">Operator</th>
</tr>
</thead>
<tbody>
@foreach (DashboardActiveAlarm alarm in rows)
{
<tr>
<td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
<td class="alarm-severity">@alarm.Severity</td>
<td>
<code>@alarm.Reference</code>
@if (!string.IsNullOrWhiteSpace(alarm.Description))
{
<div class="alarm-desc">@alarm.Description</div>
}
</td>
<td>@DashboardDisplay.Text(alarm.Source)</td>
<td>@DashboardDisplay.Text(alarm.AlarmType)</td>
<td>@DashboardDisplay.Text(alarm.Area)</td>
<td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
<td>
@DashboardDisplay.Text(alarm.OperatorUser)
@if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
{
<div class="alarm-desc">@alarm.OperatorComment</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="browse-search-note">
Cleared alarms are not retained — this list reflects only alarms currently Active or
ActiveAcked, refreshed every 3 seconds.
</div>
</section>
@code {
private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError;
private int? _workerPid;
private DateTimeOffset? _lastRefresh;
private int _unackedCount;
private int _ackedCount;
private bool _showActive = true;
private bool _showAcked;
private string _areaFilter = string.Empty;
private int _minSeverity;
private int _maxSeverity = 1000;
private string _search = string.Empty;
private readonly CancellationTokenSource _cts = new();
private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
private HubConnection? _alarmsHub;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
_ = AttachAlarmsHubAsync();
}
private string? ProviderStatusTitle()
{
return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
? _providerStatus.Reason
: null;
}
private async Task AttachAlarmsHubAsync()
{
_alarmsHub = HubFactory.Create("/hubs/alarms");
_alarmsHub.On<AlarmFeedMessage>(AlarmsHub.AlarmMessage, async message =>
{
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
});
try
{
await _alarmsHub.StartAsync(_cts.Token).ConfigureAwait(false);
}
catch
{
// The badge is best-effort; it stays at the healthy default until
// the hub reconnects and delivers a fresh provider-status message.
}
}
private string HeaderLine()
{
string refreshed = _lastRefresh is { } at
? $"refreshed {DashboardDisplay.DateTime(at)}"
: "awaiting first refresh";
return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
}
private IReadOnlyList<string> Areas()
{
return _alarms
.Select(alarm => alarm.Area)
.Where(area => !string.IsNullOrWhiteSpace(area))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
{
string query = _search.Trim();
return _alarms
.Where(MatchesState)
.Where(alarm => _areaFilter.Length == 0
|| string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
.Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
.Where(alarm => query.Length == 0
|| alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(alarm => alarm.Severity)
.ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
.ToArray();
}
private bool MatchesState(DashboardActiveAlarm alarm)
{
return alarm.State switch
{
AlarmConditionState.Active => _showActive,
AlarmConditionState.ActiveAcked => _showAcked,
_ => true,
};
}
private static string StateText(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "Active",
AlarmConditionState.ActiveAcked => "Acked",
AlarmConditionState.Inactive => "Inactive",
_ => "Unknown",
};
}
private static string StateClass(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "alarm-state-active",
AlarmConditionState.ActiveAcked => "alarm-state-acked",
_ => "alarm-state-other",
};
}
private async Task PollLoopAsync()
{
try
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
}
private async Task RefreshAlarmsAsync()
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
_alarms.AddRange(result.Alarms);
_unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
_ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
StateHasChanged();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_alarmsHub is not null)
{
try
{
await _alarmsHub.DisposeAsync();
}
catch
{
// Disposal-time errors are best-effort.
}
}
if (_pollTask is not null)
{
try
{
await _pollTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose();
GC.SuppressFinalize(this);
}
}