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
@@ -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;
}