Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
T
Joseph Doherty d860b0d8a6 fix(adminui): dispose Alerts CTS + self-render ShowOpResult (T21 review)
Dispose the CancellationTokenSource in AcknowledgeAsync and ShelveAsync
(the TimeSpan overload holds an internal timer — leaked without using).
Add StateHasChanged() to ShowOpResult so the result chip renders even if
a future caller omits the finally-block re-render.
2026-06-11 06:50:06 -04:00

242 lines
11 KiB
Plaintext

@page "/alerts"
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
and the AB CIP ALMD bridge. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
@inject IInProcessBroadcaster<AlarmTransitionEvent> Alarms
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alerts</h4>
<div class="d-flex align-items-center gap-2">
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
</div>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
</section>
@if (_rows.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
No alarms in the current window. The table will populate as soon as a
ScriptedAlarmActor or driver alarm bridge publishes a transition.
</section>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Recent transitions (@_rows.Count)</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Alarm</th>
<th>Equipment</th>
<th>Kind</th>
<th class="num">Severity</th>
<th>User</th>
<th>Message</th>
@if (_canOperate)
{
<th>Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
<td><span class="mono small">@e.EquipmentPath</span></td>
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td>
<td>@e.User</td>
<td>@e.Message</td>
@if (_canOperate)
{
@* DriverOperator-gated Acknowledge / Shelve / Unshelve. Each routes through
the AdminOperationsActor singleton, which republishes onto the cluster
'alarm-commands' topic; the owning node applies it (ownership filter). *@
<td>
<div class="d-flex gap-1 align-items-center">
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => AcknowledgeAsync(e.AlarmId)"
title="Acknowledge this alarm">Ack</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
title="Shelve this alarm until it next clears">Shelve</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.Unshelve)"
title="Remove an existing shelve">Unshelve</button>
</div>
@if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null)
{
<span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int Capacity = 200;
private readonly List<AlarmTransitionEvent> _rows = new();
private bool _connected;
// Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls.
private bool _canOperate;
// Per-row action state. Only one alarm action is in flight at a time; the busy/result
// fields are keyed by AlarmId so the spinner + result chip attach to the right row.
private string _busyAlarmId = "";
private string _opResultAlarmId = "";
private string? _opResultMessage;
private bool _opResultOk;
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.
Alarms.Received += OnAlarm;
_connected = true;
// 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
// mid-session token refresh lands in the published command + audit accurately.
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
_canOperate = authResult.Succeeded;
}
private async Task AcknowledgeAsync(string alarmId)
{
_busyAlarmId = alarmId;
_opResultMessage = null;
StateHasChanged();
try
{
var user = await GetCurrentUserNameAsync();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
var result = await AdminOps.AcknowledgeAlarmAsync(
alarmId, user, comment: null, cts.Token);
ShowOpResult(alarmId, result.Ok, result.Ok ? "Ack sent" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyAlarmId = "";
StateHasChanged();
}
}
private async Task ShelveAsync(string alarmId, ShelveKind kind)
{
_busyAlarmId = alarmId;
_opResultMessage = null;
StateHasChanged();
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);
var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve";
ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyAlarmId = "";
StateHasChanged();
}
}
/// <summary>
/// Re-reads the AuthenticationState at call time so the operator name forwarded to the
/// command + audit reflects the current claims-principal (survives token refresh during a
/// long-lived circuit). Returns "unknown" if no Name claim is present.
/// </summary>
private async Task<string> GetCurrentUserNameAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
return auth.User.Identity?.Name
?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? "unknown";
}
private void ShowOpResult(string alarmId, bool ok, string message)
{
_opResultAlarmId = alarmId;
_opResultOk = ok;
_opResultMessage = message;
StateHasChanged();
}
private void OnAlarm(AlarmTransitionEvent evt) =>
// Marshal both the mutation and the re-render onto the circuit sync context so this can't
// race ClearAsync (which runs there) over the shared _rows list.
InvokeAsync(() =>
{
_rows.Insert(0, evt);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
StateHasChanged();
});
private async Task ClearAsync()
{
_rows.Clear();
await InvokeAsync(StateHasChanged);
}
private static string KindChipClass(string kind) => kind switch
{
"Activated" => "chip-alert",
"Cleared" => "chip-ok",
"Acknowledged" or "Confirmed" => "chip-caution",
"Shelved" or "Disabled" => "chip-idle",
_ => "chip-idle",
};
public void Dispose() => Alarms.Received -= OnAlarm;
}