301 lines
15 KiB
Plaintext
301 lines
15 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 IAsyncDisposable
|
|
|
|
<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>
|
|
<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)"
|
|
@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;
|
|
|
|
// 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;
|
|
private object? _opResultClearToken;
|
|
|
|
// 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 = 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
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
var user = await GetCurrentUserNameAsync();
|
|
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
|
|
var result = await AdminOps.ShelveAlarmAsync(
|
|
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"));
|
|
}
|
|
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();
|
|
// 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.
|
|
var token = _opResultClearToken = new object();
|
|
_opResultClearTimer?.Dispose();
|
|
_opResultClearTimer = new System.Threading.Timer(_ =>
|
|
InvokeAsync(() =>
|
|
{
|
|
// Ignore a stale fire: a newer action superseded this timer's chip. (Timer.Dispose does
|
|
// not drain an already-queued callback, so without this a stale timer could clear a
|
|
// freshly-set result from a different row.)
|
|
if (!ReferenceEquals(_opResultClearToken, token)) return;
|
|
_opResultMessage = null;
|
|
_opResultAlarmId = "";
|
|
StateHasChanged();
|
|
}),
|
|
null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
// 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();
|
|
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 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();
|
|
}
|
|
}
|