feat(alerts): AdminUI alarm ack/shelve via AdminOperationsActor singleton
T21: add an AdminUI path for acknowledging/shelving alarms that routes through the admin-pinned AdminOperationsActor cluster singleton, which republishes onto the same 'alarm-commands' DPS topic the OPC UA method path (T18) and the engine subscriber (T19) use. The broadcast + the ScriptedAlarmHostActor ownership filter handle cross-node routing, so the singleton needs no knowledge of which node owns the alarm. - Commons: AcknowledgeAlarmCommand/ShelveAlarmCommand (+ result records) and a shared AlarmCommandsTopic const; ScriptedAlarmHostActor now re-exports that const (mirrors the DriverControlTopic pattern). - AdminOperationsActor: two handlers map the control-plane messages to AlarmCommand (Acknowledge / OneShotShelve / TimedShelve / Unshelve, threading User/Comment/UnshelveAtUtc) and publish via the DPS mediator. - IAdminOperationsClient + AdminOperationsClient: typed Acknowledge/Shelve ask wrappers mirroring StartDeploymentAsync. - Alerts.razor: per-row DriverOperator-gated Ack/Shelve/Unshelve controls; operator name from AuthenticationState. Timed-shelve datetime UI deferred. - 5 TestKit tests (mediator-probe subscribed to alarm-commands) verifying each kind's mapping + reply; 56/56 ControlPlane tests green.
This commit is contained in:
@@ -4,9 +4,15 @@
|
||||
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">
|
||||
@@ -47,6 +53,10 @@ else
|
||||
<th class="num">Severity</th>
|
||||
<th>User</th>
|
||||
<th>Message</th>
|
||||
@if (_canOperate)
|
||||
{
|
||||
<th>Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,6 +70,35 @@ else
|
||||
<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>
|
||||
@@ -74,13 +113,102 @@ else
|
||||
private readonly List<AlarmTransitionEvent> _rows = new();
|
||||
private bool _connected;
|
||||
|
||||
protected override void OnInitialized()
|
||||
// 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();
|
||||
var result = await AdminOps.AcknowledgeAlarmAsync(
|
||||
alarmId, user, comment: null,
|
||||
new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)).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.
|
||||
var result = await AdminOps.ShelveAlarmAsync(
|
||||
alarmId, user, kind, unshelveAtUtc: null, comment: null,
|
||||
new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)).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;
|
||||
}
|
||||
|
||||
private void OnAlarm(AlarmTransitionEvent evt) =>
|
||||
|
||||
Reference in New Issue
Block a user