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:
@@ -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