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,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