572 lines
25 KiB
Plaintext
572 lines
25 KiB
Plaintext
@page "/monitoring/alarms"
|
|
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
|
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
@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
|
|
@using ZB.MOM.WW.ScadaBridge.Communication
|
|
@implements IDisposable
|
|
@inject IAlarmSummaryService AlarmSummaryService
|
|
@inject ISiteRepository SiteRepository
|
|
@inject ISiteAlarmLiveCache LiveAlarmCache
|
|
|
|
<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)
|
|
{
|
|
@* The instance list is unbounded — a site with many silent instances would
|
|
otherwise push a wall of names across the page. Clamp to two lines and
|
|
keep the full list reachable via the title. *@
|
|
var notReportingList = string.Join(", ", _notReporting);
|
|
<div class="text-muted small mb-2 cell-clamp-2" data-test="alarm-summary-not-reporting"
|
|
title="@notReportingList">
|
|
Not reporting (@_notReporting.Count): @notReportingList
|
|
</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" @bind:after="RecomputeVisibleRows">
|
|
<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" @bind:after="RecomputeVisibleRows">
|
|
<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" @bind:after="RecomputeVisibleRows">
|
|
<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" @bind:after="RecomputeVisibleRows">
|
|
<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" @bind:after="RecomputeVisibleRows" />
|
|
</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" @bind:after="RecomputeVisibleRows" />
|
|
</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
|
|
{
|
|
<div class="text-muted small mb-2">Showing @_visibleRows.Count of @_rows.Count</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover align-middle">
|
|
@* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0",
|
|
aria-sort reflects the active sort direction, and Enter/Space toggle the
|
|
sort (Space preventDefault suppresses page scroll). The same pattern should
|
|
be applied to the other custom grids in the app; that fleet-wide sweep is
|
|
deferred and logged (arch-review UA6). *@
|
|
<thead>
|
|
<tr>
|
|
<th role="button" tabindex="0" aria-sort="@AriaSortFor("instance")"
|
|
@onclick='() => SortBy("instance")'
|
|
@onkeydown='e => OnHeaderKeyDown(e, "instance")' @onkeydown:preventDefault="_preventHeaderDefault">Instance @SortGlyph("instance")</th>
|
|
<th role="button" tabindex="0" aria-sort="@AriaSortFor("name")"
|
|
@onclick='() => SortBy("name")'
|
|
@onkeydown='e => OnHeaderKeyDown(e, "name")' @onkeydown:preventDefault="_preventHeaderDefault">Alarm @SortGlyph("name")</th>
|
|
<th>State / Kind</th>
|
|
<th role="button" tabindex="0" class="text-end" aria-sort="@AriaSortFor("severity")"
|
|
@onclick='() => SortBy("severity")'
|
|
@onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity")</th>
|
|
</tr>
|
|
</thead>
|
|
@* Row markup is identical on both paths — the same <tr>, the same cells,
|
|
the same data-test hook. Only WHO enumerates it differs: a plain
|
|
foreach for the ordinary case, and Virtualize once the flat table
|
|
gets long enough that rendering every row per delta dominates the
|
|
circuit (arch-review WP2.4). Virtualize needs <tr> spacers or the
|
|
browser hoists its <div>s out of the table and the layout collapses. *@
|
|
<tbody>
|
|
@if (_visibleRows.Count <= VirtualizeThreshold)
|
|
{
|
|
@foreach (var row in _visibleRows)
|
|
{
|
|
@AlarmRow(row)
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<Virtualize Items="_visibleRows" Context="row" ItemSize="37" SpacerElement="tr">
|
|
@AlarmRow(row)
|
|
</Virtualize>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
private IReadOnlyList<Site> _sites = Array.Empty<Site>();
|
|
private int? _selectedSiteId;
|
|
|
|
private IReadOnlyList<AlarmSummaryRow> _rows = Array.Empty<AlarmSummaryRow>();
|
|
|
|
// P4 (arch-review): the filtered+sorted view is memoized rather than recomputed
|
|
// on every render. RecomputeVisibleRows() refreshes it whenever the inputs change
|
|
// (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort).
|
|
// Concrete List because Virtualize binds ICollection<T>, not IReadOnlyList<T>.
|
|
private List<AlarmSummaryRow> _visibleRows = new();
|
|
|
|
// Above this many visible rows the flat table switches to Virtualize. Below it the
|
|
// plain foreach is cheaper than the virtualization machinery (and needs no JS), and
|
|
// an operator filtered down to a handful of alarms should never pay for either.
|
|
private const int VirtualizeThreshold = 150;
|
|
|
|
// One row template, shared by the plain and virtualized paths so the two can never
|
|
// drift visually.
|
|
private RenderFragment<AlarmSummaryRow> AlarmRow => row =>
|
|
@<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>;
|
|
|
|
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 readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new();
|
|
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();
|
|
DisposeLiveSubscription();
|
|
return;
|
|
}
|
|
|
|
_selectedSiteId = siteId;
|
|
ClearFilters();
|
|
// Subscribe to the shared live cache FIRST so the aggregator (and its
|
|
// seed-then-stream) starts warming while the initial poll runs.
|
|
SubscribeLive(siteId);
|
|
await RefreshAsync();
|
|
StartTimer();
|
|
// If another circuit already warmed the cache for this site, the initial
|
|
// poll may be staler than what's live — apply the live snapshot on top.
|
|
if (LiveAlarmCache.IsLive(siteId))
|
|
{
|
|
ApplyLiveSnapshot(siteId);
|
|
}
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (_selectedSiteId is not int siteId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_loading = true;
|
|
try
|
|
{
|
|
var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId);
|
|
// Stale-site guard (arch-review R2 N4): the operator may have switched sites
|
|
// while this fan-out was in flight — drop the result rather than labeling
|
|
// site A's alarms under site B's picker. Mirrors OnLiveAlarmsChanged (:374).
|
|
if (_selectedSiteId != siteId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// _notReporting always comes from this call, live or not. While the cache is
|
|
// serving the site the façade sources it from the aggregator's own fan-out (no
|
|
// second fan-out is run); while it is cold the poll computes it. Either way the
|
|
// shape and ordering are identical.
|
|
_notReporting = result.NotReportingInstances;
|
|
|
|
// While the cache is live, the live deltas own the row set: a poll whose fan-out
|
|
// started BEFORE a delta must not land after it and momentarily revert the alarm
|
|
// state (arch-review R2 N5). When not live (pre-seed / degraded stream / dead
|
|
// aggregator — see R2 N6), the poll remains the full-rebuild safety net.
|
|
if (!LiveAlarmCache.IsLive(siteId))
|
|
{
|
|
_rows = result.Alarms;
|
|
_rollup = AlarmSummaryService.ComputeRollup(_rows);
|
|
}
|
|
RecomputeVisibleRows();
|
|
}
|
|
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(_ =>
|
|
{
|
|
if (!_pollGate.TryEnter()) return;
|
|
InvokeAsync(async () =>
|
|
{
|
|
try
|
|
{
|
|
await RefreshAsync();
|
|
StateHasChanged();
|
|
}
|
|
finally
|
|
{
|
|
_pollGate.Exit();
|
|
}
|
|
});
|
|
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
|
|
}
|
|
|
|
private void StopTimer()
|
|
{
|
|
_refreshTimer?.Dispose();
|
|
_refreshTimer = null;
|
|
}
|
|
|
|
// ── Live cache (plan #10, Task 5) ──────────────────────────────────────────
|
|
// The page is live-cache-first with the 15s poll kept as a fallback/safety net.
|
|
// Reconciliation model:
|
|
// • The live cache pushes onChanged deltas (near-real-time) whenever the site's
|
|
// aggregated alarm set changes. We rebuild _rows/_rollup/_visibleRows from the
|
|
// immutable live snapshot — but deliberately DO NOT touch _notReporting, since
|
|
// the alarm-only live cache can't compute it.
|
|
// • The 15s poll (RefreshAsync) is the authority for _notReporting and the
|
|
// full-rebuild safety net ONLY while the cache is not live (pre-seed or a
|
|
// degraded/failed stream — IsLive == false); when live it deliberately leaves
|
|
// _rows to the delta path, so a slow fan-out can never revert a fresher live
|
|
// delta (arch-review R2 N5).
|
|
// • Both paths mutate shared state only via the Blazor dispatcher (the poll via
|
|
// its InvokeAsync callback, the live delta via OnLiveChanged's InvokeAsync), so
|
|
// they are serialized and never race. Each rebuild is an idempotent snapshot, so
|
|
// a live rebuild immediately followed by a poll rebuild (or vice-versa) is safe.
|
|
private IDisposable? _liveSubscription;
|
|
|
|
private void SubscribeLive(int siteId)
|
|
{
|
|
DisposeLiveSubscription();
|
|
_liveSubscription = LiveAlarmCache.Subscribe(siteId, () => OnLiveAlarmsChanged(siteId));
|
|
}
|
|
|
|
private void DisposeLiveSubscription()
|
|
{
|
|
_liveSubscription?.Dispose();
|
|
_liveSubscription = null;
|
|
}
|
|
|
|
// N7: set BEFORE teardown so a live callback racing Dispose is dropped both
|
|
// before the InvokeAsync marshal and inside it (mirrors DebugView.razor).
|
|
private volatile bool _disposed;
|
|
|
|
// Raised on the aggregator's thread — marshal onto the circuit before touching state.
|
|
private void OnLiveAlarmsChanged(int siteId)
|
|
{
|
|
if (_disposed) return;
|
|
_ = InvokeAsync(() =>
|
|
{
|
|
// Drop stale callbacks for a site we've since navigated away from, and
|
|
// let the poll drive until the aggregator has actually seeded (so we never
|
|
// clobber a good poll snapshot with an empty pre-seed list).
|
|
if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ApplyLiveSnapshot(siteId);
|
|
StateHasChanged();
|
|
});
|
|
}
|
|
|
|
// Rebuilds rows/rollup/visible-rows from the live snapshot. Leaves _notReporting
|
|
// as the last poll computed it (the alarm-only cache can't know it). Idempotent.
|
|
private void ApplyLiveSnapshot(int siteId)
|
|
{
|
|
var current = LiveAlarmCache.GetCurrentAlarms(siteId);
|
|
var result = AlarmSummaryService.BuildFromLiveAlarms(current);
|
|
_rows = result.Alarms;
|
|
_rollup = AlarmSummaryService.ComputeRollup(_rows);
|
|
RecomputeVisibleRows();
|
|
}
|
|
|
|
private IEnumerable<string> DistinctInstances =>
|
|
_rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
|
|
|
|
// P4: recompute the memoized filtered+sorted view from the current snapshot and
|
|
// filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy —
|
|
// NOT per render.
|
|
private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList();
|
|
|
|
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";
|
|
}
|
|
RecomputeVisibleRows();
|
|
}
|
|
|
|
private string SortGlyph(string key) =>
|
|
_sortKey != key ? "" : (_sortDescending ? "▼" : "▲");
|
|
|
|
// UA6: aria-sort token for a sortable header — ascending/descending on the active
|
|
// column, "none" otherwise (per the WAI-ARIA aria-sort value set).
|
|
private string AriaSortFor(string key) =>
|
|
_sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending");
|
|
|
|
// UA6: whether the last header keydown should suppress the browser default. Bound to
|
|
// @onkeydown:preventDefault (evaluated at render), so it takes effect on the next
|
|
// Space keydown to stop the page scrolling when a header has keyboard focus.
|
|
private bool _preventHeaderDefault;
|
|
|
|
// UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort,
|
|
// mirroring the pointer click.
|
|
private void OnHeaderKeyDown(KeyboardEventArgs e, string key)
|
|
{
|
|
var isSpace = e.Key is " " or "Spacebar";
|
|
_preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone
|
|
if (e.Key == "Enter" || isSpace)
|
|
{
|
|
SortBy(key);
|
|
}
|
|
}
|
|
|
|
private void ClearFilters()
|
|
{
|
|
_filterInstance = "";
|
|
_filterKind = "";
|
|
_filterState = "";
|
|
_filterAck = "";
|
|
_filterMinSeverity = null;
|
|
_filterName = "";
|
|
RecomputeVisibleRows();
|
|
}
|
|
|
|
private static string KindLabel(AlarmKind kind) => kind switch
|
|
{
|
|
AlarmKind.NativeOpcUa => "OPC UA",
|
|
AlarmKind.NativeMxAccess => "MxAccess",
|
|
_ => "Computed"
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
_disposed = true;
|
|
StopTimer();
|
|
DisposeLiveSubscription();
|
|
}
|
|
}
|