feat(adminui): Alerts timed-shelve picker + chip auto-clear + live-pill health

This commit is contained in:
Joseph Doherty
2026-06-11 09:34:33 -04:00
parent f9932f2d8e
commit d29c933499
@@ -13,7 +13,7 @@
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@implements IDisposable
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alerts</h4>
@@ -87,6 +87,15 @@ else
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
title="Shelve this alarm until it next clears">Shelve</button>
<input type="number" min="1" class="form-control form-control-sm" style="width:4.5rem"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@bind:get="GetShelveMinutes(e.AlarmId)" @bind:set="m => SetShelveMinutes(e.AlarmId, m)"
title="Timed-shelve duration in minutes" />
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveTimedAsync(e.AlarmId)"
title="Shelve this alarm for the chosen number of minutes">Shelve (timed)</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@@ -123,13 +132,30 @@ else
private string? _opResultMessage;
private bool _opResultOk;
// Auto-clear timer for the per-row result chip (mirrors DriverStatusPanel): the chip is set in
// ShowOpResult and cleared 8 s later so it doesn't persist until the next action.
private System.Threading.Timer? _opResultClearTimer;
// Per-row timed-shelve duration (minutes). Keyed by AlarmId so each row's number input is
// independent — binding every row to one shared field would couple all the inputs together.
private const int DefaultShelveMinutes = 5;
private readonly Dictionary<string, int> _shelveMinutes = new();
private int GetShelveMinutes(string alarmId) =>
_shelveMinutes.TryGetValue(alarmId, out var m) ? m : DefaultShelveMinutes;
private void SetShelveMinutes(string alarmId, int minutes) =>
_shelveMinutes[alarmId] = minutes < 1 ? 1 : minutes;
protected override async Task OnInitializedAsync()
{
// Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the
// 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection
// behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
// Subscribe to the health signal BEFORE reading IsConnected so a state change between the
// read and the subscribe can't be missed (TOCTOU) — mirrors DriverStatusPanel's
// SnapshotChanged-then-TryGet ordering.
Alarms.ConnectionStateChanged += OnConnectionStateChanged;
Alarms.Received += OnAlarm;
_connected = true;
_connected = Alarms.IsConnected;
// Check DriverOperator authorization so the per-row action controls only render for
// permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a
@@ -163,7 +189,12 @@ else
}
}
private async Task ShelveAsync(string alarmId, ShelveKind kind)
// Thin wrapper for the per-row "Shelve (timed)" button: shelves until now + the row's chosen
// minutes. OneShot/Unshelve call ShelveAsync directly with the default null unshelve-at.
private Task ShelveTimedAsync(string alarmId) =>
ShelveAsync(alarmId, ShelveKind.Timed, DateTime.UtcNow.AddMinutes(GetShelveMinutes(alarmId)));
private async Task ShelveAsync(string alarmId, ShelveKind kind, DateTime? unshelveAtUtc = null)
{
_busyAlarmId = alarmId;
_opResultMessage = null;
@@ -171,12 +202,9 @@ else
try
{
var user = await GetCurrentUserNameAsync();
// Timed shelve (with an unshelve-at datetime picker) is deferred — only OneShot + Unshelve
// are surfaced here, so unshelveAtUtc is always null. TimedShelve is fully wired through the
// singleton + AlarmCommand if a UI is added later.
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
var result = await AdminOps.ShelveAlarmAsync(
alarmId, user, kind, unshelveAtUtc: null, comment: null, cts.Token);
alarmId, user, kind, unshelveAtUtc: unshelveAtUtc, comment: null, cts.Token);
var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve";
ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
}
@@ -210,6 +238,15 @@ else
_opResultOk = ok;
_opResultMessage = message;
StateHasChanged();
// Auto-clear the result chip after 8 s (mirrors DriverStatusPanel). System.Threading.Timer
// is used (not System.Timers.Timer) so DisposeAsync can drain any in-flight callback.
_opResultClearTimer?.Dispose();
_opResultClearTimer = new System.Threading.Timer(_ =>
{
_opResultMessage = null;
_opResultAlarmId = "";
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
}
private void OnAlarm(AlarmTransitionEvent evt) =>
@@ -222,6 +259,12 @@ else
StateHasChanged();
});
// Raised by the broadcaster (on the bridge actor's thread) whenever the upstream DPS
// subscription's health flips. Marshal onto the circuit sync context like OnAlarm so the live
// pill re-renders safely.
private void OnConnectionStateChanged(bool connected) =>
InvokeAsync(() => { _connected = connected; StateHasChanged(); });
private async Task ClearAsync()
{
_rows.Clear();
@@ -237,5 +280,13 @@ else
_ => "chip-idle",
};
public void Dispose() => Alarms.Received -= OnAlarm;
public async ValueTask DisposeAsync()
{
// Unsubscribe BOTH broadcaster events first so the singleton can't invoke a handler on a
// disposed component, then drain the auto-clear timer (its async dispose awaits any in-flight
// callback so it can't call StateHasChanged on a gone component).
Alarms.ConnectionStateChanged -= OnConnectionStateChanged;
Alarms.Received -= OnAlarm;
if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
}
}