feat(adminui): Alerts timed-shelve picker + chip auto-clear + live-pill health
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@inject IAuthorizationService AuthorizationService
|
@inject IAuthorizationService AuthorizationService
|
||||||
@inject IAdminOperationsClient AdminOps
|
@inject IAdminOperationsClient AdminOps
|
||||||
@implements IDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Alerts</h4>
|
<h4 class="mb-0">Alerts</h4>
|
||||||
@@ -87,6 +87,15 @@ else
|
|||||||
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
||||||
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
|
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
|
||||||
title="Shelve this alarm until it next clears">Shelve</button>
|
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"
|
<button type="button"
|
||||||
class="btn btn-sm btn-outline-secondary"
|
class="btn btn-sm btn-outline-secondary"
|
||||||
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
||||||
@@ -123,13 +132,30 @@ else
|
|||||||
private string? _opResultMessage;
|
private string? _opResultMessage;
|
||||||
private bool _opResultOk;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the
|
// 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
|
// '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.
|
// 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;
|
Alarms.Received += OnAlarm;
|
||||||
_connected = true;
|
_connected = Alarms.IsConnected;
|
||||||
|
|
||||||
// Check DriverOperator authorization so the per-row action controls only render for
|
// 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
|
// 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;
|
_busyAlarmId = alarmId;
|
||||||
_opResultMessage = null;
|
_opResultMessage = null;
|
||||||
@@ -171,12 +202,9 @@ else
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await GetCurrentUserNameAsync();
|
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));
|
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
var result = await AdminOps.ShelveAlarmAsync(
|
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";
|
var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve";
|
||||||
ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
|
ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
|
||||||
}
|
}
|
||||||
@@ -210,6 +238,15 @@ else
|
|||||||
_opResultOk = ok;
|
_opResultOk = ok;
|
||||||
_opResultMessage = message;
|
_opResultMessage = message;
|
||||||
StateHasChanged();
|
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) =>
|
private void OnAlarm(AlarmTransitionEvent evt) =>
|
||||||
@@ -222,6 +259,12 @@ else
|
|||||||
StateHasChanged();
|
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()
|
private async Task ClearAsync()
|
||||||
{
|
{
|
||||||
_rows.Clear();
|
_rows.Clear();
|
||||||
@@ -237,5 +280,13 @@ else
|
|||||||
_ => "chip-idle",
|
_ => "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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user