fix(adminui): wire Test Connect probes + live panels on admin-only nodes
v2-ci / build (push) Failing after 36s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

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<T>. Fleet status was unaffected (reads DB/ActorSystem).

Adds unit tests for probe registration, the snapshot-store event, and the
broadcaster.
This commit is contained in:
Joseph Doherty
2026-05-29 16:38:32 -04:00
parent e3a27422a1
commit 61193629b6
14 changed files with 388 additions and 106 deletions
@@ -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;
/// <summary>
/// Covers the in-process push contract the Blazor Server <c>DriverStatusPanel</c> relies on:
/// <see cref="IDriverStatusSnapshotStore.SnapshotChanged"/> fires on every
/// <see cref="IDriverStatusSnapshotStore.Upsert"/>, and <c>TryGet</c> 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).
/// </summary>
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<DriverHealthChanged>();
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);
}
}
@@ -0,0 +1,51 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Covers the in-process fan-out the Blazor Server Alerts / Script log pages rely on:
/// <see cref="IInProcessBroadcaster{T}.Publish"/> raises <c>Received</c> 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).
/// </summary>
public sealed class InProcessBroadcasterTests
{
[Fact]
public void Publish_raises_Received_for_all_current_subscribers()
{
var broadcaster = new InProcessBroadcaster<string>();
var a = new List<string>();
var b = new List<string>();
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<string>();
var received = new List<string>();
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<int>();
Should.NotThrow(() => broadcaster.Publish(42));
}
}
@@ -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;
/// <summary>
/// Guards the Test Connect wiring contract: every driver type editable in the AdminUI must have
/// a registered <see cref="IDriverProbe"/>, resolvable from the same DI container that hosts the
/// <c>admin-operations</c> cluster singleton. The singleton is role-pinned to <c>admin</c>, 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".
/// </summary>
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<IDriverProbe>().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<IDriverProbe>().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);
}
}