diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs
index be992f84..a32c5b45 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs
@@ -15,6 +15,36 @@ public interface IAdminOperationsClient
/// A task representing the asynchronous operation containing the deployment start result.
Task StartDeploymentAsync(string createdBy, CancellationToken ct);
+ ///
+ /// Acknowledges one alarm via the admin singleton, which republishes the mapped command onto
+ /// the cluster alarm-commands topic for the owning node to apply.
+ ///
+ /// The alarm's ScriptedAlarmId.
+ /// The acting operator's name (for audit + the alarm-event User field).
+ /// Optional free-text comment; null when none.
+ /// The cancellation token.
+ /// The acknowledge result.
+ Task AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct);
+
+ ///
+ /// Shelves or unshelves one alarm via the admin singleton, which republishes the mapped command
+ /// onto the cluster alarm-commands topic for the owning node to apply.
+ ///
+ /// The alarm's ScriptedAlarmId.
+ /// The acting operator's name (for audit + the alarm-event User field).
+ /// Which shelve action to perform (OneShot / Timed / Unshelve).
+ /// For , when the shelve expires; null otherwise.
+ /// Optional free-text comment; null when none.
+ /// The cancellation token.
+ /// The shelve result.
+ Task ShelveAlarmAsync(
+ string alarmId,
+ string user,
+ Messages.Admin.ShelveKind kind,
+ DateTime? unshelveAtUtc,
+ string? comment,
+ CancellationToken ct);
+
///
/// Generic Ask: forwards to the AdminOperationsActor
/// cluster-singleton proxy and awaits a reply of type .
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/AcknowledgeAlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/AcknowledgeAlarmCommand.cs
new file mode 100644
index 00000000..aad6289d
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/AcknowledgeAlarmCommand.cs
@@ -0,0 +1,28 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+
+///
+/// AdminUI → AdminOperationsActor: acknowledge one alarm on behalf of an operator. The admin
+/// singleton maps this to a Commons.OpcUa.AlarmCommand with Operation = "Acknowledge"
+/// and republishes it onto the cluster alarm-commands topic, where the owning
+/// ScriptedAlarmHostActor 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.
+///
+/// The alarm's ScriptedAlarmId (the AlarmTransitionEvent.AlarmId shown on the row).
+/// The authenticated operator who triggered the acknowledgement.
+/// Optional free-text comment supplied with the acknowledgement; null when none.
+/// Round-trip correlation token.
+public sealed record AcknowledgeAlarmCommand(
+ string AlarmId,
+ string User,
+ string? Comment,
+ Guid CorrelationId);
+
+/// Reply for .
+/// True iff the command was published without error.
+/// Failure reason; null on success.
+/// Echoes the request's correlation token.
+public sealed record AcknowledgeAlarmResult(
+ bool Ok,
+ string? Message,
+ Guid CorrelationId);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ShelveAlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ShelveAlarmCommand.cs
new file mode 100644
index 00000000..f188ae2a
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ShelveAlarmCommand.cs
@@ -0,0 +1,52 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+
+///
+/// The shelve action an operator requested. Maps 1:1 onto the engine-side Operation
+/// strings the ScriptedAlarmHostActor dispatches on
+/// (OneShotShelve / TimedShelve / Unshelve).
+///
+public enum ShelveKind
+{
+ /// Shelve until the next clear (no timer). Maps to Operation = "OneShotShelve".
+ OneShot,
+
+ /// Shelve until an absolute instant. Maps to Operation = "TimedShelve"; carries UnshelveAtUtc.
+ Timed,
+
+ /// Remove an existing shelve. Maps to Operation = "Unshelve".
+ Unshelve,
+}
+
+///
+/// AdminUI → AdminOperationsActor: shelve / unshelve one alarm on behalf of an operator. The admin
+/// singleton maps to the matching Commons.OpcUa.AlarmCommand operation
+/// (OneShotShelve / TimedShelve / Unshelve), threading
+/// for , and republishes it onto the
+/// cluster alarm-commands topic, where the owning ScriptedAlarmHostActor filters by
+/// ownership and drives the engine.
+///
+/// The alarm's ScriptedAlarmId (the AlarmTransitionEvent.AlarmId shown on the row).
+/// The authenticated operator who triggered the shelve.
+/// Which shelve action to perform.
+///
+/// For , the absolute UTC instant the shelve auto-expires; null for
+/// and .
+///
+/// Optional free-text comment supplied with the shelve; null when none.
+/// Round-trip correlation token.
+public sealed record ShelveAlarmCommand(
+ string AlarmId,
+ string User,
+ ShelveKind Kind,
+ DateTime? UnshelveAtUtc,
+ string? Comment,
+ Guid CorrelationId);
+
+/// Reply for .
+/// True iff the command was published without error.
+/// Failure reason; null on success.
+/// Echoes the request's correlation token.
+public sealed record ShelveAlarmResult(
+ bool Ok,
+ string? Message,
+ Guid CorrelationId);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
index 7def14ed..d2045883 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
@@ -1,5 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+///
+/// Shared DPS topic for inbound alarm commands (). Publishers — the
+/// OPC UA node-manager seam (T18) and the AdminUI path via AdminOperationsActor (T21) —
+/// and the subscriber (ScriptedAlarmHostActor, T19) reference this single constant so a
+/// rename can't silently desynchronise them. Mirrors the
+/// pattern; ScriptedAlarmHostActor
+/// re-exports this as its AlarmCommandsTopic const.
+///
+public static class AlarmCommandsTopic
+{
+ /// The cluster DistributedPubSub topic name inbound alarm commands are published on.
+ public const string Name = "alarm-commands";
+}
+
///
/// 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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs
index 651ec286..96036056 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs
@@ -37,6 +37,38 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
return await _proxy.Ask(msg, AskTimeout, linked.Token);
}
+ /// Acknowledges one alarm via the admin singleton.
+ /// The alarm's ScriptedAlarmId.
+ /// The acting operator's name.
+ /// Optional free-text comment; null when none.
+ /// The cancellation token.
+ /// The acknowledge result.
+ public async Task 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(msg, AskTimeout, linked.Token);
+ }
+
+ /// Shelves or unshelves one alarm via the admin singleton.
+ /// The alarm's ScriptedAlarmId.
+ /// The acting operator's name.
+ /// Which shelve action to perform.
+ /// For a timed shelve, when it expires; null otherwise.
+ /// Optional free-text comment; null when none.
+ /// The cancellation token.
+ /// The shelve result.
+ public async Task 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(msg, AskTimeout, linked.Token);
+ }
+
///
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
/// Uses the caller-supplied for cancellation; does not impose an
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
index 4907fa4a..9ee0549b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
@@ -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 Alarms
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@inject IAdminOperationsClient AdminOps
@implements IDisposable
@@ -47,6 +53,10 @@ else
Severity
User
Message
+ @if (_canOperate)
+ {
+
Actions
+ }
@@ -60,6 +70,35 @@ else
@e.Severity
@e.User
@e.Message
+ @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). *@
+
+
+
+
+
+
+ @if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null)
+ {
+ @_opResultMessage
+ }
+
+ }
}
@@ -74,13 +113,102 @@ else
private readonly List _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();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task 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) =>
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
index 8365739a..fe5443d1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
@@ -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(HandleTestDriverConnectAsync);
ReceiveAsync(HandleRestartDriverAsync);
ReceiveAsync(HandleReconnectDriverAsync);
+ Receive(HandleAcknowledgeAlarm);
+ Receive(HandleShelveAlarm);
+ }
+
+ ///
+ /// AdminUI Acknowledge path. Maps the control-plane command to a
+ /// (Operation = "Acknowledge") and publishes it onto the
+ /// cluster alarm-commands topic. The broadcast lands on every driver node's
+ /// ScriptedAlarmHostActor; 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.
+ ///
+ 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));
+ }
+ }
+
+ ///
+ /// AdminUI Shelve / Unshelve path. Maps to the matching
+ /// operation (OneShotShelve / TimedShelve /
+ /// Unshelve), threading for the timed kind,
+ /// and publishes onto the cluster alarm-commands topic. Ownership filtering happens on the
+ /// owning node exactly as for Acknowledge.
+ ///
+ 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)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
index 491ba568..65e93433 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
@@ -61,8 +61,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
/// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as s.
/// The OPC UA node manager's condition handlers build the command (after the AlarmAck role
- /// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it.
- public const string AlarmCommandsTopic = "alarm-commands";
+ /// gate, T18) or the AdminUI path republishes via AdminOperationsActor (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.
+ public const string AlarmCommandsTopic = ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommandsTopic.Name;
/// Reconcile the loaded alarm set to exactly the enabled subset of :
/// builds s (skipping disabled plans), reloads the engine, and
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
index b52f9905..32b82e25 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
@@ -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
{
+ ///
+ /// Subscribes a probe to the cluster alarm-commands topic and waits for the
+ /// so the subscription is live before the actor under test
+ /// publishes. Returns the subscribed probe.
+ ///
+ 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(TimeSpan.FromSeconds(5));
+ return probe;
+ }
+
+ /// Verifies an publishes a correctly-mapped
+ /// (Operation="Acknowledge", AlarmId/User/Comment threaded, no
+ /// UnshelveAtUtc) onto the alarm-commands topic and replies Ok.
+ [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()));
+ var topicProbe = SubscribeAlarmCommandsProbe();
+
+ var correlationId = Guid.NewGuid();
+ actor.Tell(new AcknowledgeAlarmCommand("alarm-42", "operator-jo", "looking into it", correlationId));
+
+ var published = topicProbe.ExpectMsg(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(TimeSpan.FromSeconds(3));
+ reply.Ok.ShouldBeTrue();
+ reply.Message.ShouldBeNull();
+ reply.CorrelationId.ShouldBe(correlationId);
+ }
+
+ /// Verifies a shelve maps to Operation="OneShotShelve"
+ /// with no UnshelveAtUtc and replies Ok.
+ [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()));
+ 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(TimeSpan.FromSeconds(3));
+ published.AlarmId.ShouldBe("alarm-7");
+ published.Operation.ShouldBe("OneShotShelve");
+ published.User.ShouldBe("op-kim");
+ published.UnshelveAtUtc.ShouldBeNull();
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Ok.ShouldBeTrue();
+ reply.CorrelationId.ShouldBe(correlationId);
+ }
+
+ /// Verifies a shelve maps to Operation="TimedShelve" and
+ /// threads the UnshelveAtUtc through to the published command.
+ [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()));
+ 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(TimeSpan.FromSeconds(3));
+ published.AlarmId.ShouldBe("alarm-9");
+ published.Operation.ShouldBe("TimedShelve");
+ published.UnshelveAtUtc.ShouldBe(unshelveAt);
+ published.Comment.ShouldBe("maint window");
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Ok.ShouldBeTrue();
+ }
+
+ /// Verifies a maps to Operation="Unshelve" with no
+ /// UnshelveAtUtc and replies Ok.
+ [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()));
+ var topicProbe = SubscribeAlarmCommandsProbe();
+
+ actor.Tell(new ShelveAlarmCommand("alarm-3", "op-sam", ShelveKind.Unshelve, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
+
+ var published = topicProbe.ExpectMsg(TimeSpan.FromSeconds(3));
+ published.Operation.ShouldBe("Unshelve");
+ published.UnshelveAtUtc.ShouldBeNull();
+
+ ExpectMsg(TimeSpan.FromSeconds(3)).Ok.ShouldBeTrue();
+ }
+
+ /// Verifies a shelve with a null UnshelveAtUtc is rejected
+ /// at the singleton (no publish) with an attributable failure — the engine requires the instant.
+ [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()));
+ var topicProbe = SubscribeAlarmCommandsProbe();
+
+ actor.Tell(new ShelveAlarmCommand("alarm-1", "op-zoe", ShelveKind.Timed, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Ok.ShouldBeFalse();
+ reply.Message.ShouldNotBeNull();
+ reply.Message.ShouldContain("UnshelveAtUtc");
+
+ // No command should have been published.
+ topicProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
/// Verifies that starting a deployment inserts a row and dispatches to the coordinator.
[Fact]
public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()