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
+1 -1
View File
@@ -116,7 +116,7 @@ External analysis sources referenced by design docs:
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
Dashboard auth uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly enabled.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
## Process / Platform Notes
+44 -7
View File
@@ -45,13 +45,15 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
},
"Dashboard": {
"Enabled": true,
"PathBase": "/dashboard",
"RequireAdminScope": true,
"AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
"ShowTagValues": false
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwReader": "Viewer"
}
},
"Protocol": {
"WorkerProtocolVersion": 1,
@@ -142,17 +144,52 @@ the affected stream while the MXAccess session remains active.
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. |
| `MxGateway:Dashboard:PathBase` | `/dashboard` | Base path for dashboard routes. When the dashboard is enabled, this value is required and must start with `/`. |
| `MxGateway:Dashboard:RequireAdminScope` | `true` | Requires API keys used for dashboard login to carry the `admin` scope. |
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by realtime Blazor pages. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
and `RecentSessionLimit` must be greater than or equal to zero.
`GroupToRole` values are validated at startup; invalid role names fail
validation. Emptiness is allowed (a closed deployment that admits no LDAP
users) but practical deployments populate at least one Admin group.
### Authorization policies
Three authorization policies are registered out of these options:
- `MxGateway.Dashboard.Viewer` — gates the Razor component routes. Satisfied by
either dashboard role (Admin or Viewer), by `AllowAnonymousLocalhost` on
loopback, or by `Authentication.Mode = Disabled`.
- `MxGateway.Dashboard.Admin` — gates write-capable surfaces (API-key CRUD).
Satisfied only by the Admin role (same environmental bypasses).
- `MxGateway.Dashboard.HubClients` — attached to the SignalR hubs. Accepts
either the dashboard cookie scheme or the `MxGateway.Dashboard.HubToken`
bearer scheme (used by SignalR's WebSocket upgrade path where the HttpOnly
cookie can't be forwarded).
### SignalR hubs
When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
- `GET /hubs/snapshot` — pushes `DashboardSnapshot` whenever the snapshot
service produces a new one. Drives every page that inherits
`DashboardPageBase`; replaces the earlier polling loop.
- `GET /hubs/alarms` — re-broadcasts the `AlarmFeedMessage` stream from the
central alarm monitor to all connected clients (group `__alarms__`).
- `GET /hubs/events` — per-session MxEvent feed. Clients call
`SubscribeSession(sessionId)` to join `session:{id}`. Events are mirrored
from the corresponding gRPC `StreamEvents` call as a fire-and-forget
side-effect; the dashboard only sees events while a gRPC client is also
subscribed to that session.
`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer
token for the calling user; the Blazor pages use it via
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
## Protocol Options
+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.
+25 -18
View File
@@ -289,11 +289,14 @@ Default refresh policy:
- periodic metrics refresh every 1 second,
- event-rate windows updated every 1 second.
Dashboard access should require API-key-backed authentication with `admin` scope
when enabled. A simple `/dashboard/login` form can validate an API key and issue
an HTTP-only secure cookie for dashboard pages. Do not put API keys in query
strings. Anonymous localhost access may exist only behind an explicit
configuration option that defaults to false.
Dashboard access requires LDAP-backed authentication with role mapping when
enabled. A simple `/login` form binds against the configured directory, maps
the user's groups to `Admin` or `Viewer` via
`MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure cookie.
SignalR hub connections accept either that cookie or a short-lived
data-protected bearer minted at `/hubs/token`. Anonymous localhost access is
gated by `MxGateway:Dashboard:AllowAnonymousLocalhost` (defaults to true for
local development; remote requests always require auth).
## Session State Machine
@@ -674,15 +677,17 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
development only.
Dashboard authentication reuses the API-key verifier and scope model. The
dashboard login endpoint accepts the key in a form post, checks `admin` scope
when `Dashboard:RequireAdminScope` is enabled, and signs in with the
`ZB.MOM.WW.MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict
SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears
that cookie. Login and logout posts use anti-forgery validation, and dashboard
API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost`
allows only loopback requests to bypass the dashboard cookie requirement and
defaults to `true`.
Dashboard authentication uses LDAP bind + role mapping (separate from the
API-key model used on the gRPC API). The login endpoint accepts username and
password in a form post, calls `DashboardAuthenticator` to bind against
`MxGateway:Ldap`, resolves the user's LDAP groups through
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout
clears it. Login and logout posts validate antiforgery tokens. SignalR
connections additionally accept a 30-minute data-protected bearer minted at
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
to bypass the cookie requirement and defaults to `true`.
Recommended scopes:
@@ -870,13 +875,15 @@ Suggested configuration shape:
},
"Dashboard": {
"Enabled": true,
"PathBase": "/dashboard",
"RequireAdminScope": true,
"AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
"ShowTagValues": false
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwReader": "Viewer"
}
},
"Protocol": {
"WorkerProtocolVersion": 1
@@ -968,7 +975,7 @@ The first gateway slice should implement:
13. Blazor Server dashboard with Bootstrap assets.
14. Dashboard home, sessions, and workers pages.
15. Dashboard realtime snapshot refresh.
16. Dashboard API-key login with admin-scope check.
16. Dashboard LDAP login mapped to Admin / Viewer roles.
17. Basic structured logs.
This proves the process model before the full command surface is implemented.
+15 -12
View File
@@ -205,20 +205,23 @@ SQL-injection framing does not apply to a constant-query layer.
`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` is set because it binds against the GLAuth
service described in `glauth.md`.
The suite builds the authenticator with a default `GatewayOptions`, so
`LdapOptions.RequiredGroup` keeps its `GwAdmin` default. `GwAdmin` is the
gateway-specific dashboard-admin role and is **not** part of the five baseline
GLAuth role groups — it must be provisioned before the LDAP live tests pass.
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips) when
GLAuth has only the baseline groups, so this is a hard prerequisite beyond "LDAP
is up." See the "Adding a gw-specific group" section of `glauth.md` for the
provisioning step that adds `GwAdmin` and grants it to `admin`.
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
dashboard-admin role and is **not** part of the five baseline GLAuth role
groups — it must be provisioned before the LDAP live tests pass.
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
when GLAuth has only the baseline groups, so this is a hard prerequisite
beyond "LDAP is up." See the "Adding a gw-specific group" section of
`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to
`admin`.
The suite covers both the success path and the `DashboardAuthenticator` failure
branches: `admin` in `GwAdmin` succeeds; `readonly` is denied for missing group;
`admin` with a wrong password is rejected by the candidate bind without leaking
the password into `FailureMessage`; an unknown username yields no candidate; and
an unreachable LDAP server is absorbed into a failed result rather than throwing.
branches: `admin` whose LDAP groups resolve to the `Admin` role succeeds and
emits the role claim; `readonly` is denied because no group in their `memberOf`
appears in `GroupToRole`; `admin` with a wrong password is rejected by the
candidate bind without leaking the password into `FailureMessage`; an unknown
username yields no candidate; and an unreachable LDAP server is absorbed into a
failed result rather than throwing.
Run the LDAP live tests explicitly:
+10 -6
View File
@@ -447,12 +447,16 @@ Labels: `area:dashboard`, `area:auth`, `type:feature`, `priority:p1`
Deliverables:
- `/dashboard/login`,
- API-key validation with `admin` scope,
- HTTP-only secure cookie,
- logout,
- anti-forgery protection,
- optional explicit anonymous-localhost dev mode defaulting false.
- `/login` (root-mounted),
- LDAP bind against `MxGateway:Ldap`,
- LDAP-group → role mapping (`Admin` / `Viewer`) via
`MxGateway:Dashboard:GroupToRole`,
- HTTP-only secure cookie (`__Host-MxGatewayDashboard`),
- `/hubs/token` bearer mint for SignalR connections,
- `/logout`,
- antiforgery protection,
- `MxGateway:Dashboard:AllowAnonymousLocalhost` loopback bypass
(defaults to true for local development).
Acceptance criteria:
+24 -19
View File
@@ -112,18 +112,20 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can
project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
The Blazor Server dashboard renders those snapshots at `/dashboard`,
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`,
`/dashboard/galaxy`, and `/dashboard/settings`. Components subscribe to
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
snapshot interval without mutating session or worker state. The dashboard uses
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
use a Blazor UI component library.
The Blazor Server dashboard mounts at the host root and renders those snapshots
at `/`, `/sessions`, `/workers`, `/events`, `/galaxy`, `/alarms`, `/apikeys`,
and `/settings`. Pages connect to `/hubs/snapshot` (a SignalR hub published by
`DashboardSnapshotPublisher`) and refresh on every push instead of polling.
`/hubs/alarms` broadcasts `AlarmFeedMessage` values from the central alarm
monitor; `/hubs/events` mirrors per-session `MxEvent` traffic from
`EventStreamService` to clients subscribed to `session:{id}`. The dashboard
uses local Bootstrap CSS and JavaScript plus a small local stylesheet; it does
not use a Blazor UI component library.
`/dashboard/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed
tag values live through `IDashboardLiveDataService`, which owns one shared,
lazily-opened gateway session for the whole dashboard. `/dashboard/alarms`
reads the central alarm monitor's in-process cache directly. See
`/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed tag
values live through `IDashboardLiveDataService`, which owns one shared,
lazily-opened gateway session for the whole dashboard. `/alarms` reads the
central alarm monitor's in-process cache directly. See
`docs/GatewayDashboardDesign.md`.
The gateway runs an always-on central alarm monitor (`GatewayAlarmMonitor`):
@@ -138,14 +140,17 @@ session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
for the alarm subsystem.
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
accepts the API key in a form body, validates the configured `admin` scope,
and issues an HTTP-only secure cookie for subsequent dashboard requests.
`/dashboard/logout` clears that cookie. Login and logout posts validate
anti-forgery tokens, and API keys are never accepted through query strings.
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
loopback requests only when explicitly enabled. Setting
`MxGateway:Dashboard:Enabled` to `false` leaves the dashboard routes unmapped.
Dashboard authentication is LDAP-backed (distinct from the API-key model on
the gRPC API). `/login` accepts username and password in a form body, binds
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout
posts validate antiforgery tokens. SignalR hub connections accept either the
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
cookie requirement; remote requests always require an authenticated principal
with at least the Viewer role. Setting `MxGateway:Dashboard:Enabled` to
`false` leaves the dashboard and hub routes unmapped.
### Worker Process
@@ -1082,6 +1082,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
options,
mapper,
_metrics,
NullDashboardEventBroadcaster.Instance,
_loggerFactory.CreateLogger<EventStreamService>());
Service = new MxAccessGatewayService(
@@ -1593,4 +1594,10 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
ConstraintFailure failure,
CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster
{
public static readonly NullDashboardEventBroadcaster Instance = new();
public void Publish(string sessionId, ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent mxEvent) { }
}
}
@@ -1,5 +1,9 @@
@page "/sessions/{SessionId}"
@inherits DashboardPageBase
@implements IAsyncDisposable
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.MxGateway.Contracts.Proto
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
<PageTitle>Dashboard Session</PageTitle>
@@ -59,12 +63,150 @@ else
</table>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent events</h2>
<span class="conn-pill" data-state="@(_eventsConnected ? "connected" : "disconnected")">
<span class="dot"></span>
<span>@(_eventsConnected ? "live" : "offline")</span>
</span>
</div>
@if (_recentEvents.Count == 0)
{
<div class="empty-state">
Waiting for events. The dashboard mirrors the session's gRPC event stream — events
appear here only while a gRPC client is also consuming this session's events.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Worker seq</th>
<th scope="col">Family</th>
<th scope="col">Server</th>
<th scope="col">Item</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@foreach (MxEvent evt in _recentEvents)
{
<tr>
<td class="num mono">@evt.WorkerSequence</td>
<td>@evt.Family</td>
<td class="num mono">@evt.ServerHandle</td>
<td class="num mono">@evt.ItemHandle</td>
<td>@DashboardDisplay.Text(EventStatusLabel(evt))</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private const int MaxRecentEvents = 50;
[Parameter]
public string SessionId { get; set; } = string.Empty;
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
private HubConnection? _eventsHub;
private bool _eventsConnected;
private string? _subscribedSessionId;
private readonly LinkedList<MxEvent> _recentEvents = new();
protected override async Task OnParametersSetAsync()
{
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
{
await DetachEventsHubAsync().ConfigureAwait(false);
await AttachEventsHubAsync().ConfigureAwait(false);
}
}
private async Task AttachEventsHubAsync()
{
if (string.IsNullOrWhiteSpace(SessionId))
{
return;
}
_eventsHub = HubFactory.Create("/hubs/events");
_eventsHub.On<MxEvent>(EventsHub.EventMessage, async mxEvent =>
{
_recentEvents.AddFirst(mxEvent);
while (_recentEvents.Count > MaxRecentEvents)
{
_recentEvents.RemoveLast();
}
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
});
_eventsHub.Closed += _ =>
{
_eventsConnected = false;
return InvokeAsync(StateHasChanged);
};
_eventsHub.Reconnected += _ =>
{
_eventsConnected = true;
return InvokeAsync(StateHasChanged);
};
try
{
await _eventsHub.StartAsync().ConfigureAwait(false);
await _eventsHub.SendAsync("SubscribeSession", SessionId).ConfigureAwait(false);
_eventsConnected = true;
_subscribedSessionId = SessionId;
}
catch
{
_eventsConnected = false;
}
}
private async Task DetachEventsHubAsync()
{
HubConnection? hub = _eventsHub;
_eventsHub = null;
_eventsConnected = false;
_subscribedSessionId = null;
_recentEvents.Clear();
if (hub is not null)
{
try
{
await hub.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Disposal-time errors are best-effort.
}
}
}
private static string EventStatusLabel(MxEvent evt)
{
return evt.Family == MxEventFamily.OnAlarmTransition
? evt.OnAlarmTransition?.AlarmFullReference ?? string.Empty
: string.Empty;
}
public new async ValueTask DisposeAsync()
{
await DetachEventsHubAsync().ConfigureAwait(false);
await base.DisposeAsync().ConfigureAwait(false);
}
}
@@ -22,6 +22,7 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<HubTokenService>();
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
services.AddHostedService<Hubs.AlarmsHubPublisher>();
services.AddHttpContextAccessor();
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Broadcasts MxEvents to <see cref="EventsHub"/> clients subscribed to the
/// session's group. Fire-and-forget: we hand the send to the hub context
/// and return immediately so the source gRPC stream is never blocked.
/// Errors are logged once and dropped — keeping the SignalR mirror best-effort
/// preserves the gRPC contract that exists today.
/// </summary>
public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{
public void Publish(string sessionId, MxEvent mxEvent)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
{
return;
}
Task send = hubContext.Clients
.Group(EventsHub.GroupName(sessionId))
.SendAsync(EventsHub.EventMessage, mxEvent);
if (!send.IsCompletedSuccessfully)
{
_ = send.ContinueWith(
t =>
{
if (t.Exception is { } ex)
{
logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId);
}
},
TaskScheduler.Default);
}
}
}
@@ -0,0 +1,14 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Fan-out point for MxEvents that should be mirrored to dashboard
/// SignalR clients subscribed to a session's events group. Implementations
/// must never throw — broadcast failures are best-effort and must not
/// disrupt the source gRPC stream.
/// </summary>
public interface IDashboardEventBroadcaster
{
void Publish(string sessionId, MxEvent mxEvent);
}
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
@@ -14,6 +15,7 @@ public sealed class EventStreamService(
IOptions<GatewayOptions> options,
MxAccessGrpcMapper mapper,
GatewayMetrics metrics,
IDashboardEventBroadcaster dashboardEventBroadcaster,
ILogger<EventStreamService> logger) : IEventStreamService
{
/// <summary>
@@ -118,6 +120,11 @@ public sealed class EventStreamService(
continue;
}
// Mirror the event to the dashboard EventsHub group for this
// session. Fire-and-forget — broadcast errors must not affect
// the source gRPC stream.
dashboardEventBroadcaster.Publish(session.SessionId, publicEvent);
if (!writer.TryWrite(publicEvent))
{
string message = $"Session {session.SessionId} event stream queue overflowed.";
@@ -178,6 +178,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
options,
mapper,
_metrics,
NullDashboardEventBroadcaster.Instance,
NullLogger<EventStreamService>.Instance);
Service = new MxAccessGatewayService(
@@ -413,4 +414,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
}
}
private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster
{
public static readonly NullDashboardEventBroadcaster Instance = new();
public void Publish(string sessionId, ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent mxEvent) { }
}
}
@@ -278,9 +278,16 @@ public sealed class EventStreamServiceTests
}),
new MxAccessGrpcMapper(),
metrics ?? new GatewayMetrics(),
NullDashboardEventBroadcaster.Instance,
NullLogger<EventStreamService>.Instance);
}
private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster
{
public static readonly NullDashboardEventBroadcaster Instance = new();
public void Publish(string sessionId, MxEvent mxEvent) { }
}
private static async Task<List<MxEvent>> CollectEventsAsync(
EventStreamService service,
string sessionId)