342 lines
12 KiB
Plaintext
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);
|
|
}
|
|
}
|