dashboard: side-rail layout + SignalR push hubs (snapshot, alarms, events)

Layout
------
DashboardLayout.razor replaces the inline header nav with a left side rail
modelled on the OtOpcUa admin (Dashboard B). The top bar keeps only the
brand, breadcrumb, and signed-in status pill; navigation moves into a
fixed-width 218px rail with grouped section eyebrows (Overview,
Runtime, Galaxy, Admin) and a Session footer carrying the user name,
role claims, and a Sign-out button. dashboard.css gains the
`.app-shell` flex container, `.side-rail` column, `.rail-eyebrow`,
`.rail-link[.active]`, `.rail-foot`, `.rail-user`, `.rail-roles`, and
`.rail-btn` rules (all driven by the existing theme.css tokens, no new
hard-coded colours).

SignalR (push)
--------------
Adds three hubs under `Dashboard/Hubs/`, all gated by the
`HubClientsPolicy` registered in the previous commit:

  * DashboardSnapshotHub (/hubs/snapshot)
    Broadcasts the full DashboardSnapshot on every change. Sends the
    current snapshot to a new caller in OnConnectedAsync so the first
    paint is immediate.

  * AlarmsHub (/hubs/alarms)
    Connected clients auto-join the `__alarms__` group. Receives
    AlarmFeedMessage values (active_alarm / snapshot_complete /
    transition) re-broadcast from the gateway's central alarm monitor.

  * EventsHub (/hubs/events)
    Per-session push surface. Clients call SubscribeSession(sessionId)
    to join `session:{id}`. The publisher side is intentionally a
    follow-up — the snapshot hub already carries recent-events
    rollups; a dedicated MxEvent broadcaster on EventStreamService
    will plug into this hub's group convention.

Two BackgroundService publishers wire server-side data sources to the
hubs:

  * DashboardSnapshotPublisher subscribes to
    `IDashboardSnapshotService.WatchSnapshotsAsync` and forwards every
    snapshot to all connected hub clients.
  * AlarmsHubPublisher subscribes to `IGatewayAlarmService.StreamAsync`
    (no filter) and forwards every AlarmFeedMessage to the
    `__alarms__` group, reconnecting with a 5-second backoff if the
    stream faults.

Connection + auth plumbing
--------------------------
  * `GET /hubs/token` issues a fresh data-protected bearer token
    bound to the calling user's identity and roles. Gated by the
    cookie-only ViewerPolicy so a Blazor circuit (cookie-authenticated)
    can mint a token, but a hub bearer cannot self-bootstrap a new
    one.
  * DashboardHubConnectionFactory (scoped) is the client-side helper
    Razor pages inject. It builds a HubConnection with an
    AccessTokenProvider that calls HubTokenService.Issue on every
    (re)connect — keeps the connection alive across cookie refresh
    boundaries.

Pull → push refactor
--------------------
DashboardPageBase no longer drives its own `WatchSnapshotsAsync`
async-foreach loop. It now:
  1. seeds Snapshot synchronously from `IDashboardSnapshotService.GetSnapshot()`
     so the first render is non-empty;
  2. opens a `DashboardSnapshotHub` connection via the connection
     factory;
  3. updates Snapshot + triggers StateHasChanged on each
     `SnapshotUpdated` push.

The hub connection is best-effort: if SignalR can't start, the
synchronous snapshot seed keeps the UI populated. SignalR's
WithAutomaticReconnect handles the recovery path.

Package
-------
Adds `Microsoft.AspNetCore.SignalR.Client` 10.0.0 to the server csproj
so the in-process Blazor pages can open hub connections back to their
own hosting process.

Verification: 475 server tests (+ 2 new
`DashboardHubsRegistrationTests` that pin the hub negotiate endpoints
and the singleton/scoped DI shape), 275 worker tests (+ 9 dev-rig
skips), 18 integration tests (live MxAccess + LDAP + Galaxy) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 01:48:27 -04:00
parent 27ed65114e
commit 65943597d4
13 changed files with 494 additions and 70 deletions
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// SignalR hub that pushes alarm-feed messages from the gateway's
/// central alarm monitor. Connected clients auto-join
/// <see cref="AllAlarmsGroup"/> on connect and receive every
/// <c>AlarmFeedMessage</c> the monitor emits.
/// </summary>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class AlarmsHub : Hub
{
public const string AllAlarmsGroup = "__alarms__";
/// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary>
public const string AlarmMessage = "AlarmFeed";
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
await base.OnConnectedAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Background service that subscribes to
/// <see cref="IGatewayAlarmService.StreamAsync"/> (no filter) and re-broadcasts
/// every <see cref="AlarmFeedMessage"/> to every <see cref="AlarmsHub"/>
/// client. The hub itself is session-less; clients filter / route messages
/// in the browser.
/// </summary>
public sealed class AlarmsHubPublisher(
IGatewayAlarmService alarmService,
IHubContext<AlarmsHub> hubContext,
ILogger<AlarmsHubPublisher> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Loop forever — when StreamAsync completes (monitor restart, etc.)
// reopen the subscription. The hosted-service lifetime ends only
// when the host stops.
while (!stoppingToken.IsCancellationRequested)
{
try
{
await foreach (AlarmFeedMessage message in alarmService
.StreamAsync(alarmFilterPrefix: null, stoppingToken)
.ConfigureAwait(false))
{
if (stoppingToken.IsCancellationRequested)
{
break;
}
try
{
await hubContext.Clients
.Group(AlarmsHub.AllAlarmsGroup)
.SendAsync(AlarmsHub.AlarmMessage, message, stoppingToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Alarm broadcast failed; continuing.");
}
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Alarm subscription faulted; reconnecting in 5s.");
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.SignalR.Client;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Client-side helper that builds a <see cref="HubConnection"/> targeted at a
/// dashboard hub. Mints a fresh data-protected bearer token via
/// <see cref="HubTokenService"/> on every (re)connect so the connection
/// authenticates against <see cref="DashboardAuthenticationDefaults.HubAuthenticationScheme"/>
/// without needing to forward the browser's HttpOnly cookie.
/// </summary>
public sealed class DashboardHubConnectionFactory(
NavigationManager navigation,
HubTokenService tokens,
AuthenticationStateProvider authState)
{
public HubConnection Create(string hubPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
Uri hubUrl = navigation.ToAbsoluteUri(hubPath);
return new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = async () =>
{
AuthenticationState state = await authState.GetAuthenticationStateAsync().ConfigureAwait(false);
return tokens.Issue(state.User);
};
})
.WithAutomaticReconnect()
.Build();
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// SignalR hub that pushes a fresh <see cref="DashboardSnapshot"/> on every
/// snapshot refresh. New connections receive the current snapshot
/// immediately via <see cref="OnConnectedAsync"/>; subsequent refreshes are
/// broadcast by <see cref="DashboardSnapshotPublisher"/>.
/// </summary>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotService) : Hub
{
/// <summary>Method name used to push snapshot updates to clients.</summary>
public const string SnapshotMessage = "SnapshotUpdated";
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
await base.OnConnectedAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Background service that subscribes to <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/>
/// and broadcasts every snapshot it produces to every connected
/// <see cref="DashboardSnapshotHub"/> client. There is one publisher per
/// gateway process; clients listen via the hub.
/// </summary>
public sealed class DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext,
ILogger<DashboardSnapshotPublisher> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await foreach (DashboardSnapshot snapshot in snapshotService
.WatchSnapshotsAsync(stoppingToken)
.ConfigureAwait(false))
{
try
{
await hubContext.Clients
.All
.SendAsync(DashboardSnapshotHub.SnapshotMessage, snapshot, stoppingToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Snapshot broadcast failed; will retry on the next snapshot tick.");
}
}
}
catch (OperationCanceledException)
{
}
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// SignalR hub for per-session MxEvent push. Clients call
/// <see cref="SubscribeSession"/> to join the group for a specific
/// session; the dashboard's MxEvent broadcaster (a future hook on
/// <c>EventStreamService</c>) sends messages to <c>session:{id}</c>.
/// </summary>
/// <remarks>
/// The publisher side is intentionally a follow-up. Today the dashboard's
/// per-session event view is fed by the snapshot hub, which carries the
/// rolling recent-events list. Once a dedicated MxEvent broadcaster
/// lands, this hub's group convention is what it will publish to.
/// </remarks>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub : Hub
{
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent";
public static string GroupName(string sessionId) => $"session:{sessionId}";
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return Task.CompletedTask;
}
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
}
public Task UnsubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return Task.CompletedTask;
}
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId));
}
}