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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user