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
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:
@@ -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<AlarmTransitionEvent> Alarms
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Alerts</h4>
|
||||
@@ -73,36 +72,26 @@ else
|
||||
private const int Capacity = 200;
|
||||
|
||||
private readonly List<AlarmTransitionEvent> _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<AlarmTransitionEvent>(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;
|
||||
}
|
||||
|
||||
@@ -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<ScriptLogEntry> ScriptLogs
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Script log</h4>
|
||||
@@ -87,7 +86,6 @@ else
|
||||
private const int Capacity = 500;
|
||||
|
||||
private readonly List<ScriptLogEntry> _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<ScriptLogEntry>(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;
|
||||
}
|
||||
|
||||
+38
-24
@@ -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
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
|
||||
<div class="panel-head d-flex align-items-center gap-2">
|
||||
@@ -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<DriverHealthChanged>("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.
|
||||
|
||||
@@ -17,22 +17,26 @@ public sealed class AlertSignalRBridge : ReceiveActor
|
||||
public const string TopicName = "alerts";
|
||||
|
||||
private readonly IHubContext<AlertHub> _hub;
|
||||
private readonly IInProcessBroadcaster<AlarmTransitionEvent> _broadcaster;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>
|
||||
/// Creates actor props for the AlertSignalRBridge.
|
||||
/// </summary>
|
||||
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
||||
public static Props Props(IHubContext<AlertHub> hub) =>
|
||||
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub));
|
||||
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Alerts page.</param>
|
||||
public static Props Props(IHubContext<AlertHub> hub, IInProcessBroadcaster<AlarmTransitionEvent> broadcaster) =>
|
||||
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub, broadcaster));
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AlertSignalRBridge actor.
|
||||
/// </summary>
|
||||
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
||||
public AlertSignalRBridge(IHubContext<AlertHub> hub)
|
||||
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Alerts page.</param>
|
||||
public AlertSignalRBridge(IHubContext<AlertHub> hub, IInProcessBroadcaster<AlarmTransitionEvent> broadcaster)
|
||||
{
|
||||
_hub = hub;
|
||||
_broadcaster = broadcaster;
|
||||
ReceiveAsync<AlarmTransitionEvent>(ForwardAsync);
|
||||
Receive<SubscribeAck>(_ => { /* 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);
|
||||
|
||||
@@ -13,14 +13,21 @@ public static class HubServiceCollectionExtensions
|
||||
public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge";
|
||||
|
||||
/// <summary>
|
||||
/// Registers services required by the driver-status hub pipeline:
|
||||
/// <see cref="IDriverStatusSnapshotStore"/> as a singleton backed by
|
||||
/// <see cref="InMemoryDriverStatusSnapshotStore"/>.
|
||||
/// Registers the in-process live-push services the AdminUI's Blazor Server panels read
|
||||
/// directly (instead of self-connecting a SignalR <c>HubConnection</c>, which fails behind a
|
||||
/// reverse proxy — see <see cref="IInProcessBroadcaster{T}"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IDriverStatusSnapshotStore"/> — last-value snapshot per driver.</item>
|
||||
/// <item><see cref="IInProcessBroadcaster{T}"/> — 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.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDriverStatusSnapshotStore, InMemoryDriverStatusSnapshotStore>();
|
||||
services.AddSingleton(typeof(IInProcessBroadcaster<>), typeof(InProcessBroadcaster<>));
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -48,11 +55,13 @@ public static class HubServiceCollectionExtensions
|
||||
registry.Register<FleetStatusSignalRBridgeKey>(fleetBridge);
|
||||
|
||||
var alertHub = resolver.GetService<IHubContext<AlertHub>>();
|
||||
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName);
|
||||
var alertBroadcaster = resolver.GetService<IInProcessBroadcaster<Commons.Messages.Alerts.AlarmTransitionEvent>>();
|
||||
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub, alertBroadcaster), AlertSignalRBridgeName);
|
||||
registry.Register<AlertSignalRBridgeKey>(alertBridge);
|
||||
|
||||
var scriptLogHub = resolver.GetService<IHubContext<ScriptLogHub>>();
|
||||
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub), ScriptLogSignalRBridgeName);
|
||||
var scriptLogBroadcaster = resolver.GetService<IInProcessBroadcaster<Commons.Messages.Logging.ScriptLogEntry>>();
|
||||
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub, scriptLogBroadcaster), ScriptLogSignalRBridgeName);
|
||||
registry.Register<ScriptLogSignalRBridgeKey>(scriptLogBridge);
|
||||
|
||||
var driverStatusHub = resolver.GetService<IHubContext<DriverStatusHub>>();
|
||||
|
||||
@@ -6,10 +6,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
/// Singleton last-snapshot-per-instance cache. Populated by
|
||||
/// <c>DriverStatusSignalRBridge</c> as it forwards DPS messages; read by
|
||||
/// <see cref="DriverStatusHub.JoinDriver"/> 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 <c>DriverStatusPanel</c> via <see cref="SnapshotChanged"/>.
|
||||
/// </summary>
|
||||
public interface IDriverStatusSnapshotStore
|
||||
{
|
||||
void Upsert(DriverHealthChanged snapshot);
|
||||
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Raised after every <see cref="Upsert"/> with the just-stored snapshot. Lets in-process
|
||||
/// consumers (the Blazor Server <c>DriverStatusPanel</c>) 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.
|
||||
/// </summary>
|
||||
event Action<DriverHealthChanged>? SnapshotChanged;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Publish"/>; Blazor Server components subscribe to <see cref="Received"/>
|
||||
/// to render the live tail.
|
||||
/// <para>
|
||||
/// This exists because the AdminUI runs as Blazor <em>Server</em>: a component opening a
|
||||
/// SignalR <c>HubConnection</c> 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
|
||||
/// <c>IDriverStatusSnapshotStore.SnapshotChanged</c> pattern for stream (vs. last-value) feeds.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry).</typeparam>
|
||||
public interface IInProcessBroadcaster<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised once per <see cref="Publish"/> 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 <c>InvokeAsync</c>).
|
||||
/// </summary>
|
||||
event Action<T>? Received;
|
||||
|
||||
/// <summary>Fan the item out to all current <see cref="Received"/> subscribers.</summary>
|
||||
void Publish(T item);
|
||||
}
|
||||
|
||||
/// <summary>Thread-safe singleton implementation of <see cref="IInProcessBroadcaster{T}"/>.</summary>
|
||||
/// <typeparam name="T">The event payload type.</typeparam>
|
||||
public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public event Action<T>? Received;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise.
|
||||
public void Publish(T item) => Received?.Invoke(item);
|
||||
}
|
||||
@@ -11,9 +11,16 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action<DriverHealthChanged>? SnapshotChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
|
||||
|
||||
@@ -15,18 +15,22 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
|
||||
public const string TopicName = "script-logs";
|
||||
|
||||
private readonly IHubContext<ScriptLogHub> _hub;
|
||||
private readonly IInProcessBroadcaster<ScriptLogEntry> _broadcaster;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>Creates a Props instance for the ScriptLogSignalRBridge.</summary>
|
||||
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
||||
public static Props Props(IHubContext<ScriptLogHub> hub) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub));
|
||||
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Script log page.</param>
|
||||
public static Props Props(IHubContext<ScriptLogHub> hub, IInProcessBroadcaster<ScriptLogEntry> broadcaster) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub, broadcaster));
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptLogSignalRBridge"/> class.</summary>
|
||||
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
||||
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub)
|
||||
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Script log page.</param>
|
||||
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub, IInProcessBroadcaster<ScriptLogEntry> broadcaster)
|
||||
{
|
||||
_hub = hub;
|
||||
_broadcaster = broadcaster;
|
||||
ReceiveAsync<ScriptLogEntry>(ForwardAsync);
|
||||
Receive<SubscribeAck>(_ => { /* 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);
|
||||
|
||||
@@ -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 <c>NullDriverFactory</c> default so deploys actually
|
||||
/// materialise real <see cref="IDriver"/> 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 <c>hasDriver</c> 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 <c>hasDriver</c> flag). The driver <em>probe</em>
|
||||
/// set is the exception: it backs the AdminUI Test Connect button and so must also be wired on
|
||||
/// admin nodes — see <see cref="AddOtOpcUaDriverProbes"/>.
|
||||
/// </summary>
|
||||
public static class DriverFactoryBootstrap
|
||||
{
|
||||
@@ -46,16 +49,42 @@ public static class DriverFactoryBootstrap
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
||||
|
||||
// One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration.
|
||||
services.AddSingleton<IDriverProbe, ModbusProbe>();
|
||||
services.AddSingleton<IDriverProbe, AbCipProbe>();
|
||||
services.AddSingleton<IDriverProbe, AbLegacyProbe>();
|
||||
services.AddSingleton<IDriverProbe, S7Probe>();
|
||||
services.AddSingleton<IDriverProbe, TwinCATProbe>();
|
||||
services.AddSingleton<IDriverProbe, FocasProbe>();
|
||||
services.AddSingleton<IDriverProbe, OpcUaProbe>();
|
||||
services.AddSingleton<IDriverProbe, GalaxyProbe>();
|
||||
services.AddSingleton<IDriverProbe, HistorianProbe>();
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register one <see cref="IDriverProbe"/> per driver type. These back the AdminUI's
|
||||
/// "Test Connect" button: the <c>admin-operations</c> cluster singleton resolves
|
||||
/// <see cref="IEnumerable{T}"/> of <see cref="IDriverProbe"/> and dispatches by DriverType.
|
||||
/// <para>
|
||||
/// That singleton is role-pinned to <c>admin</c>, so this MUST be wired on admin nodes —
|
||||
/// including admin-only nodes that lack the <c>driver</c> 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
|
||||
/// <see cref="AddOtOpcUaDriverFactories"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Uses <c>TryAddEnumerable</c> 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 <c>ToDictionary(p => p.DriverType)</c> throw.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register driver probes with.</param>
|
||||
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HistorianProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<IDriverProbe>, 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 <AuthorizeView/> works
|
||||
// inside interactive components (NavSidebar's session block).
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user