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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user