fix(dashboard): bounded alarm drains, feed resubscribe, live pill, drop dead hub factory; doc corrections
This commit is contained in:
@@ -215,24 +215,28 @@ Three authorization policies are registered out of these options:
|
||||
|
||||
### SignalR hubs
|
||||
|
||||
When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
|
||||
When the dashboard is enabled, three hubs are mapped under `/hubs/*`. They are
|
||||
the **remote** surface — for clients outside the gateway process. Server-rendered
|
||||
pages do not use them: a page runs in this process and reads the producing
|
||||
services through in-process seams (`IDashboardSnapshotFeed`,
|
||||
`IDashboardSessionEventSubscriber`, `IGatewayAlarmService`) rather than opening a
|
||||
loopback WebSocket back into its own heap.
|
||||
|
||||
- `GET /hubs/snapshot` — pushes `DashboardSnapshot` whenever the snapshot
|
||||
service produces a new one. Drives every page that inherits
|
||||
`DashboardPageBase`; replaces the earlier polling loop.
|
||||
service produces a new one. Idle-gated on connected clients, so it stays
|
||||
dormant unless a remote client connects.
|
||||
- `GET /hubs/alarms` — re-broadcasts the `AlarmFeedMessage` stream from the
|
||||
central alarm monitor to all connected clients (group `__alarms__`).
|
||||
- `GET /hubs/events` — per-session MxEvent feed. Clients call
|
||||
`SubscribeSession(sessionId)` to join `session:{id}`. Events are mirrored
|
||||
from the corresponding gRPC `StreamEvents` call as a fire-and-forget
|
||||
side-effect; the dashboard only sees events while a gRPC client is also
|
||||
subscribed to that session.
|
||||
from the session's own event distributor, gated on `EventsHubViewerRegistry`
|
||||
so an unwatched session pays nothing.
|
||||
|
||||
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
||||
token for the calling user; the Blazor pages use it via
|
||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
||||
The factory refreshes the token on every (re)connect, so the short lifetime
|
||||
(SEC-05) is transparent to clients. The token is not server-side revocable;
|
||||
token for the calling user, so a remote hub client can authenticate the
|
||||
SignalR connection without forwarding the HttpOnly dashboard cookie. Such a
|
||||
client is expected to re-fetch on every (re)connect, which makes the short
|
||||
lifetime (SEC-05) transparent. The token is not server-side revocable;
|
||||
its short lifetime bounds exposure of a captured token (see
|
||||
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
||||
|
||||
|
||||
@@ -118,8 +118,11 @@ 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, stays registered for out-of-tree consumers, but no in-repo
|
||||
page resolves it.
|
||||
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
|
||||
|
||||
@@ -195,7 +198,27 @@ 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. On dispose it cancels the watch and waits at most **5 seconds**
|
||||
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.
|
||||
@@ -214,6 +237,19 @@ 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
|
||||
@@ -329,9 +365,14 @@ ordering — register before becoming a delivery target, deregister after ceasin
|
||||
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: `Publish` produces a
|
||||
single redacted clone and hands that same instance to the in-process subscribers
|
||||
and to the hub group. In-process delivery runs first and synchronously — it cannot
|
||||
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.
|
||||
@@ -715,11 +756,10 @@ dashboard mints short-lived bearer tokens for the connection:
|
||||
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
||||
identity.
|
||||
|
||||
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
||||
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
||||
every (re)connect, so the short 5-minute lifetime is transparent to whoever uses
|
||||
it. It remains registered, but no in-repo page opens a hub connection any more;
|
||||
external clients implement the equivalent refresh themselves.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user