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:
@@ -15,6 +15,36 @@ public interface IAdminOperationsClient
|
||||
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
|
||||
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges one alarm via the admin singleton, which republishes the mapped command onto
|
||||
/// the cluster <c>alarm-commands</c> topic for the owning node to apply.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name (for audit + the alarm-event User field).</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The acknowledge result.</returns>
|
||||
Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Shelves or unshelves one alarm via the admin singleton, which republishes the mapped command
|
||||
/// onto the cluster <c>alarm-commands</c> topic for the owning node to apply.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name (for audit + the alarm-event User field).</param>
|
||||
/// <param name="kind">Which shelve action to perform (OneShot / Timed / Unshelve).</param>
|
||||
/// <param name="unshelveAtUtc">For <see cref="Messages.Admin.ShelveKind.Timed"/>, when the shelve expires; null otherwise.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The shelve result.</returns>
|
||||
Task<ShelveAlarmResult> ShelveAlarmAsync(
|
||||
string alarmId,
|
||||
string user,
|
||||
Messages.Admin.ShelveKind kind,
|
||||
DateTime? unshelveAtUtc,
|
||||
string? comment,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generic Ask: forwards <paramref name="message"/> to the AdminOperationsActor
|
||||
/// cluster-singleton proxy and awaits a reply of type <typeparamref name="T"/>.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: acknowledge one alarm on behalf of an operator. The admin
|
||||
/// singleton maps this to a <c>Commons.OpcUa.AlarmCommand</c> with <c>Operation = "Acknowledge"</c>
|
||||
/// and republishes it onto the cluster <c>alarm-commands</c> topic, where the owning
|
||||
/// <c>ScriptedAlarmHostActor</c> filters by ownership and drives the engine. Routing this through
|
||||
/// the admin-pinned singleton lets the AdminUI act without knowing which node owns the alarm —
|
||||
/// the broadcast + ownership filter handle cross-node delivery.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm's ScriptedAlarmId (the <c>AlarmTransitionEvent.AlarmId</c> shown on the row).</param>
|
||||
/// <param name="User">The authenticated operator who triggered the acknowledgement.</param>
|
||||
/// <param name="Comment">Optional free-text comment supplied with the acknowledgement; null when none.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record AcknowledgeAlarmCommand(
|
||||
string AlarmId,
|
||||
string User,
|
||||
string? Comment,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="AcknowledgeAlarmCommand"/>.</summary>
|
||||
/// <param name="Ok">True iff the command was published without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record AcknowledgeAlarmResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// The shelve action an operator requested. Maps 1:1 onto the engine-side <c>Operation</c>
|
||||
/// strings the <c>ScriptedAlarmHostActor</c> dispatches on
|
||||
/// (<c>OneShotShelve</c> / <c>TimedShelve</c> / <c>Unshelve</c>).
|
||||
/// </summary>
|
||||
public enum ShelveKind
|
||||
{
|
||||
/// <summary>Shelve until the next clear (no timer). Maps to <c>Operation = "OneShotShelve"</c>.</summary>
|
||||
OneShot,
|
||||
|
||||
/// <summary>Shelve until an absolute instant. Maps to <c>Operation = "TimedShelve"</c>; carries <c>UnshelveAtUtc</c>.</summary>
|
||||
Timed,
|
||||
|
||||
/// <summary>Remove an existing shelve. Maps to <c>Operation = "Unshelve"</c>.</summary>
|
||||
Unshelve,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: shelve / unshelve one alarm on behalf of an operator. The admin
|
||||
/// singleton maps <see cref="Kind"/> to the matching <c>Commons.OpcUa.AlarmCommand</c> operation
|
||||
/// (<c>OneShotShelve</c> / <c>TimedShelve</c> / <c>Unshelve</c>), threading
|
||||
/// <see cref="UnshelveAtUtc"/> for <see cref="ShelveKind.Timed"/>, and republishes it onto the
|
||||
/// cluster <c>alarm-commands</c> topic, where the owning <c>ScriptedAlarmHostActor</c> filters by
|
||||
/// ownership and drives the engine.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm's ScriptedAlarmId (the <c>AlarmTransitionEvent.AlarmId</c> shown on the row).</param>
|
||||
/// <param name="User">The authenticated operator who triggered the shelve.</param>
|
||||
/// <param name="Kind">Which shelve action to perform.</param>
|
||||
/// <param name="UnshelveAtUtc">
|
||||
/// For <see cref="ShelveKind.Timed"/>, the absolute UTC instant the shelve auto-expires; null for
|
||||
/// <see cref="ShelveKind.OneShot"/> and <see cref="ShelveKind.Unshelve"/>.
|
||||
/// </param>
|
||||
/// <param name="Comment">Optional free-text comment supplied with the shelve; null when none.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record ShelveAlarmCommand(
|
||||
string AlarmId,
|
||||
string User,
|
||||
ShelveKind Kind,
|
||||
DateTime? UnshelveAtUtc,
|
||||
string? Comment,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="ShelveAlarmCommand"/>.</summary>
|
||||
/// <param name="Ok">True iff the command was published without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record ShelveAlarmResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -1,5 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Shared DPS topic for inbound alarm commands (<see cref="AlarmCommand"/>). Publishers — the
|
||||
/// OPC UA node-manager seam (T18) and the AdminUI path via <c>AdminOperationsActor</c> (T21) —
|
||||
/// and the subscriber (<c>ScriptedAlarmHostActor</c>, T19) reference this single constant so a
|
||||
/// rename can't silently desynchronise them. Mirrors the
|
||||
/// <see cref="Messages.Admin.DriverControlTopic"/> pattern; <c>ScriptedAlarmHostActor</c>
|
||||
/// re-exports this as its <c>AlarmCommandsTopic</c> const.
|
||||
/// </summary>
|
||||
public static class AlarmCommandsTopic
|
||||
{
|
||||
/// <summary>The cluster DistributedPubSub topic name inbound alarm commands are published on.</summary>
|
||||
public const string Name = "alarm-commands";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-level command carried from an inbound OPC UA Part 9 alarm method call
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment …) back to the scripted-alarm engine. The SDK
|
||||
|
||||
@@ -37,6 +37,38 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
|
||||
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges one alarm via the admin singleton.</summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The acknowledge result.</returns>
|
||||
public async Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(
|
||||
string alarmId, string user, string? comment, CancellationToken ct)
|
||||
{
|
||||
var msg = new AcknowledgeAlarmCommand(alarmId, user, comment, Guid.NewGuid());
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
linked.CancelAfter(AskTimeout);
|
||||
return await _proxy.Ask<AcknowledgeAlarmResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>Shelves or unshelves one alarm via the admin singleton.</summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name.</param>
|
||||
/// <param name="kind">Which shelve action to perform.</param>
|
||||
/// <param name="unshelveAtUtc">For a timed shelve, when it expires; null otherwise.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The shelve result.</returns>
|
||||
public async Task<ShelveAlarmResult> ShelveAlarmAsync(
|
||||
string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, string? comment, CancellationToken ct)
|
||||
{
|
||||
var msg = new ShelveAlarmCommand(alarmId, user, kind, unshelveAtUtc, comment, Guid.NewGuid());
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
linked.CancelAfter(AskTimeout);
|
||||
return await _proxy.Ask<ShelveAlarmResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
|
||||
/// Uses the caller-supplied <paramref name="ct"/> for cancellation; does not impose an
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -54,6 +55,87 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync);
|
||||
ReceiveAsync<RestartDriver>(HandleRestartDriverAsync);
|
||||
ReceiveAsync<ReconnectDriver>(HandleReconnectDriverAsync);
|
||||
Receive<AcknowledgeAlarmCommand>(HandleAcknowledgeAlarm);
|
||||
Receive<ShelveAlarmCommand>(HandleShelveAlarm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI Acknowledge path. Maps the control-plane command to a
|
||||
/// <see cref="AlarmCommand"/> (<c>Operation = "Acknowledge"</c>) and publishes it onto the
|
||||
/// cluster <c>alarm-commands</c> topic. The broadcast lands on every driver node's
|
||||
/// <c>ScriptedAlarmHostActor</c>; only the node owning the alarm acts (ownership filter), so the
|
||||
/// admin singleton needs no knowledge of placement. Synchronous — the publish is fire-and-forget
|
||||
/// via the mediator, so there is no awaitable work and no DB write.
|
||||
/// </summary>
|
||||
private void HandleAcknowledgeAlarm(AcknowledgeAlarmCommand msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
try
|
||||
{
|
||||
var alarmCmd = new AlarmCommand(
|
||||
AlarmId: msg.AlarmId,
|
||||
Operation: "Acknowledge",
|
||||
User: msg.User,
|
||||
Comment: msg.Comment,
|
||||
UnshelveAtUtc: null);
|
||||
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(AlarmCommandsTopic.Name, alarmCmd));
|
||||
|
||||
_log.Info("AdminOps: Acknowledge published for alarm {AlarmId} by {User}", msg.AlarmId, msg.User);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(true, null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId}", msg.AlarmId);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI Shelve / Unshelve path. Maps <see cref="ShelveAlarmCommand.Kind"/> to the matching
|
||||
/// <see cref="AlarmCommand"/> operation (<c>OneShotShelve</c> / <c>TimedShelve</c> /
|
||||
/// <c>Unshelve</c>), threading <see cref="ShelveAlarmCommand.UnshelveAtUtc"/> for the timed kind,
|
||||
/// and publishes onto the cluster <c>alarm-commands</c> topic. Ownership filtering happens on the
|
||||
/// owning node exactly as for Acknowledge.
|
||||
/// </summary>
|
||||
private void HandleShelveAlarm(ShelveAlarmCommand msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
try
|
||||
{
|
||||
var (operation, unshelveAt) = msg.Kind switch
|
||||
{
|
||||
ShelveKind.OneShot => ("OneShotShelve", (DateTime?)null),
|
||||
ShelveKind.Timed => ("TimedShelve", msg.UnshelveAtUtc),
|
||||
ShelveKind.Unshelve => ("Unshelve", (DateTime?)null),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(msg), msg.Kind, "Unknown shelve kind."),
|
||||
};
|
||||
|
||||
// TimedShelve requires an unshelve instant — the engine rejects it otherwise. Guard here so
|
||||
// the AdminUI gets an immediate, attributable failure instead of a silently-dropped command.
|
||||
if (msg.Kind == ShelveKind.Timed && unshelveAt is null)
|
||||
{
|
||||
replyTo.Tell(new ShelveAlarmResult(false, "TimedShelve requires UnshelveAtUtc.", msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
var alarmCmd = new AlarmCommand(
|
||||
AlarmId: msg.AlarmId,
|
||||
Operation: operation,
|
||||
User: msg.User,
|
||||
Comment: msg.Comment,
|
||||
UnshelveAtUtc: unshelveAt);
|
||||
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(AlarmCommandsTopic.Name, alarmCmd));
|
||||
|
||||
_log.Info("AdminOps: {Operation} published for alarm {AlarmId} by {User}", operation, msg.AlarmId, msg.User);
|
||||
replyTo.Tell(new ShelveAlarmResult(true, null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId}", msg.Kind, msg.AlarmId);
|
||||
replyTo.Tell(new ShelveAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStartDeploymentAsync(StartDeployment msg)
|
||||
|
||||
@@ -61,8 +61,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// <summary>The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as <see cref="AlarmCommand"/>s.
|
||||
/// The OPC UA node manager's condition handlers build the command (after the <c>AlarmAck</c> role
|
||||
/// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it.</summary>
|
||||
public const string AlarmCommandsTopic = "alarm-commands";
|
||||
/// gate, T18) or the AdminUI path republishes via <c>AdminOperationsActor</c> (T21); this host's
|
||||
/// boot wiring subscribes; T19's engine-side handler consumes it. Re-exports the single Commons
|
||||
/// const so every publisher/subscriber shares one literal.</summary>
|
||||
public const string AlarmCommandsTopic = ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommandsTopic.Name;
|
||||
|
||||
/// <summary>Reconcile the loaded alarm set to exactly the enabled subset of <paramref name="Plans"/>:
|
||||
/// builds <see cref="ScriptedAlarmDefinition"/>s (skipping disabled plans), reloads the engine, and
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
@@ -14,6 +16,136 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes a probe to the cluster <c>alarm-commands</c> topic and waits for the
|
||||
/// <see cref="SubscribeAck"/> so the subscription is live before the actor under test
|
||||
/// publishes. Returns the subscribed probe.
|
||||
/// </summary>
|
||||
private TestProbe SubscribeAlarmCommandsProbe()
|
||||
{
|
||||
var probe = CreateTestProbe("alarm-cmds");
|
||||
var mediator = DistributedPubSub.Get(Sys).Mediator;
|
||||
// Send the Subscribe FROM the probe so the SubscribeAck routes back to it (the ack goes to the
|
||||
// message sender, not to the subscribed ref). Mirrors ScriptedAlarmHostActor's self-subscribe.
|
||||
probe.Send(mediator, new Subscribe(AlarmCommandsTopic.Name, probe.Ref));
|
||||
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||
return probe;
|
||||
}
|
||||
|
||||
/// <summary>Verifies an <see cref="AcknowledgeAlarmCommand"/> publishes a correctly-mapped
|
||||
/// <see cref="AlarmCommand"/> (Operation="Acknowledge", AlarmId/User/Comment threaded, no
|
||||
/// UnshelveAtUtc) onto the <c>alarm-commands</c> topic and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_publishes_mapped_command_and_replies_ok()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var correlationId = Guid.NewGuid();
|
||||
actor.Tell(new AcknowledgeAlarmCommand("alarm-42", "operator-jo", "looking into it", correlationId));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-42");
|
||||
published.Operation.ShouldBe("Acknowledge");
|
||||
published.User.ShouldBe("operator-jo");
|
||||
published.Comment.ShouldBe("looking into it");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
var reply = ExpectMsg<AcknowledgeAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
reply.Message.ShouldBeNull();
|
||||
reply.CorrelationId.ShouldBe(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.OneShot"/> shelve maps to Operation="OneShotShelve"
|
||||
/// with no UnshelveAtUtc and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_oneshot_publishes_OneShotShelve()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var correlationId = Guid.NewGuid();
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-7", "op-kim", ShelveKind.OneShot, UnshelveAtUtc: null, Comment: null, correlationId));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-7");
|
||||
published.Operation.ShouldBe("OneShotShelve");
|
||||
published.User.ShouldBe("op-kim");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
reply.CorrelationId.ShouldBe(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve maps to Operation="TimedShelve" and
|
||||
/// threads the UnshelveAtUtc through to the published command.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_timed_publishes_TimedShelve_with_unshelve_time()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var unshelveAt = DateTime.UtcNow.AddMinutes(15);
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-9", "op-lee", ShelveKind.Timed, unshelveAt, Comment: "maint window", Guid.NewGuid()));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-9");
|
||||
published.Operation.ShouldBe("TimedShelve");
|
||||
published.UnshelveAtUtc.ShouldBe(unshelveAt);
|
||||
published.Comment.ShouldBe("maint window");
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Unshelve"/> maps to Operation="Unshelve" with no
|
||||
/// UnshelveAtUtc and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_unshelve_publishes_Unshelve()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-3", "op-sam", ShelveKind.Unshelve, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.Operation.ShouldBe("Unshelve");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve with a null UnshelveAtUtc is rejected
|
||||
/// at the singleton (no publish) with an attributable failure — the engine requires the instant.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_timed_without_unshelve_time_is_rejected_and_not_published()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-1", "op-zoe", ShelveKind.Timed, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("UnshelveAtUtc");
|
||||
|
||||
// No command should have been published.
|
||||
topicProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that starting a deployment inserts a row and dispatches to the coordinator.</summary>
|
||||
[Fact]
|
||||
public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()
|
||||
|
||||
Reference in New Issue
Block a user