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
|
### 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
|
- `GET /hubs/snapshot` — pushes `DashboardSnapshot` whenever the snapshot
|
||||||
service produces a new one. Drives every page that inherits
|
service produces a new one. Idle-gated on connected clients, so it stays
|
||||||
`DashboardPageBase`; replaces the earlier polling loop.
|
dormant unless a remote client connects.
|
||||||
- `GET /hubs/alarms` — re-broadcasts the `AlarmFeedMessage` stream from the
|
- `GET /hubs/alarms` — re-broadcasts the `AlarmFeedMessage` stream from the
|
||||||
central alarm monitor to all connected clients (group `__alarms__`).
|
central alarm monitor to all connected clients (group `__alarms__`).
|
||||||
- `GET /hubs/events` — per-session MxEvent feed. Clients call
|
- `GET /hubs/events` — per-session MxEvent feed. Clients call
|
||||||
`SubscribeSession(sessionId)` to join `session:{id}`. Events are mirrored
|
`SubscribeSession(sessionId)` to join `session:{id}`. Events are mirrored
|
||||||
from the corresponding gRPC `StreamEvents` call as a fire-and-forget
|
from the session's own event distributor, gated on `EventsHubViewerRegistry`
|
||||||
side-effect; the dashboard only sees events while a gRPC client is also
|
so an unwatched session pays nothing.
|
||||||
subscribed to that session.
|
|
||||||
|
|
||||||
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
||||||
token for the calling user; the Blazor pages use it via
|
token for the calling user, so a remote hub client can authenticate the
|
||||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
SignalR connection without forwarding the HttpOnly dashboard cookie. Such a
|
||||||
The factory refreshes the token on every (re)connect, so the short lifetime
|
client is expected to re-fetch on every (re)connect, which makes the short
|
||||||
(SEC-05) is transparent to clients. The token is not server-side revocable;
|
lifetime (SEC-05) transparent. The token is not server-side revocable;
|
||||||
its short lifetime bounds exposure of a captured token (see
|
its short lifetime bounds exposure of a captured token (see
|
||||||
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,11 @@ so it consumes the producing services directly through in-process seams —
|
|||||||
`IDashboardSnapshotFeed`, `IDashboardSessionEventSubscriber`, and
|
`IDashboardSnapshotFeed`, `IDashboardSessionEventSubscriber`, and
|
||||||
`IGatewayAlarmService` — instead of opening a loopback WebSocket back into its
|
`IGatewayAlarmService` — instead of opening a loopback WebSocket back into its
|
||||||
own heap. `DashboardHubConnectionFactory`, the helper a circuit used to open
|
own heap. `DashboardHubConnectionFactory`, the helper a circuit used to open
|
||||||
those connections, stays registered for out-of-tree consumers, but no in-repo
|
those connections, has been deleted along with the `Microsoft.AspNetCore.SignalR.Client`
|
||||||
page resolves it.
|
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
|
## Dashboard Data Source
|
||||||
|
|
||||||
@@ -195,7 +198,27 @@ instead of buffering without bound or stalling the pump.
|
|||||||
`DashboardPageBase` seeds `Snapshot` synchronously from
|
`DashboardPageBase` seeds `Snapshot` synchronously from
|
||||||
`IDashboardSnapshotService.GetSnapshot()` in `OnInitializedAsync` so the first
|
`IDashboardSnapshotService.GetSnapshot()` in `OnInitializedAsync` so the first
|
||||||
render is non-empty, then calls `InvokeAsync(StateHasChanged)` for every snapshot
|
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
|
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
|
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.
|
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
|
registration and completes the channel, so the pump has an exit even if
|
||||||
cancellation is missed), then drains under its own timeout.
|
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)
|
### SignalR hubs (remote clients)
|
||||||
|
|
||||||
Updates for out-of-process clients flow over three SignalR hubs, all guarded by the
|
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
|
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.
|
nobody, never a dropped event that was owed to a live viewer.
|
||||||
|
|
||||||
Redaction happens once per event, not once per audience: `Publish` produces a
|
Redaction happens once per event, not once per audience: with
|
||||||
single redacted clone and hands that same instance to the in-process subscribers
|
`Dashboard:ShowTagValues` false (the default) `Publish` produces a single redacted
|
||||||
and to the hub group. In-process delivery runs first and synchronously — it cannot
|
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
|
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
|
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.
|
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
|
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
||||||
identity.
|
identity.
|
||||||
|
|
||||||
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
There is no in-repo hub client: the helper that once wrapped `HubConnectionBuilder`
|
||||||
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
for a circuit was deleted when the pages moved to the in-process seams. An external
|
||||||
every (re)connect, so the short 5-minute lifetime is transparent to whoever uses
|
client re-fetches `/hubs/token` on every (re)connect itself, which is what makes the
|
||||||
it. It remains registered, but no in-repo page opens a hub connection any more;
|
short 5-minute lifetime transparent.
|
||||||
external clients implement the equivalent refresh themselves.
|
|
||||||
|
|
||||||
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
||||||
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
||||||
|
|||||||
+11
-7
@@ -117,13 +117,17 @@ project without binding to a metrics exporter.
|
|||||||
effective configuration into immutable DTOs for read-only dashboard rendering.
|
effective configuration into immutable DTOs for read-only dashboard rendering.
|
||||||
The Blazor Server dashboard mounts at the host root and renders those snapshots
|
The Blazor Server dashboard mounts at the host root and renders those snapshots
|
||||||
at `/`, `/sessions`, `/workers`, `/events`, `/galaxy`, `/alarms`, `/apikeys`,
|
at `/`, `/sessions`, `/workers`, `/events`, `/galaxy`, `/alarms`, `/apikeys`,
|
||||||
and `/settings`. Pages connect to `/hubs/snapshot` (a SignalR hub published by
|
and `/settings`. Pages run inside this process, so they consume the producing
|
||||||
`DashboardSnapshotPublisher`) and refresh on every push instead of polling.
|
services directly through in-process seams — `IDashboardSnapshotFeed`,
|
||||||
`/hubs/alarms` broadcasts `AlarmFeedMessage` values from the central alarm
|
`IDashboardSessionEventSubscriber`, and `IGatewayAlarmService` — and re-render on
|
||||||
monitor; `/hubs/events` mirrors per-session `MxEvent` traffic from
|
every update instead of polling. The three SignalR hubs are the **remote**
|
||||||
`EventStreamService` to clients subscribed to `session:{id}`. The dashboard
|
surface, for clients outside the gateway process: `/hubs/snapshot` pushes
|
||||||
uses local Bootstrap CSS and JavaScript plus a small local stylesheet; it does
|
`DashboardSnapshot` from `DashboardSnapshotPublisher`, `/hubs/alarms` broadcasts
|
||||||
not use a Blazor UI component library.
|
`AlarmFeedMessage` values from the central alarm monitor, and `/hubs/events`
|
||||||
|
mirrors per-session `MxEvent` traffic to clients subscribed to `session:{id}`.
|
||||||
|
No in-repo page opens a hub connection. The dashboard uses local Bootstrap CSS
|
||||||
|
and JavaScript plus a small local stylesheet; it does not use a Blazor UI
|
||||||
|
component library.
|
||||||
|
|
||||||
`/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed tag
|
`/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed tag
|
||||||
values live through `IDashboardLiveDataService`, which owns one shared,
|
values live through `IDashboardLiveDataService`, which owns one shared,
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan WatchDrainTimeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan WatchDrainTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delay between a feed subscription ending and the resubscribe that replaces it.
|
||||||
|
/// Long enough that a feed failing on every attempt cannot spin, short enough that
|
||||||
|
/// the page is stale for about a snapshot interval rather than until navigation.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan ResubscribeDelay = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private readonly CancellationTokenSource _watchCancellation = new();
|
private readonly CancellationTokenSource _watchCancellation = new();
|
||||||
private Task? _watchTask;
|
private Task? _watchTask;
|
||||||
|
|
||||||
@@ -88,7 +95,26 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
|||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders every snapshot the feed yields, resubscribing whenever the subscription ends
|
||||||
|
/// for any reason other than this page going away.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The feed detaches a subscriber when its pump's source faults or completes, so a single
|
||||||
|
/// enumeration is not a lifetime: without the outer loop the first fault froze the page on
|
||||||
|
/// its last snapshot until the operator navigated. Resubscribing is also what restarts the
|
||||||
|
/// feed — only a subscriber that finds no live generation starts a pump — so the page is
|
||||||
|
/// the recovery path, not merely its beneficiary.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="cancellationToken">Cancelled by <see cref="DisposeAsync"/> when the page goes away.</param>
|
||||||
|
/// <returns>A task that completes when the page is disposed.</returns>
|
||||||
private async Task WatchSnapshotsAsync(CancellationToken cancellationToken)
|
private async Task WatchSnapshotsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// One log line per fault *transition*, not per retry: a feed that is down stays down
|
||||||
|
// for many iterations, and a warning per second per open page is noise, not signal.
|
||||||
|
bool faultLogged = false;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -97,22 +123,43 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
|||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
Snapshot = snapshot;
|
Snapshot = snapshot;
|
||||||
|
faultLogged = false;
|
||||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// The page is going away.
|
// The page is going away.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error) when (!faultLogged)
|
||||||
{
|
{
|
||||||
// The feed is best-effort: the last rendered snapshot stays on screen and the
|
// The last rendered snapshot stays on screen while the retry runs, and the
|
||||||
// snapshot service keeps serving GetSnapshot() for the next page load. Logged
|
// snapshot service keeps serving GetSnapshot() for the next page load.
|
||||||
// once here, on the way out of the loop — never per snapshot.
|
faultLogged = true;
|
||||||
Logger?.LogWarning(
|
Logger?.LogWarning(
|
||||||
error,
|
error,
|
||||||
"Live snapshot updates ended for dashboard page {Page}; it keeps the last rendered snapshot.",
|
"Live snapshot updates failed for dashboard page {Page}; retrying every {Delay}. "
|
||||||
GetType().Name);
|
+ "It keeps the last rendered snapshot until they resume.",
|
||||||
|
GetType().Name,
|
||||||
|
ResubscribeDelay);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Same fault, already reported above; the retry below is unconditional.
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enumeration's own disposal (run by the await foreach on every exit path)
|
||||||
|
// is what releases the dead subscription, so the delay below is only paced —
|
||||||
|
// there is nothing left of the old subscription to unwind here.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(ResubscribeDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,13 @@
|
|||||||
private int _maxSeverity = 1000;
|
private int _maxSeverity = 1000;
|
||||||
private string _search = string.Empty;
|
private string _search = string.Empty;
|
||||||
|
|
||||||
|
// Upper bound on waiting for either background loop while disposing, mirroring
|
||||||
|
// DashboardPageBase's snapshot-watch drain: both loops marshal renders through the
|
||||||
|
// renderer's dispatcher and disposal can run on that same dispatcher, so the wait is
|
||||||
|
// bounded rather than unconditional — an unconditional one hangs teardown for good on
|
||||||
|
// a wedged dispatcher.
|
||||||
|
private static readonly TimeSpan LoopDrainTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private Task? _pollTask;
|
private Task? _pollTask;
|
||||||
|
|
||||||
@@ -336,29 +343,32 @@
|
|||||||
{
|
{
|
||||||
await _cts.CancelAsync();
|
await _cts.CancelAsync();
|
||||||
|
|
||||||
if (_pollTask is not null)
|
await DrainAsync(_pollTask);
|
||||||
{
|
await DrainAsync(_providerStatusTask);
|
||||||
try
|
|
||||||
{
|
|
||||||
await _pollTask;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_providerStatusTask is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _providerStatusTask;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The accepted cost of the bound is an abandoned loop that keeps its alarm-service
|
||||||
|
// subscription (and its poll timer) until it does unwind; the alternative — waiting
|
||||||
|
// forever on a dispatcher that is not draining — wedges the circuit teardown itself.
|
||||||
|
private static async Task DrainAsync(Task? loop)
|
||||||
|
{
|
||||||
|
if (loop is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await loop.WaitAsync(LoopDrainTimeout);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
@page "/sessions/{SessionId}"
|
@page "/sessions/{SessionId}"
|
||||||
@inherits DashboardPageBase
|
@inherits DashboardPageBase
|
||||||
|
@* Load-bearing: DisposeAsync below hides the base method with `new`, so Blazor only calls
|
||||||
|
it because this directive re-declares IAsyncDisposable on the derived component. Drop
|
||||||
|
this line and the base's DisposeAsync runs instead — the event subscription and pump
|
||||||
|
leak, silently. *@
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
||||||
@@ -358,6 +362,43 @@ else
|
|||||||
// Either the renderer went away mid-dispatch, or the drain below timed out
|
// Either the renderer went away mid-dispatch, or the drain below timed out
|
||||||
// and disposed the cancellation source this loop is still reading.
|
// and disposed the cancellation source this loop is still reading.
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await MarkDisconnectedAsync(subscription).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This pump is the only thing feeding the "live" pill, so the pill goes dark the moment
|
||||||
|
// the pump stops — the subscription's channel completing under a page that is still
|
||||||
|
// watching (the broadcaster dropped it, the session ended) is exactly what the pill
|
||||||
|
// exists to show, and without this it read "live" until navigation.
|
||||||
|
// A pump whose subscription has already been replaced must not touch it: the newer
|
||||||
|
// subscription's pump owns the pill now. Same dispatcher-owned identity check the
|
||||||
|
// render batch uses — and detach nulls _eventSubscription before cancelling, so a
|
||||||
|
// detach-driven exit correctly falls through here without repainting.
|
||||||
|
private async Task MarkDisconnectedAsync(IDashboardEventSubscription subscription)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_eventSubscription, subscription))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_eventsConnected = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// The renderer went away; there is no pill left to repaint.
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The circuit is tearing down; same.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DetachEventsAsync()
|
private async Task DetachEventsAsync()
|
||||||
@@ -401,6 +442,11 @@ else
|
|||||||
: string.Empty;
|
: string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `new` hides DashboardPageBase.DisposeAsync rather than overriding it (the base method
|
||||||
|
// is not virtual), so this runs only via the IAsyncDisposable interface slot the
|
||||||
|
// `@implements IAsyncDisposable` directive at the top of this file re-declares on the
|
||||||
|
// derived type. Remove either half and disposal silently resolves to the base method:
|
||||||
|
// the snapshot watch is cancelled, the event subscription and pump are not.
|
||||||
public new async ValueTask DisposeAsync()
|
public new async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await DetachEventsAsync();
|
await DetachEventsAsync();
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||||
|
// Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus
|
||||||
|
// the /hubs/token endpoint: server-rendered pages read the in-process feeds, so
|
||||||
|
// nothing in this process builds a hub connection or needs a token for one.
|
||||||
services.AddSingleton<HubTokenService>();
|
services.AddSingleton<HubTokenService>();
|
||||||
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
|
|
||||||
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
|
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
|
||||||
// Singleton: EventsHub instances are transient (one per hub invocation), so the
|
// Singleton: EventsHub instances are transient (one per hub invocation), so the
|
||||||
// subscriber bookkeeping they share with the broadcaster must outlive them.
|
// subscriber bookkeeping they share with the broadcaster must outlive them.
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This service is registered as a singleton in
|
/// This service is registered as a singleton in
|
||||||
/// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and
|
/// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and
|
||||||
/// is shared by two consumer scopes: <c>DashboardHubConnectionFactory</c>
|
/// is shared by two consumer scopes: the <c>/hubs/token</c> endpoint (calls
|
||||||
/// (scoped, per-circuit; calls <see cref="Issue"/> from the cookie-authenticated
|
/// <see cref="Issue"/> for a cookie-authenticated caller) and
|
||||||
/// dashboard) and <c>HubTokenAuthenticationHandler</c> (transient, per-request;
|
/// <c>HubTokenAuthenticationHandler</c> (transient, per-request; calls
|
||||||
/// calls <see cref="Validate"/> from the SignalR negotiate / connection path).
|
/// <see cref="Validate"/> from the SignalR negotiate / connection path). Both
|
||||||
|
/// serve external/remote hub consumers — server-rendered dashboard pages read the
|
||||||
|
/// in-process feeds and never mint a hub token.
|
||||||
/// The underlying <see cref="ITimeLimitedDataProtector"/> is thread-safe, so
|
/// The underlying <see cref="ITimeLimitedDataProtector"/> is thread-safe, so
|
||||||
/// minting and validating concurrently from any number of callers is safe;
|
/// minting and validating concurrently from any number of callers is safe;
|
||||||
/// future maintainers should preserve the singleton lifetime to keep the
|
/// future maintainers should preserve the singleton lifetime to keep the
|
||||||
@@ -31,9 +33,10 @@ public sealed class HubTokenService
|
|||||||
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||||
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
||||||
// bounds how long a stale role set survives a role change. Five minutes is transparent to
|
// bounds how long a stale role set survives a role change. Five minutes is transparent to
|
||||||
// clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect;
|
// clients that re-fetch from /hubs/token on every (re)connect, which is what a remote hub
|
||||||
// see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately
|
// consumer is expected to do; see docs/GatewayDashboardDesign.md. Heavier jti-denylist
|
||||||
// deferred until per-session hub ACLs land, when tokens gain session binding.
|
// revocation is deliberately deferred until per-session hub ACLs land, when tokens gain
|
||||||
|
// session binding.
|
||||||
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
|
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
private readonly ITimeLimitedDataProtector _protector;
|
private readonly ITimeLimitedDataProtector _protector;
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client-side helper that builds a <see cref="HubConnection"/> targeted at a
|
|
||||||
/// dashboard hub. Mints a fresh data-protected bearer token via
|
|
||||||
/// <see cref="HubTokenService"/> on every (re)connect so the connection
|
|
||||||
/// authenticates against <see cref="DashboardAuthenticationDefaults.HubAuthenticationScheme"/>
|
|
||||||
/// without needing to forward the browser's HttpOnly cookie.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DashboardHubConnectionFactory(
|
|
||||||
NavigationManager navigation,
|
|
||||||
HubTokenService tokens,
|
|
||||||
AuthenticationStateProvider authState)
|
|
||||||
{
|
|
||||||
/// <summary>Creates a new hub connection to the specified hub path.</summary>
|
|
||||||
/// <param name="hubPath">The relative hub path (e.g., "/hubs/snapshot").</param>
|
|
||||||
/// <returns>A configured hub connection with automatic reconnection and token authentication.</returns>
|
|
||||||
public HubConnection Create(string hubPath)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
|
|
||||||
|
|
||||||
Uri hubUrl = navigation.ToAbsoluteUri(hubPath);
|
|
||||||
return new HubConnectionBuilder()
|
|
||||||
.WithUrl(hubUrl, options =>
|
|
||||||
{
|
|
||||||
options.AccessTokenProvider = async () =>
|
|
||||||
{
|
|
||||||
AuthenticationState state = await authState.GetAuthenticationStateAsync().ConfigureAwait(false);
|
|
||||||
return tokens.Issue(state.User);
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.WithAutomaticReconnect()
|
|
||||||
.Build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the vulnerable
|
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the vulnerable
|
||||||
native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not clear it. -->
|
native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not clear it. -->
|
||||||
|
|||||||
@@ -27,23 +27,20 @@ public sealed class DashboardHubsRegistrationTests
|
|||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
|
/// <summary>
|
||||||
|
/// Verifies that dashboard build registers the hub token service. It is a singleton
|
||||||
|
/// shared by the <c>/hubs/token</c> endpoint and <c>HubTokenAuthenticationHandler</c>;
|
||||||
|
/// there is deliberately no client-side hub-connection factory to resolve, because
|
||||||
|
/// server-rendered pages read the in-process feeds instead of dialling their own hubs.
|
||||||
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
|
public async Task Build_WhenDashboardEnabled_RegistersHubTokenService()
|
||||||
{
|
{
|
||||||
await using WebApplication app = GatewayApplication.Build([]);
|
await using WebApplication app = GatewayApplication.Build([]);
|
||||||
|
|
||||||
// HubTokenService is singleton; DashboardHubConnectionFactory is scoped
|
|
||||||
// (it captures NavigationManager and AuthenticationStateProvider which
|
|
||||||
// are themselves per-circuit).
|
|
||||||
HubTokenService tokens = app.Services.GetRequiredService<HubTokenService>();
|
HubTokenService tokens = app.Services.GetRequiredService<HubTokenService>();
|
||||||
Assert.NotNull(tokens);
|
Assert.NotNull(tokens);
|
||||||
|
|
||||||
using IServiceScope scope = app.Services.CreateScope();
|
|
||||||
DashboardHubConnectionFactory factory = scope.ServiceProvider
|
|
||||||
.GetRequiredService<DashboardHubConnectionFactory>();
|
|
||||||
Assert.NotNull(factory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user