913 lines
44 KiB
Markdown
913 lines
44 KiB
Markdown
# Gateway Dashboard Detailed Design
|
|
|
|
## Purpose
|
|
|
|
The gateway should host a basic web dashboard for operators and developers. The
|
|
dashboard is diagnostic and operational visibility only for v1. It should show
|
|
gateway health, active MXAccess worker instances, session state, and basic
|
|
statistics in real time.
|
|
|
|
## Technology Choice
|
|
|
|
Decision: Blazor Server with Bootstrap CSS/JS.
|
|
|
|
Allowed UI stack:
|
|
|
|
- ASP.NET Core Blazor Server,
|
|
- Bootstrap CSS,
|
|
- Bootstrap JavaScript,
|
|
- small local CSS for layout and status styling,
|
|
- built-in Blazor components.
|
|
|
|
Not allowed for v1:
|
|
|
|
- MudBlazor,
|
|
- Radzen,
|
|
- Syncfusion,
|
|
- Telerik,
|
|
- other Blazor UI component libraries,
|
|
- client-side SPA framework replacement.
|
|
|
|
Rationale: Blazor Server keeps the dashboard in the gateway process, avoids a
|
|
separate frontend build, and gives real-time UI updates through the Blazor
|
|
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()` 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
|
|
/
|
|
/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 `/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
|
|
`GalaxySummaryRefreshService` on the
|
|
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` cadence so the dashboard
|
|
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
|
|
the underlying gRPC service.
|
|
|
|
## High-Level Components
|
|
|
|
```text
|
|
ZB.MOM.WW.MxGateway.Server
|
|
Dashboard/
|
|
Components/
|
|
App.razor
|
|
Routes.razor
|
|
DashboardPageBase.cs
|
|
DashboardDisplay.cs
|
|
Layout/
|
|
DashboardLayout.razor
|
|
Pages/
|
|
DashboardHome.razor
|
|
SessionsPage.razor
|
|
SessionDetailsPage.razor
|
|
WorkersPage.razor
|
|
EventsPage.razor
|
|
ApiKeysPage.razor
|
|
SettingsPage.razor
|
|
Shared/
|
|
MetricCard.razor
|
|
StatusBadge.razor
|
|
FaultList.razor
|
|
DashboardSnapshotService.cs
|
|
DashboardSnapshotFeed.cs
|
|
DashboardAuthorizationHandler.cs
|
|
DashboardAuthenticator.cs
|
|
DashboardApiKeyAuthorization.cs
|
|
DashboardApiKeyManagementService.cs
|
|
DashboardApiKeySummary.cs
|
|
DashboardSnapshot.cs
|
|
DashboardSessionSummary.cs
|
|
DashboardWorkerSummary.cs
|
|
DashboardMetricSummary.cs
|
|
```
|
|
|
|
The dashboard exposes three named SignalR hubs in addition to Blazor Server's
|
|
internal circuit. The hubs are the **remote** surface: they publish snapshot,
|
|
alarm, and per-session event updates to clients outside the gateway process.
|
|
Server-rendered Blazor pages do not use them. A page runs inside this process,
|
|
so it consumes the producing services directly through in-process seams —
|
|
`IDashboardSnapshotFeed`, `IDashboardSessionEventSubscriber`, and
|
|
`IGatewayAlarmService` — instead of opening a loopback WebSocket back into its
|
|
own heap. `DashboardHubConnectionFactory`, the helper a circuit used to open
|
|
those connections, has been deleted along with the `Microsoft.AspNetCore.SignalR.Client`
|
|
package reference: nothing in this process dials a hub, and a registered-but-unused
|
|
client factory only invites a page to reintroduce the loopback. Remote consumers
|
|
build their own connection; the hubs, `/hubs/token`, and `HubTokenService` remain
|
|
for them.
|
|
|
|
## Dashboard Data Source
|
|
|
|
The dashboard should consume read-only snapshots from gateway services:
|
|
|
|
- `SessionRegistry`,
|
|
- `SessionManager`,
|
|
- `WorkerClient`,
|
|
- `GatewayMetrics`,
|
|
- health checks,
|
|
- structured fault/event counters.
|
|
|
|
Do not let Razor components directly mutate gateway session or worker objects.
|
|
Create a small read-only dashboard service that projects gateway state into
|
|
plain DTOs.
|
|
|
|
`GatewayMetrics.GetSnapshot()` is the metrics input for the first dashboard
|
|
projection. It carries current session and worker gauges, command and event
|
|
counters, queue depth, and fault totals. The dashboard reads that snapshot
|
|
instead of reading raw `Meter` instruments because exporter configuration is an
|
|
operations concern, not a UI dependency.
|
|
|
|
Suggested service:
|
|
|
|
```csharp
|
|
public interface IDashboardSnapshotService
|
|
{
|
|
DashboardSnapshot GetSnapshot();
|
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
|
CancellationToken cancellationToken);
|
|
}
|
|
```
|
|
|
|
Snapshot updates can be driven by:
|
|
|
|
- periodic timer, default every 1 second,
|
|
- session lifecycle notifications,
|
|
- worker heartbeat updates,
|
|
- event counter updates,
|
|
- fault notifications.
|
|
|
|
Use immutable snapshot DTOs so Razor components can render without locking
|
|
gateway internals.
|
|
|
|
## Realtime Updates
|
|
|
|
Realtime data reaches two audiences over two seams:
|
|
|
|
- **in-process**, for the server-rendered Blazor pages, which run inside the
|
|
gateway process and read the producing services directly;
|
|
- **SignalR hubs**, for clients outside the process.
|
|
|
|
Pages originally took the hub path too, which put a loopback WebSocket, a
|
|
hub-token mint, and a serialize/deserialize round trip between a Blazor component
|
|
and an object already in its own heap. The in-process seams remove that hop. The
|
|
hubs stay for the audience that genuinely needs a wire.
|
|
|
|
### In-process page feeds
|
|
|
|
| Page | Seam | Producer |
|
|
|---|---|---|
|
|
| every page deriving from `DashboardPageBase` | `IDashboardSnapshotFeed.WatchAsync` | `DashboardSnapshotFeed` (singleton) multicasting one `IDashboardSnapshotService.WatchSnapshotsAsync` enumeration |
|
|
| `SessionDetailsPage` | `IDashboardSessionEventSubscriber.Subscribe(sessionId)` | `DashboardEventBroadcaster` — the same singleton the session mirror publishes to, registered behind both interfaces |
|
|
| `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **provider status only**; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll |
|
|
|
|
The snapshot feed multicasts rather than handing each page its own enumeration:
|
|
`WatchSnapshotsAsync` is not multicast on its own — each enumeration owns a timer
|
|
and builds its own snapshot per tick — so a subscription per page would multiply
|
|
the snapshot cost by the number of open pages. Each subscriber reads through a
|
|
capacity-1 drop-oldest channel, so a circuit that renders slowly skips snapshots
|
|
instead of buffering without bound or stalling the pump.
|
|
|
|
`DashboardPageBase` seeds `Snapshot` synchronously from
|
|
`IDashboardSnapshotService.GetSnapshot()` in `OnInitializedAsync` so the first
|
|
render is non-empty, then calls `InvokeAsync(StateHasChanged)` for every snapshot
|
|
the feed yields.
|
|
|
|
A subscription is not a lifetime, so the watch is a loop, not a single enumeration:
|
|
the feed detaches its subscribers whenever a pump's source faults or completes, and
|
|
a page that treated that as terminal would sit on its last snapshot until the
|
|
operator navigated. On any end other than its own cancellation the page waits one
|
|
second — honouring its own token, so teardown is not delayed — and resubscribes. The
|
|
fault is logged at Warning once per fault *transition*, not once per retry: a feed
|
|
that is down stays down for many iterations, and one line per second per open page
|
|
is noise. The last rendered snapshot stays on screen throughout, and the next page
|
|
load still seeds from `IDashboardSnapshotService.GetSnapshot()`.
|
|
|
|
That resubscribe is also the feed's primary recovery path, not just the page's:
|
|
only a subscriber that finds no live generation starts a pump, so a page coming back
|
|
is what restarts the enumeration. `Reset`'s belt-and-braces restart — if subscribers
|
|
of *other* generations are still attached when a generation dies, it starts a fresh
|
|
pump for them and re-tags them — remains the backstop for the case where no
|
|
subscriber is left to drive recovery, but it is no longer the only thing standing
|
|
between a faulted feed and a permanently stale page.
|
|
|
|
On dispose the page cancels the watch and waits at most **5 seconds**
|
|
for the loop to drain, logging a warning on timeout. The bound is deliberate: the
|
|
loop marshals renders through the renderer's dispatcher and disposal can run on
|
|
that same dispatcher, so an unconditional wait would hang on a wedged dispatcher.
|
|
The accepted cost is that an abandoned loop still holds its feed subscription — the
|
|
feed's idle gate stays open until it unwinds — and the warning is the operator's
|
|
only signal that a circuit teardown wedged.
|
|
|
|
`SessionDetailsPage` subscribes for the current session id and renders the most
|
|
recent N events (default 50) in a "Recent events" table. Its pump drains everything
|
|
queued and renders once per batch rather than once per event, and it re-checks
|
|
**inside the renderer dispatch** — where the subscription field is written, making
|
|
the check an unsynchronized read of dispatcher-owned state — that the batch's
|
|
subscription is still the live one. A batch read before a session switch would
|
|
otherwise render the previous session's events under the new session's heading.
|
|
Detaching cancels the pump, disposes the subscription (which releases the viewer
|
|
registration and completes the channel, so the pump has an exit even if
|
|
cancellation is missed), then drains under its own timeout.
|
|
|
|
The page's live/offline pill tracks that pump rather than a connection: it is set
|
|
live on attach and cleared when the pump exits, through the same dispatcher-owned
|
|
identity check the render batch uses, so a stale pump cannot darken the pill of the
|
|
subscription that replaced it. Because detach clears the subscription field before
|
|
cancelling, a detach-driven exit leaves the pill to the incoming subscription; what
|
|
the pill therefore reports is the case it exists for — the channel completing under
|
|
a page that is still watching.
|
|
|
|
`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the provider-status
|
|
badge) and bounds their drain at 5 seconds on dispose, for the same reason
|
|
`DashboardPageBase` bounds its watch drain: both loops render through the renderer's
|
|
dispatcher, and disposal can run on it. The two are drained concurrently, so the
|
|
bound on disposal is 5 seconds in total rather than per loop — a wedged dispatcher
|
|
blocks both loops at once, and draining them in sequence would time out twice.
|
|
|
|
Both loops handle faults *inside* the loop and retry: only cancellation ends them.
|
|
A failing alarm query or a render that faults on one tick leaves the page's last
|
|
rows in place and is retried on the next tick — a fault that stopped polling for the
|
|
life of the page would leave stale rows behind with nothing to say so. The poll loop
|
|
also surfaces the fault in the same `Alarm query failed` banner that a query error
|
|
uses, and clears it on the first tick that succeeds; that dispatch is itself
|
|
best-effort, because the fault being reported may be an `InvokeAsync` against a
|
|
disposed renderer, in which case reporting fails the same way and the loop simply
|
|
exits on its next cancellation check. Neither loop method can fault, which is what
|
|
the bounded drain relies on — it must never observe a faulted loop task, since that
|
|
would surface out of `DisposeAsync` and skip the `CancellationTokenSource` dispose.
|
|
|
|
### SignalR hubs (remote clients)
|
|
|
|
Updates for out-of-process clients 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)]`.
|
|
|
|
| Hub | Path | Producer | Payload | Routing |
|
|
|---|---|---|---|---|
|
|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); 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 each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. The per-session ACL that would scope a Viewer to specific sessions is still outstanding for this seam and the in-process one alike (SEC-25 / remediation roadmap item 12); the value redaction is the near-term hardening that closes the value-leak seam independently of that ACL. |
|
|
|
|
### Default cadences
|
|
|
|
Both seams consume the same producing services, so they share these cadences:
|
|
|
|
- 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 fanned by the session's `SessionEventDistributor`
|
|
to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`);
|
|
- the alarms page's provider-status badge resubscribes one second after its
|
|
`IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a
|
|
subscriber's stream when it falls behind and again when it restarts, both
|
|
recoverable by resubscribing — and holds its last value in between. The page's
|
|
alarm rows are independent of that stream and refresh on the 3 s poll.
|
|
|
|
### Idle gating and snapshot cost
|
|
|
|
A snapshot is not free: each one takes a session-registry snapshot and sorts it,
|
|
copies the metrics dictionaries under the global metrics lock, and projects
|
|
sessions, workers, faults, and the Galaxy summary. Without gating that work ran
|
|
once a second for the life of the process even when nothing was watching.
|
|
|
|
Gating is two-tier, because the two seams have independent audiences and each must
|
|
be able to reach zero on its own.
|
|
|
|
**Hub tier.** `DashboardSnapshotHub` counts live connections into the singleton
|
|
`DashboardSnapshotHubConnectionCounter` (`OnConnectedAsync` / `OnDisconnectedAsync`,
|
|
clamped at zero). `DashboardSnapshotPublisher` reads that count before advancing the
|
|
snapshot enumerator: while it is zero the publisher does not call `MoveNextAsync` at
|
|
all, so the producing iterator stays suspended at its `yield` and builds nothing —
|
|
the gate removes the snapshot *build*, not just the broadcast. The publisher
|
|
re-checks once a second while idle, so the first client to connect resumes the tick
|
|
within roughly one snapshot interval, and `OnConnectedAsync` pushes the current
|
|
snapshot to that connection immediately. Now that no in-repo page connects to the
|
|
hub, this tier stays idle unless a remote client connects.
|
|
|
|
**In-process tier.** `DashboardSnapshotFeed` gates its own pump on its subscriber
|
|
list: the first subscriber starts the pump, the last one leaving cancels it and
|
|
awaits it, so a gateway with no page open runs no timer and builds no snapshots on
|
|
this seam either. Successive pumps are chained through the previous pump's task, so
|
|
an unsubscribe immediately followed by a resubscribe restarts a fresh pump without
|
|
ever running two enumerations at once. A page does not wait for the pump's first
|
|
tick — `DashboardPageBase` seeds its first render synchronously from
|
|
`IDashboardSnapshotService.GetSnapshot()`.
|
|
|
|
That gate is generation-scoped rather than a plain subscriber count. Each pump owns
|
|
a generation, each subscriber is tagged with the generation it joined under, a pump
|
|
ends its generation the instant its source faults or completes — before the possibly
|
|
slow enumerator disposal — and a dying pump only ever detaches its own generation's
|
|
subscribers. Two races motivate the extra state:
|
|
|
|
- a subscriber arriving mid-teardown must start a fresh generation rather than
|
|
attach to a pump that is about to detach everybody and leave nobody watching;
|
|
- an unsubscribe must compare its own generation against the live one before it
|
|
cancels anything. Subscribers of an ending generation linger in the list until
|
|
that pump's reset runs, so counting the whole list would let them hold the idle
|
|
gate open, and cancelling on their behalf would stop a live pump that other
|
|
viewers depend on.
|
|
|
|
Two per-tick costs inside the snapshot itself are bounded independently of either gate:
|
|
|
|
- the effective configuration (`EffectiveGatewayConfiguration`) is built once and
|
|
cached. It is a projection of `IOptions<GatewayOptions>`, which the gateway binds
|
|
at startup and never reloads, so rebuilding the whole option tree every tick
|
|
produced an identical object;
|
|
- the API key summaries are refreshed at most once every 15 seconds
|
|
(`ApiKeySummaryRefreshInterval`) instead of on every tick. The list is a SQLite
|
|
read whose content changes only when an operator creates, rotates, or revokes a
|
|
key, so a key change reaches the dashboard within that interval. Only a
|
|
*successful* refresh restarts the interval, so a failed or timed-out read is
|
|
retried on the next tick and the previous summaries stay on screen.
|
|
|
|
Avoid pushing every MXAccess data-change event into a wider broadcast group.
|
|
Events are routed strictly per session (`session:{id}` groups on the hub,
|
|
per-session subscriber lists in process); the snapshot seams continue to carry
|
|
aggregate event counters and rates.
|
|
|
|
### Mirror gating
|
|
|
|
Each session's dashboard-mirror subscriber calls
|
|
`DashboardEventBroadcaster.Publish` for every event the session produces,
|
|
independently of whether anything is watching that session. `Publish` returns
|
|
immediately when `EventsHubViewerRegistry.HasViewers(sessionId)` is false,
|
|
**before** the redaction clone. That matters because redaction is on by default
|
|
(`Dashboard:ShowTagValues` false), so the unwatched steady state — nobody on any
|
|
session-details page — previously paid a deep protobuf clone plus a send to an
|
|
empty group for every event of every session. Behaviour for a watched session is
|
|
unchanged.
|
|
|
|
The registry counts both audiences, which is what lets one gate serve both seams.
|
|
`EventsHub` mirrors its own `AddToGroup` / `RemoveFromGroup` calls into it —
|
|
SignalR does not expose group membership, so the broadcaster cannot ask whether
|
|
`session:{id}` is empty — and `OnDisconnectedAsync` releases every subscription a
|
|
dropped connection held, the only reliable signal for a browser tab that closes
|
|
without unsubscribing. An in-process subscription registers the same way under a
|
|
synthetic `inproc-`-prefixed connection id, which cannot collide with a SignalR
|
|
connection id and makes the origin obvious in a debugger; disposing it removes the
|
|
viewer and releases the synthetic connection, because that id never reconnects and
|
|
nothing else would ever release its per-connection entry. Both paths use the same
|
|
ordering — register before becoming a delivery target, deregister after ceasing to
|
|
be one — so the widest a race window opens is a redaction clone that reaches
|
|
nobody, never a dropped event that was owed to a live viewer.
|
|
|
|
Redaction happens once per event, not once per audience: with
|
|
`Dashboard:ShowTagValues` false (the default) `Publish` produces a single redacted
|
|
clone and hands that same instance to the in-process subscribers and to the hub
|
|
group. With `ShowTagValues` true there is no clone at all — the original `MxEvent`
|
|
instance is handed to both audiences — so the "one clone per event" cost holds only
|
|
in the redacting configuration, and in the value-showing one both audiences share a
|
|
reference to the session pipeline's own event object. In-process delivery runs first
|
|
and synchronously — it cannot
|
|
throw, and it must not be skipped by the guard clause around the hub send — into
|
|
per-subscriber bounded drop-oldest channels, so a page that falls behind loses its
|
|
oldest queued events rather than blocking the session's event pipeline.
|
|
|
|
The mirror subscriber itself is still registered on the `SessionEventDistributor`
|
|
for the session's whole lifetime; only the per-event work is gated. Starting and
|
|
stopping the mirror lease lazily with the first and last viewer was considered
|
|
and deliberately not done — it entangles the dashboard with distributor
|
|
subscribe/unsubscribe lifetime (and with the replay/sequence bookkeeping that
|
|
attaching a subscriber mid-stream implies) for no additional saving beyond the
|
|
clone and send this gate already removes.
|
|
|
|
## Pages
|
|
|
|
### Dashboard home
|
|
|
|
Show top-level status:
|
|
|
|
- gateway status,
|
|
- gateway version,
|
|
- uptime,
|
|
- open sessions,
|
|
- workers running,
|
|
- sessions faulted,
|
|
- command rate,
|
|
- command failure count,
|
|
- event rate,
|
|
- event queue depth,
|
|
- worker restart/kill count.
|
|
|
|
Use Bootstrap cards for individual metric summaries. Keep the layout compact
|
|
and operational.
|
|
|
|
### Sessions page
|
|
|
|
Show active and recent sessions in a table:
|
|
|
|
- session id,
|
|
- client identity or API key display name,
|
|
- state,
|
|
- backend,
|
|
- worker process id,
|
|
- open time,
|
|
- last client activity,
|
|
- last worker heartbeat,
|
|
- active event subscribers,
|
|
- pending commands,
|
|
- event queue depth,
|
|
- last fault summary.
|
|
|
|
Rows should link to session details.
|
|
|
|
### Session details page
|
|
|
|
Show:
|
|
|
|
- session metadata,
|
|
- worker metadata,
|
|
- command counters by method,
|
|
- event counters by family,
|
|
- active server handles and item counts if gateway shadow state has them,
|
|
- latest faults,
|
|
- last heartbeat payload,
|
|
- admin Close session / Kill worker controls (Admin role only).
|
|
|
|
The Sessions list, the Workers list, and this details page all render the same
|
|
admin controls when the signed-in principal carries the `Admin` role; viewers
|
|
and the localhost-anonymous bypass see no action affordances and the server
|
|
re-checks the role on every invocation. Every destructive admin action is
|
|
gated by a confirmation dialog before it reaches `ISessionManager`.
|
|
|
|
- **Close session** routes through `ISessionManager.CloseSessionAsync`: the
|
|
worker is asked to shut down gracefully and is killed only as a fallback if
|
|
shutdown fails.
|
|
- **Kill worker** routes through `ISessionManager.KillWorkerAsync`: the worker
|
|
is killed immediately with no graceful-shutdown attempt. The session is
|
|
removed from the registry and the open-session slot is released either way.
|
|
|
|
Both actions write a canonical `AuditEvent` through `IAuditWriter` into the
|
|
`audit_event` store (category `SessionAdmin`, actions `dashboard-close-session`
|
|
and `dashboard-kill-worker`) in addition to the operational `ILogger` line — so a
|
|
worker killed mid-production leaves a durable, queryable row rather than only a
|
|
rotatable log entry. The event records the LDAP actor, the session id (`Target`),
|
|
the remote address, and an outcome of `Success`, `Failure`, or `Denied`
|
|
(an unauthorized attempt is still audited as `Denied`). This mirrors the API-key
|
|
management audit path.
|
|
|
|
### Workers page
|
|
|
|
Show:
|
|
|
|
- worker process id,
|
|
- session id,
|
|
- executable path/version,
|
|
- state,
|
|
- startup duration,
|
|
- memory and CPU if available,
|
|
- last heartbeat,
|
|
- current command correlation id,
|
|
- pending command count,
|
|
- event queue depth,
|
|
- restart/kill reason if terminal.
|
|
|
|
### Events page
|
|
|
|
Show aggregate event diagnostics:
|
|
|
|
- event rate by session,
|
|
- event rate by event family,
|
|
- total events since start,
|
|
- queue overflow count,
|
|
- stream disconnect count,
|
|
- recent terminal faults.
|
|
|
|
Do not display full tag values by default. If value display is later added, make
|
|
it opt-in and redacted.
|
|
|
|
### Browse page
|
|
|
|
`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch
|
|
live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from
|
|
`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a
|
|
render costs no gRPC call and no SQL round-trip. Each node shows its child
|
|
objects and, when expanded, its attributes with attribute name, data type
|
|
(including array dimension), and the alarm / historized flags. Galaxy SQL
|
|
carries no attribute description, so none is shown. A filter box switches the
|
|
tree to a flat list of matching attributes.
|
|
|
|
Right-clicking an attribute (or double-clicking it) adds it to the subscription
|
|
panel. The panel shows each subscribed tag's live value, MXAccess data type,
|
|
quality and source timestamp, refreshed every two seconds. The subscription
|
|
panel is the explicit opt-in tag-value surface: it always shows values
|
|
regardless of `Dashboard:ShowTagValues`, which governs the diagnostic
|
|
session/worker views and the per-session event mirror — both its hub and
|
|
in-process audiences (values are redacted from the mirrored events when the flag
|
|
is false).
|
|
|
|
### Alarms page
|
|
|
|
`/dashboard/alarms` lists the alarms the gateway's central alarm monitor
|
|
currently holds as Active or ActiveAcked, refreshed every three seconds. It
|
|
defaults to showing unacknowledged `Active` alarms; filters add acknowledged
|
|
alarms and narrow by area, severity range, and a reference/source/description
|
|
text search. Cleared alarms are not retained — the gateway holds no
|
|
alarm-history store, so the page reflects only the live active set. The page is
|
|
read-only; it does not acknowledge alarms. A provider-status badge tracks the
|
|
central monitor's health from `IGatewayAlarmService.StreamAsync` in process — the
|
|
alarm service is already a multi-subscriber fan-out, so the badge needs no SignalR
|
|
client, no loopback socket, and no hub token — while the alarm rows themselves
|
|
still come from the three-second poll. If `MxGateway:Alarms:Enabled` is
|
|
false the central monitor never starts, and the page says so instead of showing
|
|
an empty list with no explanation.
|
|
|
|
### Live data source
|
|
|
|
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
|
through `IDashboardLiveDataService` (`DashboardLiveDataService`). For tag data
|
|
it owns one shared gateway session for the whole dashboard, opened lazily on
|
|
first use via `ISessionManager` and re-opened transparently when it faults or
|
|
its lease expires. One session means one worker process backs every dashboard
|
|
circuit; all access is serialised so the worker sees one in-flight command at a
|
|
time. Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`.
|
|
|
|
The advise set that backs those reads is capped at 256 tags (one browse page plus
|
|
headroom) and evicted least-recently-read-first. Without the cap every tag any
|
|
viewer ever inspected stayed advised on the single dashboard worker until the
|
|
session faulted, so browsing a large galaxy accreted unbounded live MXAccess
|
|
subscriptions — and the event churn they feed — on one x86 process. Reading a tag
|
|
already in the set marks it most-recently-read; subscribing past the cap unadvises
|
|
the oldest entries with `GatewaySession.UnsubscribeBulkAsync` in one batch before
|
|
the new ones are advised. Tags read in the same call are never evicted to make
|
|
room for each other. A failed unadvise does not fail the read: the tags are
|
|
dropped from tracking anyway (they re-subscribe if read again), because the
|
|
session-invalidation path already handles gateway/worker drift.
|
|
|
|
The cap is per-read, not absolute. A read may never evict a tag it is itself about
|
|
to return, so one read of more distinct tags than the cap leaves the set that
|
|
large; what the eviction pass guarantees is
|
|
|
|
> after any read, the advise set holds at most `max(256, distinct tags in that read)`
|
|
> tags.
|
|
|
|
The overshoot is not sticky: the next read that subscribes anything measures the
|
|
overflow against the oversized set and evicts the whole excess in one pass (a
|
|
300-tag set plus one new tag evicts 45 and lands back at 256). A read that
|
|
subscribes nothing new evicts nothing, but neither can it grow the set. A browse
|
|
page requests far fewer tags than the cap, so in practice the set settles at 256.
|
|
|
|
The Alarms page does **not** use the dashboard session: alarm data comes from
|
|
the gateway's always-on central monitor. `QueryAlarmsAsync` reads
|
|
`IGatewayAlarmService.CurrentAlarms` — the monitor's in-process cache — so the
|
|
dashboard sees the same active-alarm set as every `StreamAlarms` client, with
|
|
no per-dashboard alarm subscription. When `MxGateway:Alarms:Enabled` is false
|
|
the monitor never starts and the cache stays empty.
|
|
|
|
### API keys page
|
|
|
|
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
|
|
operators, manages them. It reads key metadata through the same
|
|
`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act
|
|
on one source of truth.
|
|
|
|
The table shows one row per key:
|
|
|
|
- key id,
|
|
- status (`Active` or `Revoked`),
|
|
- display name,
|
|
- scopes,
|
|
- constraints (rendered as `unconstrained` when none are set),
|
|
- created timestamp,
|
|
- last-used timestamp.
|
|
|
|
Key secrets are never listed. Only the peppered hash is stored, and the page
|
|
never reconstructs a key. See [Authorization](./Authorization.md#constraint-enforcement)
|
|
for what each constraint means and how it is enforced on the gRPC path.
|
|
|
|
#### Management actions
|
|
|
|
Create, Rotate, Revoke, and Delete controls render only when the signed-in
|
|
user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an
|
|
authenticated 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
|
|
write subtrees, read and write tag globs, browse subtrees, max write
|
|
classification, and the read-alarm-only / read-historized-only flags.
|
|
- **Rotate** issues a new secret for an existing key id and invalidates the
|
|
old one. Active keys only — rotating a revoked key would un-revoke it, so
|
|
the button is not shown on revoked rows.
|
|
- **Revoke** marks a key revoked; a revoked key cannot be un-revoked.
|
|
- **Delete** permanently removes a key row from the auth database, but only
|
|
when the key is already revoked. `IApiKeyAdminStore.DeleteAsync` rejects
|
|
active keys (returns false) so the revoke event lands in the audit log
|
|
before the row disappears. Revoked rows show a Delete button in place of
|
|
the previous "No actions" placeholder.
|
|
|
|
Every destructive action (Rotate / Revoke / Delete) is gated by the shared
|
|
`ConfirmDialog` component before reaching the service; Create uses its own
|
|
form modal as the implicit confirmation step.
|
|
|
|
Create and Rotate return the assembled `mxgw_<keyId>_<secret>` token **once**,
|
|
in a one-time banner. It is never shown again, so the operator must copy it
|
|
immediately. This mirrors the `apikey create-key` / `rotate-key` CLI.
|
|
|
|
Every management action appends an `api_key_audit` entry
|
|
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
|
|
`dashboard-delete-key`) with the key id and the caller's remote address.
|
|
Secrets and pepper values are never logged.
|
|
|
|
### Settings page
|
|
|
|
Show read-only effective configuration:
|
|
|
|
- worker executable path,
|
|
- configured timeouts,
|
|
- queue capacities,
|
|
- auth mode,
|
|
- SQLite auth database path with sensitive parts redacted if needed,
|
|
- dashboard enabled state,
|
|
- protocol version.
|
|
|
|
Do not show API key secrets or pepper values.
|
|
|
|
## Authentication And Authorization
|
|
|
|
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 (`Administrator` or `Viewer`) and
|
|
issues a cookie carrying those role claims. `Administrator` is the canonical
|
|
role value — the exact string `GroupToRole` values and the validator accept
|
|
(`DashboardRoles.Admin`); the shorthand "Admin" used elsewhere in this document
|
|
names the same role and the `MxGateway.Dashboard.Admin` policy, not a distinct
|
|
config value.
|
|
|
|
Implemented behavior:
|
|
|
|
- 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
|
|
(HttpOnly, SameSite=Strict, Secure); the cookie is named
|
|
`__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
|
|
true (default) and no `MxGateway:Dashboard:CookieName` override is set,
|
|
otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
|
|
only honoured on a Secure cookie);
|
|
- 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.
|
|
|
|
Three authorization policies are registered:
|
|
|
|
- `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).
|
|
|
|
The in-process page feeds carry no authentication of their own, and need none:
|
|
`MapRazorComponents<App>()` applies `RequireAuthorization(ViewerPolicy)` to the
|
|
component endpoints, so a page can only run inside a circuit whose principal is
|
|
already an authorized Viewer. The hub-token flow below therefore covers only the
|
|
remote hub surface. Neither seam scopes a Viewer to particular sessions — SEC-25
|
|
(the per-session ACL) is outstanding for both, and the mirror's value redaction
|
|
remains the near-term mitigation, unchanged by the move in-process.
|
|
|
|
Two environmental bypasses still apply, both scoped to **read-only** access:
|
|
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
|
(default `true`, loopback only) each satisfy a requirement that includes the Viewer
|
|
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
|
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
|
role claim, so the "anonymous localhost is read-only" contract holds at the policy layer
|
|
rather than only in downstream service re-checks. Remote requests (outside the `Disabled`
|
|
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
|
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
|
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
|
|
|
### DisableLogin dev bypass
|
|
|
|
`MxGateway:Dashboard:DisableLogin` (default `false`) is a third bypass for
|
|
dev and test environments where LDAP is unavailable or irrelevant.
|
|
|
|
When the flag is `true`, the `DashboardAuthenticator`-backed cookie handler is
|
|
replaced by `DashboardAutoLoginAuthenticationHandler`, registered under the
|
|
same scheme name (`MxGateway.Dashboard`). The handler auto-authenticates every
|
|
incoming request — including requests from remote browsers, not just loopback —
|
|
as a principal for `MxGateway:Dashboard:AutoLoginUser` (default `multi-role`)
|
|
holding both the `Administrator` and `Viewer` role claims.
|
|
|
|
The same-scheme-name swap is intentional: every authorization policy
|
|
(`MxGateway.Dashboard.Viewer`, `MxGateway.Dashboard.Admin`,
|
|
`MxGateway.Dashboard.HubClients`) resolves the `MxGateway.Dashboard` scheme,
|
|
so the handler replacement requires zero changes to policies, Razor page
|
|
attributes, or hub authorization attributes. `UseAuthentication()` stamps the
|
|
principal on `HttpContext.User` for the full HTTP pipeline, the Blazor circuit,
|
|
and the SignalR hubs uniformly — there is no separate path for each surface.
|
|
|
|
This differs from `AllowAnonymousLocalhost`: that flag satisfies the Viewer
|
|
authorization requirement on loopback without minting an authenticated
|
|
principal, so role-gated write affordances (Admin-only API-key CRUD, Close/Kill
|
|
controls) stay hidden. `DisableLogin` mints a real multi-role principal, so
|
|
those affordances appear — which is the point for dev scenarios where a
|
|
developer needs the full Admin surface without standing up LDAP.
|
|
|
|
A loud one-time startup warning is logged when `DisableLogin` is `true`. The
|
|
gRPC API-key authentication path is untouched; only the dashboard cookie
|
|
surface is affected. Never enable in production.
|
|
|
|
### Hub bearer flow
|
|
|
|
This flow serves remote hub clients only; in-process pages are authorized by the
|
|
component endpoint's `ViewerPolicy` and never mint a token.
|
|
|
|
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 client 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 **5 minutes**.
|
|
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
|
`?access_token=…` (WebSocket upgrade query string). The query-string form is
|
|
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
|
|
custom header; because it is easy to capture (proxy logs, browser history),
|
|
it must never be request-logged (see the remarks on
|
|
`HubTokenAuthenticationHandler`).
|
|
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
|
rebuilds the `ClaimsPrincipal` with the carried roles.
|
|
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
|
identity.
|
|
|
|
There is no in-repo hub client: the helper that once wrapped `HubConnectionBuilder`
|
|
for a circuit was deleted when the pages moved to the in-process seams. An external
|
|
client re-fetches `/hubs/token` on every (re)connect itself, which is what makes the
|
|
short 5-minute lifetime transparent.
|
|
|
|
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
|
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
|
carry no server-side revocation state (no jti denylist). A token captured before
|
|
logout remains valid until it expires, and a role change or key revocation does
|
|
not take effect on an already-issued token until then. The 5-minute lifetime is
|
|
the deliberate mitigation: it bounds that exposure window without the cost of a
|
|
revocation store. Server-side revocation is deferred until per-session hub ACLs
|
|
land (see the per-session-ACL note), at which point tokens gain session/role
|
|
binding and a denylist becomes worthwhile.
|
|
|
|
## Configuration
|
|
|
|
Effective configuration:
|
|
|
|
```json
|
|
{
|
|
"MxGateway": {
|
|
"Dashboard": {
|
|
"Enabled": true,
|
|
"AllowAnonymousLocalhost": true,
|
|
"SnapshotIntervalMilliseconds": 1000,
|
|
"RecentFaultLimit": 100,
|
|
"RecentSessionLimit": 200,
|
|
"ShowTagValues": false,
|
|
"GroupToRole": {
|
|
"GwAdmin": "Administrator",
|
|
"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.
|
|
- Do not display credential-bearing MXAccess command values.
|
|
- Do not display full tag values by default.
|
|
- Do not expose worker pipe names with nonce or sensitive details.
|
|
- Protect dashboard auth cookies with `HttpOnly`, `Secure`, and `SameSite`.
|
|
- Require TLS for remote dashboard access.
|
|
- Use anti-forgery protection for login/logout and any future admin actions.
|
|
|
|
## Styling
|
|
|
|
The dashboard serves Bootstrap 5.3.3 assets from
|
|
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
|
|
from `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css`.
|
|
|
|
Recommended visual language:
|
|
|
|
- compact tables,
|
|
- status badges,
|
|
- metric cards,
|
|
- Bootstrap alerts for faults,
|
|
- restrained colors,
|
|
- no decorative hero sections,
|
|
- no charting dependency for v1.
|
|
|
|
If charts are added later, prefer simple server-generated data tables first. Do
|
|
not add a JavaScript charting dependency without a specific need.
|
|
|
|
The reusable visual rules for replicating this interface in other projects are
|
|
documented in [Dashboard Interface Design](./DashboardInterfaceDesign.md).
|
|
|
|
## Testing
|
|
|
|
Dashboard unit/component tests should cover:
|
|
|
|
- snapshot projection,
|
|
- dashboard auth authorization decisions,
|
|
- login API-key validation behavior,
|
|
- pages render with empty state,
|
|
- pages render with active sessions,
|
|
- pages render with faulted sessions,
|
|
- realtime subscription disposal,
|
|
- redaction of API keys and credential values.
|
|
|
|
Use bUnit if component testing is added. Otherwise keep the first tests focused
|
|
on snapshot services and authorization logic.
|
|
|
|
Integration tests should verify:
|
|
|
|
- dashboard disabled returns not found or configured fallback,
|
|
- dashboard requires auth when enabled,
|
|
- 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 reach a page through
|
|
the in-process `IDashboardSnapshotFeed` and reach a remote client through the
|
|
`/hubs/snapshot` push — neither by polling;
|
|
- the snapshot feed's idle gate: no subscribers means no pump, the last
|
|
subscriber of the live generation stops it, and a subscriber arriving
|
|
mid-teardown gets a fresh generation rather than a dead one;
|
|
- the event mirror's viewer gate counts in-process subscriptions as well as hub
|
|
connections, and a disposed in-process subscription releases its viewer count.
|
|
|
|
## Initial Implementation Slice
|
|
|
|
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 LDAP bind + role-mapped HTTP-only cookie.
|
|
5. `DashboardSnapshotService` projecting gateway state for read views.
|
|
6. home page with metric cards.
|
|
7. sessions page with active session table and session details.
|
|
8. workers page with worker table.
|
|
9. events page with aggregate counters.
|
|
10. settings page with redacted effective configuration.
|
|
11. periodic realtime refresh through Blazor Server.
|
|
12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot
|
|
projection/redaction tests.
|
|
|
|
Subsequent slices added Admin-gated destructive actions: API-key
|
|
Create/Rotate/Revoke (and Delete on revoked keys), and session/worker
|
|
Close/Kill via `IDashboardSessionAdminService` → `ISessionManager`. Every
|
|
destructive action passes through the shared `ConfirmDialog` component
|
|
before reaching its service.
|
|
|
|
## Related Documentation
|
|
|
|
- [Dashboard Interface Design](./DashboardInterfaceDesign.md)
|
|
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
|
- [Authentication](./Authentication.md)
|
|
- [Authorization](./Authorization.md)
|
|
- [Sessions](./Sessions.md)
|
|
- [Metrics](./Metrics.md)
|
|
- [Diagnostics](./Diagnostics.md)
|