dashboard: clear deferred items — EventsHub publisher + doc refresh

EventsHub publisher (closes the v2.1 follow-up flagged in the previous commit)

EventStreamService now mirrors every MxEvent it forwards to a gRPC client
into the `EventsHub` group for the session. The fan-out goes through a new
singleton `IDashboardEventBroadcaster`:

  * IDashboardEventBroadcaster — abstraction so EventStreamService doesn't
    take a direct dependency on SignalR.
  * DashboardEventBroadcaster — singleton implementation that hands the
    SendAsync to IHubContext<EventsHub> as fire-and-forget. Errors are
    logged at debug and dropped so the source gRPC stream is never
    blocked.

EventStreamService now takes IDashboardEventBroadcaster as a ctor parameter
and calls Publish(sessionId, publicEvent) once per event after sequence
filtering, before the bounded queue write. Test fixtures and the live
integration harness pass NullDashboardEventBroadcaster.Instance so the
broadcaster is a no-op in unit tests.

SessionDetailsPage adds a "Recent events" panel:
  * implements IAsyncDisposable
  * opens a second HubConnection via DashboardHubConnectionFactory targeting
    /hubs/events
  * calls SubscribeSession(SessionId) on Start
  * renders the most recent 50 events in a small table (worker seq, family,
    server/item handle, alarm reference when the event is OnAlarmTransition)
  * shows a live/offline conn-pill driven by HubConnection.Closed /
    Reconnected events

The dashboard mirror is intentionally passive — events appear only while a
gRPC client is also consuming that session's events. Documented as such in
the empty-state copy and in GatewayDashboardDesign.md.

Documentation refresh

Every doc that referenced the retired options (PathBase, RequireAdminScope,
RequiredGroup) and the old API-key-cookie auth flow is updated to describe
the new model:

  * CLAUDE.md — Authentication section now explains LDAP bind +
    GroupToRole + HubToken bearer flow.
  * gateway.md — Dashboard section: root-mounted routes, snapshot/alarms/
    events SignalR hubs, LDAP cookie + bearer scheme.
  * docs/GatewayConfiguration.md — drop PathBase / RequireAdminScope rows,
    add GroupToRole row, append "Authorization policies" and "SignalR hubs"
    subsections describing the three policies and the /hubs/* endpoints.
  * docs/GatewayDashboardDesign.md — hosting model (root mount, new
    endpoint layout), Realtime Updates rewritten as a hub table
    (DashboardSnapshotHub / AlarmsHub / EventsHub with producers, payloads,
    and routing), Authentication And Authorization rewritten around LDAP +
    role mapping + the hub bearer flow, Configuration block updated.
  * docs/GatewayProcessDesign.md — security-section dashboard paragraph
    and the example config block both refreshed to LDAP/role auth.
  * docs/ImplementationPlanGateway.md — dashboard-auth deliverable list
    updated (LDAP bind + GroupToRole + /hubs/token bearer mint replace the
    API-key login flow).
  * docs/GatewayTesting.md — DashboardLdapLiveTests blurb describes the
    GroupToRole fixture (`{ GwAdmin: Admin }`) instead of the retired
    RequiredGroup default; success-path assertion explains the role-claim
    check.

Verification: 475 server tests, 275 worker tests (+ 9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass — including the
live worker smoke test fixture that now constructs EventStreamService with
the new broadcaster parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 02:07:30 -04:00
parent 65943597d4
commit d692232191
15 changed files with 471 additions and 129 deletions
+127 -66
View File
@@ -35,26 +35,36 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
## Hosting Model
The dashboard is hosted by `ZB.MOM.WW.MxGateway.Server` alongside the gRPC API. When
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` maps the
configured `Dashboard:PathBase` to the Blazor Server app and maps the login,
logout, and access-denied HTTP endpoints beside it. When dashboard hosting is
disabled, those routes are not mapped.
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` mounts the
Blazor Server app at the host root and registers the login, logout, denied,
SignalR hub, and hub-token endpoints beside it. When dashboard hosting is
disabled, none of those routes are mapped — the same listener still serves
gRPC.
Endpoint layout:
```text
/dashboard
/dashboard/sessions
/dashboard/sessions/{sessionId}
/dashboard/workers
/dashboard/events
/dashboard/galaxy
/dashboard/apikeys
/dashboard/settings
/dashboard/_blazor
/
/sessions
/sessions/{sessionId}
/workers
/events
/alarms
/galaxy
/browse
/apikeys
/settings
/login (POST also)
/logout (POST)
/denied
/hubs/snapshot
/hubs/alarms
/hubs/events
/hubs/token
/_blazor
```
The `/dashboard/galaxy` page surfaces the Galaxy Repository browse summary
The `/galaxy` page surfaces the Galaxy Repository browse summary
(deployed object hierarchy size, last deploy timestamp, attribute totals,
template usage, and connectivity sync info). The summary is fed by
`GalaxySummaryCache`, which is refreshed off the request path by
@@ -63,9 +73,6 @@ template usage, and connectivity sync info). The summary is fed by
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
the underlying gRPC service.
The app should redirect `/` to `/dashboard` only if the deployment wants the
dashboard as the default web page. Otherwise leave gRPC/API hosting unaffected.
## High-Level Components
```text
@@ -102,8 +109,10 @@ ZB.MOM.WW.MxGateway.Server
DashboardMetricSummary.cs
```
Blazor Server provides the SignalR circuit for UI updates. The implementation
does not add a separate public dashboard hub.
The dashboard exposes three named SignalR hubs in addition to Blazor Server's
internal circuit; pages connect to those hubs from within the circuit via the
`DashboardHubConnectionFactory` helper. The hubs publish snapshot, alarm, and
per-session event updates that the pages render in place of polling.
## Dashboard Data Source
@@ -150,23 +159,36 @@ gateway internals.
## Realtime Updates
Use Blazor Server component state updates for real-time dashboard refresh.
Updates flow over three SignalR hubs, all guarded by the
`MxGateway.Dashboard.HubClients` policy (cookie OR `MxGateway.Dashboard.HubToken`
bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
Implemented pattern:
| Hub | Path | Producer | Payload | Routing |
|---|---|---|---|---|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick; new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. |
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by `EventStreamService` for each event it forwards to a gRPC client | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. Events appear only while a gRPC client is also consuming that session's events — the dashboard is a passive mirror, not a separate worker subscriber. |
1. Page/component subscribes to `WatchSnapshotsAsync`.
2. Snapshot service emits updates from a bounded channel or timer.
3. Component stores the latest snapshot.
4. Component calls `InvokeAsync(StateHasChanged)`.
5. Component cancels subscription on dispose.
`DashboardPageBase` opens a `DashboardSnapshotHub` connection via the connection
factory in `OnInitializedAsync`, seeds `Snapshot` synchronously from
`IDashboardSnapshotService.GetSnapshot()` so the first render is non-empty, and
calls `InvokeAsync(StateHasChanged)` on every `SnapshotUpdated` push. SignalR's
`WithAutomaticReconnect` handles transient disconnects.
Default update cadence:
`SessionDetailsPage` additionally opens an `EventsHub` connection for the
current session id and renders the most recent N events (default 50) in a
"Recent events" table with a live/offline connection pill.
- periodic metrics refresh every 1 second,
- event counters update on the next snapshot tick.
Default cadences:
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
counts and rates instead.
- snapshot service produces one snapshot per
`MxGateway:Dashboard:SnapshotIntervalMilliseconds` (default 1s);
- alarm publisher emits on each transition observed by the central monitor;
- event publisher emits per event forwarded by `StreamEvents`.
Avoid pushing every MXAccess data-change event into a wider broadcast group.
The current design routes events strictly through `session:{id}` groups; the
snapshot hub continues to carry aggregate event counters and rates.
## Pages
@@ -326,9 +348,10 @@ for what each constraint means and how it is enforced on the gRPC path.
Create, Rotate, and Revoke controls render only when the signed-in user is
authorized. `DashboardApiKeyAuthorization.CanManage` requires an authenticated
principal that is a member of the LDAP `MxGateway:Ldap:RequiredGroup` — the
same group the dashboard login enforces. An anonymous localhost viewer can read
the table but sees no action controls.
principal carrying the `Admin` role claim (resolved at login from the user's
LDAP groups via `MxGateway:Dashboard:GroupToRole`). A `Viewer` role can read
the table but sees no action controls, and an anonymous localhost session
shows the same read-only view.
- **Create** opens a dialog for the key id, display name, scope checkboxes
(the `GatewayScopes` catalog), and the optional constraint fields: read and
@@ -363,58 +386,92 @@ Do not show API key secrets or pepper values.
## Authentication And Authorization
Dashboard access uses the same API-key authentication model as gRPC where
practical.
Dashboard authentication is LDAP-backed, distinct from the API-key model used
on the gRPC API. Users sign in with directory credentials; the gateway maps
their LDAP groups to one of two dashboard roles (`Admin` or `Viewer`) and
issues a cookie carrying those role claims.
Implemented v1 behavior:
Implemented behavior:
- when enabled, require API key auth,
- require `admin` scope for dashboard access,
- accept API key through a secure cookie established by a simple login form,
- do not put API keys in query strings,
- validate anti-forgery tokens for login and logout posts.
- a static `/login` HTML form posts username/password to the gateway;
- `DashboardAuthenticator` binds against `MxGateway:Ldap` (service-account bind,
user search, candidate bind) using `Novell.Directory.Ldap.NETStandard`;
- the user's `memberOf` (or short CN) is matched against
`MxGateway:Dashboard:GroupToRole`; the resolved role(s) are emitted as
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
claims;
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
(`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure);
- a user with no matching group cannot sign in — the login screen returns the
generic credential-rejected message;
- antiforgery tokens guard the login and logout POSTs.
The implementation path is:
Three authorization policies are registered:
1. Add `/dashboard/login`.
2. User submits API key over HTTPS.
3. Gateway validates key and `admin` scope.
4. Gateway issues an HTTP-only secure auth cookie for the dashboard.
5. Dashboard pages require that cookie.
6. Logout clears the cookie.
- `MxGateway.Dashboard.Viewer` — Razor component routes. Satisfied by Admin or
Viewer.
- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD).
- `MxGateway.Dashboard.HubClients` — SignalR hubs. Accepts the dashboard
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
where the cookie can't be forwarded).
For local development, `Dashboard:AllowAnonymousLocalhost` defaults to `true`.
The bypass applies only to loopback requests; remote dashboard requests still
use the API-key-backed cookie flow.
Two environmental bypasses still apply: `MxGateway:Authentication:Mode = Disabled`
authorizes every request, and `MxGateway:Dashboard:AllowAnonymousLocalhost`
(default `true`) authorizes any loopback request without a role check. Remote
requests always require an authenticated principal carrying at least the
Viewer role.
`DashboardAuthenticator` keeps API-key validation outside UI components. It
formats the submitted key as a bearer authorization header for
`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is
enabled, and creates the dashboard cookie principal without storing raw API key
material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and
explicit loopback bypass decisions for all protected dashboard routes.
### Hub bearer flow
SignalR connections cannot reuse the `__Host-` cookie when the JS client
upgrades to WebSocket — the cookie's `SameSite=Strict; Path=/` keeps it from
being forwarded by the browser's WebSocket layer in some edge cases. The
dashboard mints short-lived bearer tokens for the connection:
1. The cookie-authenticated Blazor page calls `GET /hubs/token`
(gated by `ViewerPolicy`, cookie-only).
2. `HubTokenService.Issue(user)` serializes the user's name, NameIdentifier,
and role claims to JSON, encrypts with the ASP.NET Core data-protection
time-limited protector under purpose
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
string. Lifetime is 30 minutes.
3. The SignalR client passes the token as either `Authorization: Bearer …` or
`?access_token=…` (WebSocket upgrade query string).
4. `HubTokenAuthenticationHandler` validates the protected payload and
rebuilds the `ClaimsPrincipal` with the carried roles.
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
identity.
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
every (re)connect.
## Configuration
Suggested configuration:
Effective configuration:
```json
{
"MxGateway": {
"Dashboard": {
"Enabled": true,
"PathBase": "/dashboard",
"RequireAdminScope": true,
"AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
"ShowTagValues": false
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwReader": "Viewer"
}
}
}
}
```
See [Gateway Configuration](./GatewayConfiguration.md#dashboard-options) for
the full option table and the policies/hubs that derive from these values.
## Security Rules
- Do not display API key secrets.
@@ -467,9 +524,13 @@ Integration tests should verify:
- dashboard disabled returns not found or configured fallback,
- dashboard requires auth when enabled,
- admin-scoped key can access dashboard,
- non-admin key is denied,
- live snapshot updates when a fake session changes state.
- a user in an Admin-mapped LDAP group can access the dashboard and the
API-key CRUD surface,
- a user in a Viewer-mapped LDAP group can render every page but cannot
invoke the Admin-only management actions,
- a user with no mapped LDAP group cannot sign in at all,
- live snapshot updates when a fake session changes state are delivered
via the `/hubs/snapshot` push, not by polling.
## Initial Implementation Slice
@@ -478,7 +539,7 @@ The first dashboard slice implements:
1. Blazor Server hosting in `ZB.MOM.WW.MxGateway.Server`.
2. local Bootstrap static assets.
3. dashboard configuration binding.
4. dashboard auth using API key login and HTTP-only cookie.
4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie.
5. read-only `DashboardSnapshotService`.
6. home page with metric cards.
7. sessions page with active session table and session details.