diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs index f3675564..cf664c67 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -1,7 +1,14 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.AdminUI.Telemetry; +using ZB.MOM.WW.OtOpcUa.Cluster; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; @@ -12,6 +19,7 @@ public static class HubServiceCollectionExtensions public const string ScriptLogSignalRBridgeName = "script-log-signalr-bridge"; public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge"; public const string DriverResilienceStatusBridgeName = "driver-resilience-status-bridge"; + public const string TelemetryDialSupervisorName = "telemetry-dial-supervisor"; /// /// Registers the in-process live-push services the AdminUI's Blazor Server panels read @@ -54,30 +62,69 @@ public static class HubServiceCollectionExtensions { builder.WithActors((system, registry, resolver) => { + // Fleet-status always stays on DPS in both modes (deferred / out of Phase 5 scope) — it is + // never gated by the telemetry dark switch. var fleetHub = resolver.GetService>(); var fleetBridge = system.ActorOf(FleetStatusSignalRBridge.Props(fleetHub), FleetStatusSignalRBridgeName); registry.Register(fleetBridge); - var alertHub = resolver.GetService>(); + // The four telemetry sinks are registered identically in both modes; only the upstream that + // feeds them swaps (DPS bridges vs. the gRPC dial supervisor). Resolve them once, above the + // branch, so both paths feed the SAME singletons the Blazor panels read. var alertBroadcaster = resolver.GetService>(); - var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub, alertBroadcaster), AlertSignalRBridgeName); - registry.Register(alertBridge); - - var scriptLogHub = resolver.GetService>(); var scriptLogBroadcaster = resolver.GetService>(); - var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub, scriptLogBroadcaster), ScriptLogSignalRBridgeName); - registry.Register(scriptLogBridge); - - var driverStatusHub = resolver.GetService>(); var driverStatusStore = resolver.GetService(); - var driverStatusBridge = system.ActorOf(DriverStatusSignalRBridge.Props(driverStatusHub, driverStatusStore), DriverStatusSignalRBridgeName); - registry.Register(driverStatusBridge); - - // Resilience-status bridge: DPS topic -> in-process store (no SignalR hub — the panel reads - // the store directly, and resilience has no browser-JS consumer). var resilienceStore = resolver.GetService(); - var resilienceBridge = system.ActorOf(DriverResilienceStatusBridge.Props(resilienceStore), DriverResilienceStatusBridgeName); - registry.Register(resilienceBridge); + + // Phase 5 dark switch. Absent options ⇒ Dps (today's behaviour); case-insensitive. + var telemetryMode = resolver.GetService>()?.Value.Mode + ?? TelemetryDialOptions.ModeDps; + + if (string.Equals(telemetryMode, TelemetryDialOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase)) + { + // Grpc: central dials each enabled node's dedicated telemetry stream, feeding the SAME + // four sinks the DPS bridges feed. No DPS telemetry bridges are spawned. + var options = resolver.GetService>()!.Value; + var dbFactory = resolver.GetService>(); + var loggerFactory = resolver.GetService(); + + var nodeSource = TelemetryNodeSource.Create( + dbFactory!, loggerFactory!.CreateLogger(typeof(TelemetryNodeSource).FullName!)); + var dialLoop = TelemetryNodeSource.CreateDialLoop( + options.ApiKey, loggerFactory.CreateLogger()); + + var supervisor = system.ActorOf( + TelemetryDialSupervisor.Props( + nodeSource, + dialLoop, + alertBroadcaster, + scriptLogBroadcaster, + driverStatusStore, + resilienceStore, + options), + TelemetryDialSupervisorName); + registry.Register(supervisor); + } + else + { + // Dps (default): the four DPS bridges subscribe their mesh-wide topics and feed the sinks. + var alertHub = resolver.GetService>(); + var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub, alertBroadcaster), AlertSignalRBridgeName); + registry.Register(alertBridge); + + var scriptLogHub = resolver.GetService>(); + var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub, scriptLogBroadcaster), ScriptLogSignalRBridgeName); + registry.Register(scriptLogBridge); + + var driverStatusHub = resolver.GetService>(); + var driverStatusBridge = system.ActorOf(DriverStatusSignalRBridge.Props(driverStatusHub, driverStatusStore), DriverStatusSignalRBridgeName); + registry.Register(driverStatusBridge); + + // Resilience-status bridge: DPS topic -> in-process store (no SignalR hub — the panel reads + // the store directly, and resilience has no browser-JS consumer). + var resilienceBridge = system.ActorOf(DriverResilienceStatusBridge.Props(resilienceStore), DriverResilienceStatusBridgeName); + registry.Register(resilienceBridge); + } }); return builder; } @@ -89,3 +136,6 @@ public sealed class AlertSignalRBridgeKey { } public sealed class ScriptLogSignalRBridgeKey { } public sealed class DriverStatusSignalRBridgeKey { } public sealed class DriverResilienceStatusBridgeKey { } + +/// Marker key for lookup of the Grpc-mode telemetry dial supervisor. +public sealed class TelemetryDialSupervisorKey { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs new file mode 100644 index 00000000..bfd3ed4d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs @@ -0,0 +1,134 @@ +using Akka.Actor; +using Akka.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.Cluster; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs; + +/// +/// Verifies the Phase 5 dark switch in : +/// TelemetryDial:Mode = Dps (default) spawns the four telemetry DPS bridge actors and NOT the +/// gRPC dial supervisor; Grpc spawns the dial supervisor and NONE of the four telemetry +/// bridges. The fleet-status bridge (deferred / out of Phase 5 scope) is spawned in BOTH modes. +/// The gate is proven on a real from a started Akka host. +/// +public sealed class TelemetryModeWiringTests +{ + /// Dps mode: the four telemetry bridges + fleet bridge are registered; the dial supervisor is not. + [Fact] + public async Task Dps_mode_spawns_the_four_telemetry_bridges_and_not_the_dial_supervisor() + { + using var host = BuildBridgeHost(TelemetryDialOptions.ModeDps); + await host.StartAsync(); + try + { + var registry = host.Services.GetRequiredService(); + + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeTrue(); + + registry.TryGet(out _).ShouldBeFalse(); + } + finally + { + await host.StopAsync(); + } + } + + /// Grpc mode: the dial supervisor + fleet bridge are registered; none of the four telemetry bridges are. + [Fact] + public async Task Grpc_mode_spawns_the_dial_supervisor_and_none_of_the_four_telemetry_bridges() + { + using var host = BuildBridgeHost(TelemetryDialOptions.ModeGrpc); + await host.StartAsync(); + try + { + var registry = host.Services.GetRequiredService(); + + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeTrue(); + + registry.TryGet(out _).ShouldBeFalse(); + registry.TryGet(out _).ShouldBeFalse(); + registry.TryGet(out _).ShouldBeFalse(); + registry.TryGet(out _).ShouldBeFalse(); + } + finally + { + await host.StopAsync(); + } + } + + /// Unset (default) mode is treated as Dps — the four bridges spawn, the supervisor does not. + [Fact] + public async Task Default_mode_is_dps() + { + using var host = BuildBridgeHost(mode: null); + await host.StartAsync(); + try + { + var registry = host.Services.GetRequiredService(); + registry.TryGet(out _).ShouldBeTrue(); + registry.TryGet(out _).ShouldBeFalse(); + } + finally + { + await host.StopAsync(); + } + } + + /// Builds an admin-role host that runs WithOtOpcUaSignalRBridges under the given telemetry mode. + /// The TelemetryDial:Mode value, or to leave it at its default. + private static IHost BuildBridgeHost(string? mode) + => Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSignalR(); + services.AddOtOpcUaDriverStatusServices(); + services.AddSingleton>( + new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); + + var options = new TelemetryDialOptions { ApiKey = "test-key" }; + if (mode is not null) + { + options.Mode = mode; + } + + services.AddSingleton>(Options.Create(options)); + + services.AddAkka("otopcua-test", (ab, _) => + { + ab.AddHocon(@" + akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" + akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" + akka.remote.dot-netty.tcp.port = 0 + akka.cluster.seed-nodes = [] + akka.cluster.roles = [""admin""] + ", HoconAddMode.Prepend); + ab.WithOtOpcUaSignalRBridges(); + }); + }) + .Build(); + + /// An whose contexts share one InMemory database. + private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } +}