Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
T
Joseph Doherty 2a7d6facce perf(dashboard): memoize AlarmsPage filtered rows and area list
FilteredAlarms() ran the full filter+sort twice per render (Showing card and
table) and Areas() once more, including every search keystroke render. Both
now go through DashboardAlarmView, recomputed only when the alarm generation
(bumped per poll) or the filter criteria change.
2026-10-22 06:19:34 -04:00

466 lines
18 KiB
Plaintext

@page "/alarms"
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.MxGateway.Server.Alarms
@* SEC-40: the alarm feed needs a real authenticated dashboard role, not the read-only
environment bypasses (Authentication:Mode=Disabled, anonymous localhost) that satisfy the
ViewerPolicy MapRazorComponents<App>() applies. This page reads the gateway-central alarm
monitor in process, exactly the surface AlarmsHub serves to external clients, so it carries the
SAME policy as the hub — gating only the hub would leave the disclosure one navigation away.
Alarm metadata stays galaxy-wide for an authenticated Viewer: the monitor has no per-session
provenance, so the SEC-25 tag ACL has nothing to intersect. *@
@attribute [Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
@inject IGatewayAlarmService AlarmService
<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>
}
@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
completeness of the set is in doubt, so this must not read as "alarms are broken". *@
@if (_snapshotTruncated)
{
<div class="alert alert-warning">
Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
the cap are not listed. Alarms already known stay listed rather than clearing. Raise
<code>MxGateway:Alarms:MaxAlarmsPerFetch</code> or narrow the subscription if this persists.
</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")" />
@* FilteredAlarms() is memoized on (alarm generation, filter inputs), so this card and the
table below share one computation per change instead of filtering twice per render. *@
<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 bool _snapshotTruncated;
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;
// Bumped whenever _alarms is replaced, so the memoized view recomputes exactly then.
private readonly DashboardAlarmView _view = new();
private long _alarmGeneration;
// Upper bound on waiting for either background loop while disposing, mirroring
// DashboardPageBase's snapshot-watch drain: both loops marshal renders through the
// renderer's dispatcher and disposal can run on that same dispatcher, so the wait is
// bounded rather than unconditional — an unconditional one hangs teardown for good on
// a wedged dispatcher.
private static readonly TimeSpan LoopDrainTimeout = TimeSpan.FromSeconds(5);
private readonly CancellationTokenSource _cts = new();
private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
private Task? _statusFeedTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
_statusFeedTask = StatusFeedLoopAsync();
}
private string? ProviderStatusTitle()
{
return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
? _providerStatus.Reason
: null;
}
// The badge tracks the central monitor directly rather than looping back through
// /hubs/alarms: the alarm service is an in-process multi-subscriber fan-out, so a
// server-rendered page needs no SignalR client, no loopback socket and no auth token.
// This loop feeds the two gateway-status indicators — the provider badge and the
// truncation banner — from the feed's own status frames, so both move as soon as the
// monitor's verdict changes instead of on the next 3-second tick. Alarm rows still come
// from the poll below, which also re-asserts the truncation verdict as its reconcile
// baseline: both sources read the same monitor verdict, so they cannot disagree for
// longer than one tick, and neither one is synthesized here.
private async Task StatusFeedLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
await foreach (AlarmFeedMessage message in AlarmService
.StreamAsync(alarmFilterPrefix: null, _cts.Token)
.ConfigureAwait(false))
{
switch (message.PayloadCase)
{
case AlarmFeedMessage.PayloadOneofCase.ProviderStatus:
await InvokeAsync(() =>
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
StateHasChanged();
}).ConfigureAwait(false);
break;
// Every subscriber is primed with this frame at open, so a page that
// attaches mid-truncation gets the caveat without waiting for an edge —
// no page-side priming needed.
case AlarmFeedMessage.PayloadOneofCase.SnapshotStatus:
await InvokeAsync(() =>
{
_snapshotTruncated = message.SnapshotStatus.Truncated;
StateHasChanged();
}).ConfigureAwait(false);
break;
default:
break;
}
}
}
catch (OperationCanceledException)
{
return;
}
catch
{
// The monitor drops a subscriber whose queue it cannot write to, completing
// that stream with an error; short of cancellation or disposal that is the
// only way this enumeration ends. A monitor restart is NOT one of them — it
// keeps the channel and pushes the cleared status frames through it — so this
// catch is the fell-behind case, recoverable by resubscribing. The badge and
// banner hold their last values meanwhile, and the resubscribe is primed with
// the current ones.
}
try
{
await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
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() => _view.Areas(_alarmGeneration, _alarms);
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms() =>
_view.Filtered(
_alarmGeneration,
new DashboardAlarmFilterCriteria(_showActive, _showAcked, _areaFilter, _minSeverity, _maxSeverity, _search),
_alarms);
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",
};
}
// Fault handling sits inside the loop, matching StatusFeedLoopAsync: a query or render
// fault on one tick is transient (a provider blip, a momentarily unavailable session), so it
// is surfaced on the page and retried on the next tick rather than ending polling for the
// life of the page. Cancellation is the only exit. The loop method itself therefore cannot
// fault, which is what DrainAsync in DisposeAsync relies on.
private async Task PollLoopAsync()
{
if (!await PollOnceAsync().ConfigureAwait(false))
{
return;
}
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (true)
{
try
{
if (!await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{
return;
}
}
catch (OperationCanceledException)
{
return;
}
if (!await PollOnceAsync().ConfigureAwait(false))
{
return;
}
}
}
// Returns false only when cancellation has ended the poll; a non-cancellation fault returns
// true so the caller waits for the next tick and tries again.
private async Task<bool> PollOnceAsync()
{
try
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
await ReportPollFaultAsync(ex).ConfigureAwait(false);
return true;
}
}
private async Task ReportPollFaultAsync(Exception fault)
{
try
{
await InvokeAsync(() =>
{
_queryError = fault.Message;
StateHasChanged();
}).ConfigureAwait(false);
}
catch
{
// Reporting is best-effort: the fault being reported may itself be the teardown race
// this catch-all exists for — an InvokeAsync against a disposed renderer — in which
// case the dispatch fails the same way and there is no page left to show it on. The
// poll loop keeps ticking either way and exits on the next cancellation check.
}
}
private async Task RefreshAlarmsAsync()
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
// Kept alongside the feed's snapshot_status frame rather than replaced by it: this is
// the reconcile baseline. Both read the same monitor verdict, so the poll can only
// confirm what the frame already showed — but it also re-establishes the banner for a
// page whose feed subscription is mid-resubscribe after the monitor dropped it.
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
_alarms.AddRange(result.Alarms);
_alarmGeneration++;
_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().ConfigureAwait(false);
// Drained together, not one after the other: the wedged dispatcher this bound exists
// for blocks both loops at once, so sequential drains would time out twice and make
// the real bound 10 seconds. DrainAsync tolerates a null task.
await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_statusFeedTask))
.ConfigureAwait(false);
_cts.Dispose();
GC.SuppressFinalize(this);
}
// The accepted cost of the bound is an abandoned loop that keeps its alarm-service
// subscription (and its poll timer) until it does unwind; the alternative — waiting
// forever on a dispatcher that is not draining — wedges the circuit teardown itself.
private static async Task DrainAsync(Task? loop)
{
if (loop is null)
{
return;
}
try
{
await loop.WaitAsync(LoopDrainTimeout).ConfigureAwait(false);
}
catch (TimeoutException)
{
}
catch (OperationCanceledException)
{
}
catch
{
// Other disposal-time errors are best-effort.
}
}
}