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:
Joseph Doherty
2026-06-11 06:44:27 -04:00
parent f742050ebd
commit 370a2b7b48
9 changed files with 503 additions and 3 deletions
@@ -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