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
@@ -1,62 +1,79 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
/// <summary>
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
/// Base class for Blazor dashboard pages that watch gateway metrics
/// snapshots. The previous implementation polled
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> directly; we
/// now subscribe to <see cref="DashboardSnapshotHub"/> so updates are
/// pushed and disconnects survive reconnects via SignalR's
/// auto-reconnect.
/// </summary>
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{
private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask;
private HubConnection? _hub;
/// <summary>
/// Service that provides gateway metric snapshots.
/// </summary>
/// <summary>Snapshot service used to seed the initial render before the hub connects.</summary>
[Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
/// <summary>Factory that builds the SignalR connection (mints the hub bearer token).</summary>
[Inject]
protected DashboardHubConnectionFactory HubFactory { get; set; } = null!;
/// <summary>
/// The most recent gateway metric snapshot, updated as it changes.
/// The most recent gateway metric snapshot. Synchronously seeded from
/// <see cref="IDashboardSnapshotService.GetSnapshot"/> for the very
/// first render, then refreshed by hub push.
/// </summary>
protected DashboardSnapshot? Snapshot { get; private set; }
/// <inheritdoc />
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
_watchTask = WatchSnapshotsAsync();
Snapshot = SnapshotService.GetSnapshot();
await ConnectHubAsync().ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
if (_watchTask is not null)
if (_hub is not null)
{
await _watchTask.ConfigureAwait(false);
try
{
await _hub.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Disposal-time errors are best-effort.
}
}
_disposeCancellation.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Watches snapshot changes and triggers component refresh.
/// </summary>
private async Task WatchSnapshotsAsync()
private async Task ConnectHubAsync()
{
_hub = HubFactory.Create("/hubs/snapshot");
_hub.On<DashboardSnapshot>(DashboardSnapshotHub.SnapshotMessage, async snapshot =>
{
Snapshot = snapshot;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
});
try
{
await foreach (DashboardSnapshot snapshot in SnapshotService
.WatchSnapshotsAsync(_disposeCancellation.Token)
.ConfigureAwait(false))
{
Snapshot = snapshot;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
await _hub.StartAsync().ConfigureAwait(false);
}
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
catch
{
// Hub is best-effort; the initial GetSnapshot() seed remains
// valid and the snapshot service keeps populating its cache for
// the next reconnect cycle.
}
}
}
@@ -1,35 +1,67 @@
@inherits LayoutComponentBase
<div class="dashboard-shell">
<header class="app-bar">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<nav class="app-nav">
<NavLink href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="/sessions">Sessions</NavLink>
<NavLink href="/workers">Workers</NavLink>
<NavLink href="/events">Events</NavLink>
<NavLink href="/galaxy">Galaxy</NavLink>
<NavLink href="/browse">Browse</NavLink>
<NavLink href="/alarms">Alarms</NavLink>
<NavLink href="/apikeys">API Keys</NavLink>
<NavLink href="/settings">Settings</NavLink>
</nav>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span>
<header class="app-bar">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<span class="crumb">&rsaquo;</span>
<span class="crumb">gateway dashboard</span>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<span class="meta">@authState.User.Identity?.Name</span>
<span class="conn-pill" data-state="connected">
<span class="dot"></span><span>signed in</span>
</span>
</Authorized>
<NotAuthorized>
<span class="conn-pill" data-state="disconnected">
<span class="dot"></span><span>signed out</span>
</span>
</NotAuthorized>
</AuthorizeView>
</header>
<div class="app-shell">
<nav class="side-rail">
<div class="rail-eyebrow">Overview</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
<div class="rail-eyebrow">Runtime</div>
<NavLink class="rail-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
<NavLink class="rail-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
<NavLink class="rail-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
<NavLink class="rail-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
<div class="rail-eyebrow">Galaxy</div>
<NavLink class="rail-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
<NavLink class="rail-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
<div class="rail-eyebrow">Admin</div>
<NavLink class="rail-link" href="/apikeys" Match="NavLinkMatch.Prefix">API keys</NavLink>
<NavLink class="rail-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
<div class="rail-foot">
<AuthorizeView>
<Authorized Context="authState">
<div class="rail-eyebrow">Session</div>
<div class="rail-user">@authState.User.Identity?.Name</div>
<div class="rail-roles">
@string.Join(", ", authState.User.Claims
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role)
.Select(c => c.Value))
</div>
<form method="post" action="/logout">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
<button class="rail-btn" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</header>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</nav>
<main class="page">
@Body
</main>