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()