diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs new file mode 100644 index 0000000..ea6c09c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs @@ -0,0 +1,52 @@ +using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Event; +using Microsoft.AspNetCore.SignalR; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// Akka actor that subscribes to the fleet-status DistributedPubSub topic and forwards +/// every snapshot to all SignalR clients connected to +/// . Spawned on admin-role nodes by +/// AddOtOpcUaSignalRBridges. +/// +/// The bridge runs locally on each admin node — every node that hosts the hub also forwards +/// snapshots to its own connected clients. That keeps the hub-to-actor wiring simple (no +/// cluster-singleton coordination needed) at the cost of duplicated DPS subscriptions on +/// multi-admin deployments. Acceptable since the messages are small + the SignalR fan-out +/// is per-node anyway. +/// +public sealed class FleetStatusSignalRBridge : ReceiveActor +{ + public const string TopicName = "fleet-status"; + + private readonly IHubContext _hub; + private readonly ILoggingAdapter _log = Context.GetLogger(); + + public static Props Props(IHubContext hub) => + Akka.Actor.Props.Create(() => new FleetStatusSignalRBridge(hub)); + + public FleetStatusSignalRBridge(IHubContext hub) + { + _hub = hub; + ReceiveAsync(ForwardAsync); + Receive(_ => { /* DPS confirmation */ }); + } + + protected override void PreStart() => + DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self)); + + private async Task ForwardAsync(FleetStatusChanged msg) + { + try + { + await _hub.Clients.All.SendAsync(FleetStatusHub.MethodName, msg); + } + catch (Exception ex) + { + _log.Warning(ex, "FleetStatusSignalRBridge: SignalR push failed (count={Count})", msg.Nodes.Count); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs new file mode 100644 index 0000000..3b1e118 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +using Akka.Actor; +using Akka.Hosting; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +public static class HubServiceCollectionExtensions +{ + public const string FleetStatusSignalRBridgeName = "fleet-status-signalr-bridge"; + + /// + /// Spawns the SignalR bridge actors that forward DPS messages to browser-facing SignalR + /// hubs. Currently: (DPS fleet-status topic → + /// clients). + /// + /// Call inside the admin-role configurator on the shared : + /// + /// if (hasAdmin) + /// { + /// ab.WithOtOpcUaControlPlaneSingletons(); + /// ab.WithOtOpcUaSignalRBridges(); + /// } + /// + /// + public static AkkaConfigurationBuilder WithOtOpcUaSignalRBridges(this AkkaConfigurationBuilder builder) + { + builder.WithActors((system, registry, resolver) => + { + var hub = resolver.GetService>(); + var actor = system.ActorOf(FleetStatusSignalRBridge.Props(hub), FleetStatusSignalRBridgeName); + registry.Register(actor); + }); + return builder; + } +} + +/// Marker key for lookup of the SignalR bridge actor. +public sealed class FleetStatusSignalRBridgeKey { } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index a655b9f..b4faddb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -44,7 +44,10 @@ builder.Services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasAdmin) + { ab.WithOtOpcUaControlPlaneSingletons(); + ab.WithOtOpcUaSignalRBridges(); + } if (hasDriver) ab.WithOtOpcUaRuntimeActors(); });