From 61193629b64aea068137e86793cd68e28b461ab1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 16:38:32 -0400 Subject: [PATCH] fix(adminui): wire Test Connect probes + live panels on admin-only nodes Both bugs surfaced only on split-role deployments (the MAIN cluster's admin-only nodes), where the AdminUI runs without the driver role. - Test Connect returned "No probe registered" for every driver: the IDriverProbe set was registered only under the driver role, but the admin-operations singleton that consumes it is pinned to admin. Extract AddOtOpcUaDriverProbes() (idempotent via TryAddEnumerable) and call it in the hasAdmin path too. - Live driver-status/alerts/script-log panels showed "SignalR error: Connection refused": these Blazor Server components opened a HubConnection to their own hub via the browser's public URL, which server-side code can't reach behind Traefik (host :9200 -> container :9000). Read the in-process source directly instead -- DriverStatus via IDriverStatusSnapshotStore.SnapshotChanged, Alerts/ScriptLog via a new IInProcessBroadcaster. Fleet status was unaffected (reads DB/ActorSystem). Adds unit tests for probe registration, the snapshot-store event, and the broadcaster. --- .../Components/Pages/Alerts.razor | 44 ++++-------- .../Components/Pages/ScriptLog.razor | 43 ++++-------- .../Shared/Drivers/DriverStatusPanel.razor | 62 ++++++++++------- .../Hubs/AlertSignalRBridge.cs | 13 +++- .../Hubs/HubServiceCollectionExtensions.cs | 19 +++-- .../Hubs/IDriverStatusSnapshotStore.cs | 13 +++- .../Hubs/IInProcessBroadcaster.cs | 41 +++++++++++ .../Hubs/InMemoryDriverStatusSnapshotStore.cs | 9 ++- .../Hubs/ScriptLogSignalRBridge.cs | 13 +++- .../Drivers/DriverFactoryBootstrap.cs | 53 ++++++++++---- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 5 ++ .../DriverStatusSnapshotStoreTests.cs | 59 ++++++++++++++++ .../InProcessBroadcasterTests.cs | 51 ++++++++++++++ .../DriverProbeRegistrationTests.cs | 69 +++++++++++++++++++ 14 files changed, 388 insertions(+), 106 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index d8755fa8..4907fa4a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -4,11 +4,10 @@ and the AB CIP ALMD bridge. *@ @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer -@using Microsoft.AspNetCore.SignalR.Client @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts -@inject NavigationManager Nav -@implements IAsyncDisposable +@inject IInProcessBroadcaster Alarms +@implements IDisposable

Alerts

@@ -73,36 +72,26 @@ else private const int Capacity = 200; private readonly List _rows = new(); - private HubConnection? _hub; private bool _connected; - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri(AlertHub.Endpoint)) - .WithAutomaticReconnect() - .Build(); + // Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the + // 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection + // behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead. + Alarms.Received += OnAlarm; + _connected = true; + } - _hub.On(AlertHub.MethodName, evt => + private void OnAlarm(AlarmTransitionEvent evt) => + // Marshal both the mutation and the re-render onto the circuit sync context so this can't + // race ClearAsync (which runs there) over the shared _rows list. + InvokeAsync(() => { _rows.Insert(0, evt); if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - InvokeAsync(StateHasChanged); + StateHasChanged(); }); - _hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); }; - _hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); }; - - try - { - await _hub.StartAsync(); - _connected = true; - } - catch - { - // Connection failures (admin-only deployment, hub not mapped, etc.) leave the page - // showing "disconnected" — operator action: reload or talk to the host operator. - } - } private async Task ClearAsync() { @@ -119,8 +108,5 @@ else _ => "chip-idle", }; - public async ValueTask DisposeAsync() - { - if (_hub is not null) await _hub.DisposeAsync(); - } + public void Dispose() => Alarms.Received -= OnAlarm; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor index fe28f7d8..08192305 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -3,11 +3,10 @@ VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@ @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer -@using Microsoft.AspNetCore.SignalR.Client @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging -@inject NavigationManager Nav -@implements IAsyncDisposable +@inject IInProcessBroadcaster ScriptLogs +@implements IDisposable

Script log

@@ -87,7 +86,6 @@ else private const int Capacity = 500; private readonly List _rows = new(); - private HubConnection? _hub; private bool _connected; private string _levelFilter = ""; private string _scriptFilter = ""; @@ -115,32 +113,24 @@ else } } - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri(ScriptLogHub.Endpoint)) - .WithAutomaticReconnect() - .Build(); + // Live tail straight from the in-process broadcaster (fed by ScriptLogSignalRBridge off the + // 'script-logs' DPS topic). Blazor Server can't self-connect a SignalR HubConnection behind + // a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead. + ScriptLogs.Received += OnEntry; + _connected = true; + } - _hub.On(ScriptLogHub.MethodName, entry => + private void OnEntry(ScriptLogEntry entry) => + // Marshal both the mutation and the re-render onto the circuit sync context so this can't + // race ClearAsync (which runs there) over the shared _rows list. + InvokeAsync(() => { _rows.Insert(0, entry); if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - InvokeAsync(StateHasChanged); + StateHasChanged(); }); - _hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); }; - _hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); }; - - try - { - await _hub.StartAsync(); - _connected = true; - } - catch - { - // Connection error — page shows "disconnected". - } - } private async Task ClearAsync() { @@ -156,8 +146,5 @@ else _ => "chip-idle", }; - public async ValueTask DisposeAsync() - { - if (_hub is not null) await _hub.DisposeAsync(); - } + public void Dispose() => ScriptLogs.Received -= OnEntry; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor index 02441c01..8c4a1855 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor @@ -4,14 +4,14 @@ DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@ @implements IAsyncDisposable @using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers -@inject NavigationManager Nav @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @inject IAdminOperationsClient AdminOps +@inject IDriverStatusSnapshotStore StatusStore
@@ -139,7 +139,6 @@ [Parameter] public string ClusterId { get; set; } = ""; [Parameter] public bool Enabled { get; set; } = true; - private HubConnection? _hub; private DriverHealthChanged? _snapshot; private DateTime _lastUpdateUtc = DateTime.MinValue; private bool _stale; @@ -180,30 +179,44 @@ InvokeAsync(StateHasChanged); }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus")) - .WithAutomaticReconnect() - .Build(); - - _hub.On("status", snap => - { - _snapshot = snap; - _lastUpdateUtc = DateTime.UtcNow; - _stale = false; - InvokeAsync(StateHasChanged); - }); - + // Read live status straight from the in-process snapshot store rather than opening a + // self-targeted SignalR connection. This component runs server-side (Blazor + // InteractiveServer), so a HubConnection to the browser's public URL (e.g. + // http://localhost:9200 behind Traefik) would dial that port from *inside* the container — + // where only Kestrel's :9000 listens — and fail with "Connection refused". The store is fed + // on every admin node by DriverStatusSignalRBridge (a per-node DistributedPubSub + // subscriber), so the local singleton is always current regardless of which replica serves + // this circuit. try { - await _hub.StartAsync(); - _connecting = false; - await _hub.InvokeAsync("JoinDriver", DriverInstanceId); + StatusStore.SnapshotChanged += OnSnapshotChanged; + if (StatusStore.TryGet(DriverInstanceId, out var snap)) + { + _snapshot = snap; + _lastUpdateUtc = DateTime.UtcNow; + } } catch (Exception ex) { - _connecting = false; _error = ex.Message; } + finally + { + _connecting = false; + } + } + + // Invoked by the snapshot store (on the bridge actor's thread) for every driver instance; + // ignore snapshots for other instances and marshal onto the render sync context. + private void OnSnapshotChanged(DriverHealthChanged snap) + { + if (!string.Equals(snap.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal)) + return; + + _snapshot = snap; + _lastUpdateUtc = DateTime.UtcNow; + _stale = false; + InvokeAsync(StateHasChanged); } private async Task ReconnectAsync() @@ -285,12 +298,13 @@ public async ValueTask DisposeAsync() { - // Drain BOTH timers first so an in-flight callback can't invoke StateHasChanged on - // a component whose hub has already been released. System.Threading.Timer's async - // dispose awaits any in-flight callback (.NET 6+). + // Unsubscribe first so the singleton store can't invoke a handler on a disposed component. + StatusStore.SnapshotChanged -= OnSnapshotChanged; + // Drain BOTH timers so an in-flight callback can't invoke StateHasChanged on a component + // that's already gone. System.Threading.Timer's async dispose awaits any in-flight + // callback (.NET 6+). if (_timer is not null) await _timer.DisposeAsync(); if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync(); - if (_hub is not null) await _hub.DisposeAsync(); } // Map DriverState string → chip CSS class using the 4 defined theme variants. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs index 1ede186b..906db2a3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs @@ -17,22 +17,26 @@ public sealed class AlertSignalRBridge : ReceiveActor public const string TopicName = "alerts"; private readonly IHubContext _hub; + private readonly IInProcessBroadcaster _broadcaster; private readonly ILoggingAdapter _log = Context.GetLogger(); /// /// Creates actor props for the AlertSignalRBridge. /// /// The SignalR hub context to send alerts to. - public static Props Props(IHubContext hub) => - Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub)); + /// In-process fan-out read directly by the Blazor Server Alerts page. + public static Props Props(IHubContext hub, IInProcessBroadcaster broadcaster) => + Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub, broadcaster)); /// /// Initializes a new instance of the AlertSignalRBridge actor. /// /// The SignalR hub context to send alerts to. - public AlertSignalRBridge(IHubContext hub) + /// In-process fan-out read directly by the Blazor Server Alerts page. + public AlertSignalRBridge(IHubContext hub, IInProcessBroadcaster broadcaster) { _hub = hub; + _broadcaster = broadcaster; ReceiveAsync(ForwardAsync); Receive(_ => { /* DPS confirmation */ }); } @@ -43,6 +47,9 @@ public sealed class AlertSignalRBridge : ReceiveActor private async Task ForwardAsync(AlarmTransitionEvent msg) { + // In-process fan-out first — this is what the Blazor Server Alerts page reads. The hub push + // is kept for any out-of-process (e.g. WASM) SignalR client. + _broadcaster.Publish(msg); try { await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg); 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 fc26e05d..3e5a2fff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -13,14 +13,21 @@ public static class HubServiceCollectionExtensions public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge"; /// - /// Registers services required by the driver-status hub pipeline: - /// as a singleton backed by - /// . + /// Registers the in-process live-push services the AdminUI's Blazor Server panels read + /// directly (instead of self-connecting a SignalR HubConnection, which fails behind a + /// reverse proxy — see ): + /// + /// — last-value snapshot per driver. + /// — append-stream fan-out (alarm + /// transitions, script-log lines). Registered as an open generic so each closed type + /// resolves to its own singleton shared by the bridge actor and the consuming component. + /// /// /// The service collection. public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(typeof(IInProcessBroadcaster<>), typeof(InProcessBroadcaster<>)); return services; } @@ -48,11 +55,13 @@ public static class HubServiceCollectionExtensions registry.Register(fleetBridge); var alertHub = resolver.GetService>(); - var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName); + var alertBroadcaster = 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), ScriptLogSignalRBridgeName); + var scriptLogBroadcaster = resolver.GetService>(); + var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub, scriptLogBroadcaster), ScriptLogSignalRBridgeName); registry.Register(scriptLogBridge); var driverStatusHub = resolver.GetService>(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs index 23c4ab79..0527d555 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs @@ -6,10 +6,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; /// Singleton last-snapshot-per-instance cache. Populated by /// DriverStatusSignalRBridge as it forwards DPS messages; read by /// so newly-joined clients see current state -/// without waiting for the next change event. +/// without waiting for the next change event, and subscribed to directly by the Blazor +/// Server DriverStatusPanel via . /// public interface IDriverStatusSnapshotStore { void Upsert(DriverHealthChanged snapshot); bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot); + + /// + /// Raised after every with the just-stored snapshot. Lets in-process + /// consumers (the Blazor Server DriverStatusPanel) receive live updates by reading + /// this singleton directly instead of opening a self-targeted SignalR connection — which a + /// server-side Blazor component cannot reach when the public URL (e.g. a reverse-proxy port) + /// differs from the local Kestrel bind. Handlers run on the caller's thread (the bridge + /// actor), so subscribers must marshal to their own sync context. + /// + event Action? SnapshotChanged; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs new file mode 100644 index 00000000..46e0d9e8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// A singleton, in-process fan-out for live event streams (alarm transitions, script-log +/// lines). A per-node SignalR bridge actor subscribes to the cluster's DistributedPubSub topic +/// and calls ; Blazor Server components subscribe to +/// to render the live tail. +/// +/// This exists because the AdminUI runs as Blazor Server: a component opening a +/// SignalR HubConnection to its own hub would dial the browser's public URL from +/// server-side code, which is unreachable behind a reverse proxy (e.g. Traefik mapping host +/// :9200 → container :9000) and so fails with "Connection refused". Reading this in-process +/// broadcaster instead avoids the network hop entirely. Mirrors the +/// IDriverStatusSnapshotStore.SnapshotChanged pattern for stream (vs. last-value) feeds. +/// +/// +/// The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry). +public interface IInProcessBroadcaster +{ + /// + /// Raised once per with the published item. Handlers run on the + /// caller's thread (the bridge actor), so subscribers must marshal to their own sync + /// context (Blazor's InvokeAsync). + /// + event Action? Received; + + /// Fan the item out to all current subscribers. + void Publish(T item); +} + +/// Thread-safe singleton implementation of . +/// The event payload type. +public sealed class InProcessBroadcaster : IInProcessBroadcaster +{ + /// + public event Action? Received; + + /// + // Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise. + public void Publish(T item) => Received?.Invoke(item); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs index 9f272d78..353fe6de 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs @@ -11,9 +11,16 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto { private readonly ConcurrentDictionary _byInstance = new(); + /// + public event Action? SnapshotChanged; + /// public void Upsert(DriverHealthChanged snapshot) - => _byInstance[snapshot.DriverInstanceId] = snapshot; + { + _byInstance[snapshot.DriverInstanceId] = snapshot; + // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise. + SnapshotChanged?.Invoke(snapshot); + } /// public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs index 3b4fd41c..8fac9fb6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs @@ -15,18 +15,22 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor public const string TopicName = "script-logs"; private readonly IHubContext _hub; + private readonly IInProcessBroadcaster _broadcaster; private readonly ILoggingAdapter _log = Context.GetLogger(); /// Creates a Props instance for the ScriptLogSignalRBridge. /// The SignalR hub context for sending messages to clients. - public static Props Props(IHubContext hub) => - Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub)); + /// In-process fan-out read directly by the Blazor Server Script log page. + public static Props Props(IHubContext hub, IInProcessBroadcaster broadcaster) => + Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub, broadcaster)); /// Initializes a new instance of the class. /// The SignalR hub context for sending messages to clients. - public ScriptLogSignalRBridge(IHubContext hub) + /// In-process fan-out read directly by the Blazor Server Script log page. + public ScriptLogSignalRBridge(IHubContext hub, IInProcessBroadcaster broadcaster) { _hub = hub; + _broadcaster = broadcaster; ReceiveAsync(ForwardAsync); Receive(_ => { /* DPS confirmation */ }); } @@ -37,6 +41,9 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor private async Task ForwardAsync(ScriptLogEntry msg) { + // In-process fan-out first — this is what the Blazor Server Script log page reads. The hub + // push is kept for any out-of-process (e.g. WASM) SignalR client. + _broadcaster.Publish(msg); try { await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 845e0232..26ef2284 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; @@ -23,8 +24,10 @@ using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDri /// over it. Replaces the F7 seam's NullDriverFactory default so deploys actually /// materialise real instances on driver-role nodes. /// -/// Skipped entirely on admin-only nodes — they never run drivers, so the registry doesn't -/// need to exist (Program.cs guards via the hasDriver flag). +/// The factory registry is skipped on admin-only nodes — they never run drivers, so it doesn't +/// need to exist (Program.cs guards via the hasDriver flag). The driver probe +/// set is the exception: it backs the AdminUI Test Connect button and so must also be wired on +/// admin nodes — see . /// public static class DriverFactoryBootstrap { @@ -46,16 +49,42 @@ public static class DriverFactoryBootstrap services.AddSingleton(sp => new DriverFactoryRegistryAdapter(sp.GetRequiredService())); - // One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration. - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only + // case is covered by Program.cs calling AddOtOpcUaDriverProbes() in the hasAdmin block. + services.AddOtOpcUaDriverProbes(); + + return services; + } + + /// + /// Register one per driver type. These back the AdminUI's + /// "Test Connect" button: the admin-operations cluster singleton resolves + /// of and dispatches by DriverType. + /// + /// That singleton is role-pinned to admin, so this MUST be wired on admin nodes — + /// including admin-only nodes that lack the driver role (e.g. the MAIN cluster's + /// admin-a/admin-b). Probes are lightweight (cheap connect, no persistent state) and don't + /// need the driver-factory registry, so they register independently of + /// . + /// + /// + /// Uses TryAddEnumerable so a fused admin,driver node — which reaches this from both + /// the driver-factory path and the admin path — registers each probe exactly once. A + /// duplicate would make the singleton's ToDictionary(p => p.DriverType) throw. + /// + /// + /// The service collection to register driver probes with. + public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index d627f2dc..4a9dd50c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -122,6 +122,11 @@ if (hasAdmin) // Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI. builder.Services.AddOtOpcUaAuth(builder.Configuration); builder.Services.AddAdminUI(); + // Test Connect probes back the AdminUI driver pages. The admin-operations singleton (role-pinned + // to admin) resolves IEnumerable, so admin-only nodes — which skip the hasDriver + // block above — must wire the probe set here too, or every Test Connect returns "No probe + // registered". Idempotent on fused admin,driver nodes (TryAddEnumerable de-dups). + builder.Services.AddOtOpcUaDriverProbes(); // Flow AuthenticationState through cascading parameters so works // inside interactive components (NavSidebar's session block). builder.Services.AddCascadingAuthenticationState(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs new file mode 100644 index 00000000..410c5e53 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Covers the in-process push contract the Blazor Server DriverStatusPanel relies on: +/// fires on every +/// , and TryGet returns the latest. +/// The panel subscribes to this store directly instead of opening a self-targeted SignalR +/// connection (which a server-side component can't reach behind a reverse proxy). +/// +public sealed class DriverStatusSnapshotStoreTests +{ + private static DriverHealthChanged Snap(string instance, string state = "Healthy") => + new("MAIN", instance, state, null, null, 0, new DateTime(2026, 5, 29, 0, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void Upsert_raises_SnapshotChanged_with_the_stored_snapshot() + { + var store = new InMemoryDriverStatusSnapshotStore(); + var received = new List(); + store.SnapshotChanged += received.Add; + + var snap = Snap("drv-1", "Faulted"); + store.Upsert(snap); + + received.Count.ShouldBe(1); + received[0].ShouldBeSameAs(snap); + } + + [Fact] + public void Upsert_then_TryGet_returns_the_latest_snapshot() + { + var store = new InMemoryDriverStatusSnapshotStore(); + store.Upsert(Snap("drv-1", "Healthy")); + store.Upsert(Snap("drv-1", "Degraded")); + + store.TryGet("drv-1", out var latest).ShouldBeTrue(); + latest.State.ShouldBe("Degraded"); + } + + [Fact] + public void Unsubscribed_handler_stops_receiving_after_removal() + { + var store = new InMemoryDriverStatusSnapshotStore(); + var count = 0; + void Handler(DriverHealthChanged _) => count++; + + store.SnapshotChanged += Handler; + store.Upsert(Snap("drv-1")); + store.SnapshotChanged -= Handler; + store.Upsert(Snap("drv-1")); + + count.ShouldBe(1); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs new file mode 100644 index 00000000..263a31da --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs @@ -0,0 +1,51 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Covers the in-process fan-out the Blazor Server Alerts / Script log pages rely on: +/// raises Received for every current +/// subscriber, and unsubscribing stops delivery. These pages read this broadcaster directly +/// instead of opening a self-targeted SignalR connection (unreachable behind a reverse proxy). +/// +public sealed class InProcessBroadcasterTests +{ + [Fact] + public void Publish_raises_Received_for_all_current_subscribers() + { + var broadcaster = new InProcessBroadcaster(); + var a = new List(); + var b = new List(); + broadcaster.Received += a.Add; + broadcaster.Received += b.Add; + + broadcaster.Publish("evt-1"); + + a.ShouldBe(["evt-1"]); + b.ShouldBe(["evt-1"]); + } + + [Fact] + public void Unsubscribed_handler_stops_receiving() + { + var broadcaster = new InProcessBroadcaster(); + var received = new List(); + void Handler(string s) => received.Add(s); + + broadcaster.Received += Handler; + broadcaster.Publish("first"); + broadcaster.Received -= Handler; + broadcaster.Publish("second"); + + received.ShouldBe(["first"]); + } + + [Fact] + public void Publish_with_no_subscribers_does_not_throw() + { + var broadcaster = new InProcessBroadcaster(); + Should.NotThrow(() => broadcaster.Publish(42)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs new file mode 100644 index 00000000..67286577 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Host.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Guards the Test Connect wiring contract: every driver type editable in the AdminUI must have +/// a registered , resolvable from the same DI container that hosts the +/// admin-operations cluster singleton. The singleton is role-pinned to admin, so on +/// a split-role deployment (the MAIN cluster's admin-only nodes) the probes must be wired by the +/// admin path — not only the driver path — or every Test Connect button returns +/// "No probe registered for driver type X". +/// +public sealed class DriverProbeRegistrationTests +{ + // The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey. + // Keep in sync with the DriverTypeKey constants in + // src/Server/.../Components/Pages/Clusters/Drivers/*DriverPage.razor. + private static readonly string[] AdminUiDriverTypeKeys = + [ + "ModbusTcp", + "AbCip", + "AbLegacy", + "S7", + "TwinCat", // page key; probe reports "TwinCAT" — must resolve case-insensitively + "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively + "OpcUaClient", + "GalaxyMxGateway", + "Historian.Wonderware", + ]; + + [Fact] + public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type() + { + var services = new ServiceCollection(); + services.AddOtOpcUaDriverProbes(); + + using var sp = services.BuildServiceProvider(); + var probes = sp.GetServices().ToList(); + + // No duplicate DriverType — AdminOperationsActor builds a dictionary keyed by DriverType + // (case-insensitive) and would throw on a duplicate key, crashing the singleton. + var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); + + foreach (var key in AdminUiDriverTypeKeys) + byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'."); + } + + [Fact] + public void AddOtOpcUaDriverProbes_is_idempotent() + { + // A fused admin,driver node calls the registration from both the driver-factory path and the + // admin path. TryAddEnumerable must de-dup so the probe set stays unique (else the actor's + // ToDictionary throws). + var services = new ServiceCollection(); + services.AddOtOpcUaDriverProbes(); + services.AddOtOpcUaDriverProbes(); + + using var sp = services.BuildServiceProvider(); + var probes = sp.GetServices().ToList(); + + var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup."); + distinctTypes.ShouldBe(AdminUiDriverTypeKeys.Length); + } +}