Rewrite the Pages 'Show:' lists to what each page actually renders (no per-session subscriber/pending-command/queue counters, no worker memory/CPU, no per-family command counters, counters not rates). Document the per-session EventsHub pump, alarm-hub send bound and AlarmFeedReset marker, configuration sent once per snapshot-hub connection, health-derived gateway status, Browse search debounce/index, bounded disposal, 30s circuit retention with connection-gated polling, per-viewer advise-set share, and AlarmsPage memo.
63 KiB
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:
/
/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 for
the underlying gRPC service.
High-Level Components
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:
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, gateway-status frames only — provider_status for the badge and snapshot_status for the truncated-snapshot banner; 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 status feed that
drives the provider badge and the truncated-snapshot banner) 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) |
SnapshotUpdated: DashboardSnapshot without configuration; ConfigurationUpdated: EffectiveGatewayConfiguration |
Ticks go to all connected clients on every snapshot interval, but only while at least one client is connected (see "Idle gating" below). The effective configuration is startup-static and the largest part of the record, so it is [JsonIgnore]d on DashboardSnapshot and sent once per connection instead: OnConnectedAsync pushes ConfigurationUpdated and then the current SnapshotUpdated, and the hub method GetConfiguration() returns it on demand. Each tick broadcast is bounded to 1 s; a send that misses the bound is abandoned, so one slow client cannot delay the next tick for everyone. |
AlarmsHub |
/hubs/alarms |
AlarmsHubPublisher (BackgroundService consuming IGatewayAlarmService.StreamAsync(filter: null)) |
AlarmFeedMessage (active_alarm / snapshot_complete / transition / provider_status / snapshot_status) |
Guarded by MxGateway.Dashboard.Alarms, not HubClients: a real authenticated dashboard role is required, so the Authentication:Mode=Disabled and anonymous-localhost bypasses are excluded (SEC-40). Connected clients auto-join __alarms__; all admitted clients receive every message — the feed is galaxy-wide and carries no session provenance, so no per-caller scoping applies (see "Alarm-feed visibility"). Publisher auto-reconnects every 5s on stream faults. Every subscription to the monitor — the first and each resubscribe — is preceded by an AlarmFeedReset frame ({ generation }, monotonic per process): the monitor primes each new subscription with a full active_alarm snapshot ending in snapshot_complete, so a client must discard its accumulated alarm state on the marker and rebuild from that snapshot rather than double-counting it. Each broadcast is bounded to 2 s and abandoned on timeout: awaiting it unbounded let one slow client stall the drain until the monitor dropped the publisher as a slow subscriber, and the resubscribe then replayed the whole snapshot to every client. The alarm value fields (current_value / limit_value) are stripped from a deep-cloned copy of the message when Dashboard:ShowTagValues is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC StreamAlarms subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
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. Delivery to the group goes through one bounded per-session pump (see "Mirror gating"), so a slow viewer loses its oldest events instead of accumulating pending sends — 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. SubscribeSession is gated by IDashboardSessionAcl (SEC-25 / TST-15): a denied caller gets a HubException, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a permitted subscriber sees. |
No hub client ships in this repository: the server-rendered pages read the
in-process seams above, and the hubs serve external consumers. Those consumers
must honour AlarmFeedReset and keep the configuration from
ConfigurationUpdated, since ticks no longer carry it.
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
SessionEventDistributorto its internal dashboard-mirror subscriber (independent of any gRPCStreamEvents); - the alarms page's status feed resubscribes one second after its
IGatewayAlarmService.StreamAsyncenumeration ends — the monitor completes a subscriber's stream only when that subscriber has fallen behind, which resubscribing recovers (a monitor restart keeps the channel and pushes cleared status frames through it) — and the badge and banner hold their last values in between. That feed carries both gateway-status frames:provider_statusdrives the badge, andsnapshot_statusdrives the truncated-snapshot banner, so the caveat appears on the monitor's verdict change rather than up to three seconds later. Every subscriber is primed with asnapshot_statusframe at open, so a page attaching mid-truncation needs no priming logic of its own. The page's alarm rows are independent of that stream and refresh on the 3 s poll, which also re-asserts the truncation verdict as its reconcile baseline — both sources read the same monitor verdict, and neither is synthesized page-side.
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.
Three 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 ofIOptions<GatewayOptions>, which the gateway binds at startup and never reloads, so rebuilding the whole option tree every tick produced an identical object. For the same reason it is not serialized on the/hubs/snapshottick (see the hub table above); - the gateway status is one
HealthCheckService.CheckHealthAsyncevaluation per tick, bounded to 2 s and behind a zero-wait gate so concurrent enumerations (the hub publisher and the page feed) do not both evaluate;GetSnapshot()reads the cached result; - 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.
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 — 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.
Hub delivery has the same shape. EventsHubViewerRegistry counts remote hub
connections separately from inproc- subscribers (HasHubViewers), and while a
session has at least one, Publish writes the event into that session's
HubPump: a 256-deep drop-oldest channel drained by a single task that sends one
event at a time to session:{id}, each send bounded to 5 s. A session watched only
in process starts no pump. The pump starts on the first event published while a
hub viewer is present and is torn down when the registry raises
HubViewersReleased for the session's last hub viewer (unsubscribe or disconnect).
Before this, Publish fired a group SendAsync per event and returned; a slow
remote viewer made each send stay pending in the SignalR lifetime manager, and the
pending sends — each holding a cloned event — grew without bound.
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
The Overview page (/) shows, from the snapshot:
- gateway status badge — the aggregate status of the registered ASP.NET Core
health checks (
Healthy/Degraded/Unhealthy), evaluated once per snapshot tick with a 2 s bound and cached for the synchronousGetSnapshot()seed path. It readsUnknownbefore the first evaluation, when an evaluation throws or times out, and when noHealthCheckServiceis registered — never a hardcodedHealthy(SEC-27); - the snapshot's generation time;
- metric cards: uptime (with the gateway version as detail), open sessions, workers running, worker event queue depth, failed commands, events received, faults, and queue overflows — all cumulative counters or point-in-time gauges, not rates;
- a Galaxy Repository section: cache status, last deploy time, object count (with area count), attribute, historized-attribute and alarm-attribute counts, and the last refresh time;
- the recent faults list (
Dashboard:RecentFaultLimit).
There is no per-family command or event rate, no faulted-session counter, and no
worker restart/kill card on this page; mxgateway.workers.killed and
mxgateway.workers.exited are in the snapshot's metric list but not rendered.
Metric summaries use Bootstrap cards; the layout stays compact and operational.
Sessions page
Shows the most recent sessions (Dashboard:RecentSessionLimit, newest first) in a
table with these columns:
- session id (links to the session details page),
- session state,
- client identity (redacted),
- backend,
- worker process id and worker state,
- events received for the session (the
mxgateway.events.receivedper-session counter), - open time,
- last client activity,
- last worker heartbeat,
- last fault summary (abbreviated, full text on hover),
- Close / Kill actions for Admin principals.
Event subscriber counts, pending command counts, and per-session queue depth are not tracked per session and are not shown.
Session details page
Shows:
- a session section: state badge, backend, client identity, client session name, client correlation id (all redacted), open time, last activity, lease expiry, events received, and last fault;
- a worker section: process id, worker state, and last heartbeat time;
- a "Recent events" table — the newest 50 events from the in-process mirror, with worker sequence, family, server handle, item handle, and status (no values), plus a live/offline pill; gated by the session ACL, so a caller who may not observe the session sees "Not authorized" instead;
- admin Close session / Kill worker controls (Admin role only).
Per-method command counters, per-family event counters for the session, server handle / item counts, and the heartbeat payload are not shown — the gateway keeps no per-session shadow state for them.
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
Shows one row per worker that is not closed, with:
- worker process id,
- worker state,
- owning session id (links to the session details page),
- last heartbeat time,
- last fault summary (the owning session's final fault),
- a Kill worker action for Admin principals.
Executable path and version, startup duration, memory/CPU, in-flight command correlation, pending command count, and per-worker queue depth are not collected and are not shown; the worker executable path is on the Settings page.
Events page
Shows aggregate event diagnostics from the snapshot:
- events received since start,
- worker event queue depth and gRPC stream queue depth (gauges),
- queue overflow count,
- gRPC stream disconnect count,
- an "Event Families" table: cumulative events received per event family.
The figures are counters since start, not rates; per-session event counts are on the Sessions page. No tag values are shown here.
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 — but the value column
obeys Dashboard:ShowTagValues like every other dashboard value surface. With
the flag false (the default) DashboardLiveDataService hands the page
[redacted] in place of the formatted value of each successfully read tag —
a failed read keeps its - placeholder, since there was no value to suppress —
while data type, quality, source
timestamp and any read error are untouched, so the panel still answers "is this
tag advising and healthy" without disclosing the value. The substitution happens
at the service, not in the page: one decision point, and the value never enters
the render tree. Set the flag true to make the panel the explicit opt-in
tag-value surface it was originally designed as.
The filter box is debounced: keystrokes within 150 ms coalesce into one search,
which runs on the thread pool with a cancellation token that the next keystroke
cancels, and only the latest query may publish its results. The search reads
DashboardBrowseSearchIndex, a singleton holding every attribute's full reference
and name lowercased once, keyed on the Galaxy cache Sequence and rebuilt only when
a deploy moves it. The previous filter rescanned the whole cached Galaxy with
case-insensitive comparisons, synchronously on the render dispatcher, once per
keystroke per circuit.
The page's two background loops — the 2 s live-read poll and the Galaxy
deploy-notification subscription — handle faults inside the loop and retry; only
cancellation ends them. A poll fault surfaces in the "Live read failed" banner; a
notifier fault surfaces as a "redeploy notifications failed (retrying)" warning and
resubscribes after 5 s; the tree rebuild after a redeploy runs on the renderer
dispatcher. DisposeAsync cancels the loops and any in-flight search, drains them
concurrently with the same 5 s bound DashboardPageBase uses, and disposes its
cancellation sources in a finally.
A closed or backgrounded tab leaves its circuit alive, disconnected, for
DisconnectedCircuitRetentionPeriod, which the dashboard sets to 30 seconds
(DashboardServiceCollectionExtensions.DisconnectedCircuitRetention) instead of
the framework's three minutes. Within that window the poll does not read: a scoped
DashboardCircuitConnectionTracker, registered as the circuit's CircuitHandler,
reports the browser connection down and the poll skips its ticks until it comes
back. Every tick it does run is a ReadBulk against the shared dashboard worker,
so a closed tab previously kept the worker busy for three minutes.
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 — and the same subscription carries the
snapshot_status frame behind the truncated-snapshot banner ("Alarm snapshot may be
incomplete"), so a capped provider fetch is caveated the moment the monitor decides
it. The alarm rows themselves still come from the three-second poll, which also
re-asserts the truncation verdict as the reconcile baseline. The filtered rows and
the area list are memoized in DashboardAlarmView on the poll's alarm generation and
the filter inputs, so the "Showing" card and the table share one filter-and-sort per
change rather than recomputing it several times per render. 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.
The page carries [Authorize(Policy = MxGateway.Dashboard.Alarms)] rather than relying on the
Viewer policy MapRazorComponents<App>() applies to every component route (SEC-40), so it
needs a real authenticated dashboard role: the Authentication:Mode=Disabled and
anonymous-localhost bypasses reach every other read-only page but not this one. It is the same
policy AlarmsHub carries — the two read one central monitor and must not disagree about who
may see it — and the side rail hides the Alarms link for callers the policy denies. What a
permitted caller sees is galaxy-wide and not tag-scoped; see "Alarm-feed visibility".
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 256 is shared between viewers rather than one global LRU. Each Browse page
reads under its own viewer id, every advised tag records the viewer that read it
last, and each active viewer (one that read within the last 30 s) owns a share of
256 / active viewers, floored at 32. When a read needs room, eviction first takes
the least-recently-read tags of other viewers that are over their share — an
inactive viewer's share is zero, so a closed page's tags go first — and then the
reader's own tags that this read did not touch. A viewer within its share is never
evicted by someone else's read, so with several viewers the cap is soft: the set
can sit above 256 rather than strip a neighbour. Under the global LRU, two circuits
browsing different areas evicted each other's whole panels on every poll and
re-advised them two seconds later. With a single viewer the behaviour is exactly
the global LRU described above.
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 (
ActiveorRevoked), - display name,
- scopes,
- constraints (rendered as
unconstrainedwhen 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 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
GatewayScopescatalog), 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.DeleteAsyncrejects 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
Shows the read-only effective configuration:
- authentication: mode, SQLite auth database path, pepper secret name, and whether migrations run on startup;
- LDAP: enabled, server and port, fallback servers, transport, search base, service account DN, service password (redacted), and the username and group attributes;
- the LDAP group mappings that decide what a signed-in user may see —
Dashboard:GroupToRole(group →Administrator/Viewer) andDashboard:GroupToTag(group → the session tags it grants); - worker: executable path, required architecture, and the startup, shutdown, and heartbeat-grace timeouts;
- sessions: default command timeout and max sessions;
- events: queue capacity and backpressure policy;
- dashboard: enabled, anonymous localhost, snapshot interval, recent fault and
session limits,
ShowTagValues, andDashboard:UntaggedSessionVisibility(who sees a session whose owning API key carries no dashboard tags); - the worker 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
/loginHTML form posts username/password to the gateway; DashboardAuthenticatorbinds againstMxGateway:Ldap(service-account bind, user search, candidate bind) usingNovell.Directory.Ldap.NETStandard;- the user's groups arrive from the LDAP provider already stripped to short
names (the CN / first-RDN value of each
memberOfentry) and are matched against the short-name keys ofMxGateway:Dashboard:GroupToRole— a full-DN key there never matches; the resolved role(s) are emitted asClaimTypes.Roleclaims, alongside the per-groupmxgateway:ldap_groupclaims; - a successful login signs in the
MxGateway.Dashboardcookie scheme (HttpOnly, SameSite=Strict, Secure); the cookie is named__Host-MxGatewayDashboardwhenMxGateway:Dashboard:RequireHttpsCookieis true (default) and noMxGateway:Dashboard:CookieNameoverride is set, otherwise the plainMxGatewayDashboardname 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.
Four 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— the snapshot and events SignalR hubs. Accepts the dashboard cookie OR aMxGateway.Dashboard.HubTokenbearer (used by WebSocket upgrades where the cookie can't be forwarded).MxGateway.Dashboard.Alarms— the alarm feed:AlarmsHuband the/alarmspage. Same two schemes asHubClients, but built fromAuthenticatedDashboardRoleRequirementinstead ofDashboardAuthorizationRequirement(SEC-40). BecauseDashboardAuthorizationHandlerhandles only the latter type, the read-only environment bypasses below cannot satisfy this policy — see "Alarm-feed visibility".
The first three requirement types are satisfiable by the environment bypasses;
MxGateway.Dashboard.Alarms and the mounted Secrets UI's secrets:manage are not,
both for the same structural reason — the bypass handler is typed to a requirement
they do not use.
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 policy scopes a Viewer to particular sessions — that is
IDashboardSessionAcl's job (SEC-25 / TST-15), consulted by both subscribe seams:
EventsHub.SubscribeSession for remote hub clients and the session-details page's
in-process subscribe, which renders an inline denial instead of subscribing. The
decision is: authenticated Administrator → allow (checked before the session is
looked up, so an Administrator naming a session that just closed is still allowed);
unknown session id → deny; untagged session → Dashboard:UntaggedSessionVisibility
(AdminOnly by default); otherwise allow iff the session's tags intersect the
caller's granted tags, ordinal-ignore-case. A session's tags are inherited from its
owning API key's --dashboard-tags constraint and are immutable for the session's
life, so a subscribe-time decision cannot go stale while the subscription lives and
no per-event re-check is needed. A Viewer's grant comes from
Dashboard:GroupToTag applied to their LDAP groups. Since SEC-41 the ACL re-resolves
that grant on every subscribe, from the caller's mxgateway:ldap_group claims against the
currently bound GroupToTag map — the same one DashboardGroupTagMapping.MapGroupsToTags
call the hub-token mint makes — rather than trusting the zb:dashboardtag claims stamped
into the cookie at login. Those stamped claims were resolved under whatever map was bound
when the user signed in, and the auth cookie outlives the gateway restart that applies a
GroupToTag edit, so trusting them let a revoked grant survive the restart meant to remove
it. The stamped claims remain the fallback for a principal carrying no group claims,
which in practice means the hub-token principal: the token payload carries roles and tags
but no LDAP groups, and the mint already re-resolved them. That fallback cannot re-open the
cookie hole, because DashboardAuthenticator stamps group and tag claims from one group
list — a cookie principal holding a tag claim always holds the groups it came from, and a
login yielding no groups yields no tags. A principal carrying neither kind of claim —
anonymous localhost included — is an empty-grant Viewer. The mirror's value redaction is an
independent layer: it bounds what a permitted subscriber sees, not who may subscribe.
Revocation bounds. Three different revocations propagate on three different clocks, and the difference is intrinsic to where each fact lives:
| Revocation | Takes effect |
|---|---|
A GroupToTag entry removed from config |
At the next subscribe after the gateway restart that rebinds options — identically on both seams, since both re-resolve against current options. GatewayOptions binds once at startup, so the restart is what applies the edit; nothing further is needed. |
| An LDAP group-membership revocation | When the principal is next rebuilt: at re-login for the cookie seam, bounded by the cookie's sliding 8-hour idle life (renewable by activity); within one 5-minute token lifetime for the hub-token seam. Neither seam re-queries LDAP per subscribe. |
| Either, for an already-open subscription | Never — the subscription lives until it drops. The ACL is a subscribe-time gate by design (session tags are immutable, so no per-event re-check is warranted); closing an in-flight stream on a grant change would need a revocation channel the dashboard does not have. Force it by closing the session, or by restarting the gateway, which tears down every circuit and hub connection. |
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. Since SEC-40 the bypasses also do not
reach the alarm feed (MxGateway.Dashboard.Alarms) — read-only is not the same as
"may see the whole plant's alarm metadata"; see "Alarm-feed visibility" below. 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.
Alarm-feed visibility
The alarm feed is not tag-scoped, and that is a deliberate, bounded decision rather than an oversight in the SEC-25 ACL.
The gateway's alarm monitor is central and session-less: one subscription expression, one
IGatewayAlarmService feed, consumed by AlarmsHubPublisher (which re-broadcasts every
message to the __alarms__ group) and by the /alarms page in process. An AlarmFeedMessage
carries no session id and no owning API key, so there is nothing for the per-session tag
intersection to intersect — a tag ACL cannot be applied to it without inventing provenance the
feed does not have. Deriving a filter from the caller's tags would also mean equating a
visibility tag with an alarm-reference prefix, which GroupToTag deliberately does not do
(tags are visibility labels, never data-access constraints).
So alarm metadata stays galaxy-wide for every authenticated dashboard Viewer: alarm
reference (a full tag path), state, severity, priority, category, area, and acknowledging
operator are all visible to any real dashboard role, regardless of which sessions their tag
grant covers. Alarm values (current_value / limit_value) are still stripped when
Dashboard:ShowTagValues is false (the default), on the hub seam as on the events seam.
What SEC-40 tightened is the entry gate rather than the scope. Both readers of the monitor —
AlarmsHub and the /alarms page — carry MxGateway.Dashboard.Alarms, whose requirement type
the environment-bypass handler does not handle. Consequences:
MxGateway:Authentication:Mode = Disabledand anonymous localhost (AllowAnonymousLocalhost, defaulttrue) no longer receive the alarm feed at all. They keep read-only access to every other dashboard page;/alarmsand/hubs/alarmsdeny, and the side rail hides the Alarms link rather than offering a dead end.Dashboard:DisableLoginauto-login is unaffected: it mints a genuinely authenticated principal holding both roles, so it satisfies the policy like any signed-in user.- Gating only the hub was rejected: the page reads the same monitor in process, so the disclosure would have been one navigation away.
Operators who want the alarm feed on an unauthenticated local console must sign in (or, in a dev
environment, set Dashboard:DisableLogin=true); there is deliberately no config flag that re-opens
the feed to the bypasses.
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:
- The cookie-authenticated client calls
GET /hubs/token(gated byViewerPolicy, cookie-only). 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 purposeZB.MOM.WW.MxGateway.Dashboard.HubToken.v1, and returns the protected string. Lifetime is 5 minutes.- 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 onHubTokenAuthenticationHandler). HubTokenAuthenticationHandlervalidates the protected payload and rebuilds theClaimsPrincipalwith the carried roles.- 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. It now bounds a stale tag grant the same way: the token carries
the tags resolved from the caller's LDAP groups at mint time, so removing a
GroupToTag entry takes effect for token-authenticated hub connections within one
lifetime. Since SEC-41 the cookie seam is no longer the weaker one: the ACL
re-resolves a cookie principal's grant from its group claims against current
options on every subscribe, so a GroupToTag edit takes effect on both seams at
the next subscribe after the restart that rebinds options — the stamped
zb:dashboardtag claims are consulted only for a principal with no group claims
to re-resolve from, i.e. the hub-token principal itself. What the token's lifetime
still bounds uniquely is a stale role set and a stale LDAP group list, since
both are copied into the payload at mint. See the revocation-bounds table under
"Authentication And Authorization". A jti denylist stays deferred — the short
lifetime already bounds every claim the token carries.
Configuration
Effective configuration:
{
"MxGateway": {
"Dashboard": {
"Enabled": true,
"AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Administrator",
"GwReader": "Viewer"
}
}
}
}
See Gateway Configuration 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, andSameSite. - 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.
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
IDashboardSnapshotFeedand reach a remote client through the/hubs/snapshotpush — 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:
- Blazor Server hosting in
ZB.MOM.WW.MxGateway.Server. - local Bootstrap static assets.
- dashboard configuration binding.
- dashboard auth using LDAP bind + role-mapped HTTP-only cookie.
DashboardSnapshotServiceprojecting gateway state for read views.- home page with metric cards.
- sessions page with active session table and session details.
- workers page with worker table.
- events page with aggregate counters.
- settings page with redacted effective configuration.
- periodic realtime refresh through Blazor Server.
- 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.