Files
mxaccessgw/docs/GatewayDashboardDesign.md
T

43 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, 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 each 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.

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 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:

{
  "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, 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.

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 IDashboardSessionAdminServiceISessionManager. Every destructive action passes through the shared ConfirmDialog component before reaching its service.