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:
@@ -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">▮</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">▮</span> MXAccess Gateway</a>
|
||||
<span class="crumb">›</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>
|
||||
|
||||
Reference in New Issue
Block a user