fix(dashboard): bounded alarm drains, feed resubscribe, live pill, drop dead hub factory; doc corrections

This commit is contained in:
Joseph Doherty
2026-08-16 04:12:25 -04:00
parent e1ff05c605
commit 3faa272db9
11 changed files with 240 additions and 127 deletions
+51 -11
View File
@@ -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