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
+14 -10
View File
@@ -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)).
+51 -11
View File
@@ -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
View File
@@ -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,31 +95,71 @@ 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)
{ {
try // 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)
{ {
await foreach (DashboardSnapshot snapshot in SnapshotFeed try
.WatchAsync(cancellationToken)
.ConfigureAwait(false))
{ {
Snapshot = snapshot; await foreach (DashboardSnapshot snapshot in SnapshotFeed
await InvokeAsync(StateHasChanged).ConfigureAwait(false); .WatchAsync(cancellationToken)
.ConfigureAwait(false))
{
Snapshot = snapshot;
faultLogged = false;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// The page is going away.
return;
}
catch (Exception error) when (!faultLogged)
{
// The last rendered snapshot stays on screen while the retry runs, and the
// snapshot service keeps serving GetSnapshot() for the next page load.
faultLogged = true;
Logger?.LogWarning(
error,
"Live snapshot updates failed for dashboard page {Page}; retrying every {Delay}. "
+ "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;
} }
}
catch (OperationCanceledException)
{
// The page is going away.
}
catch (Exception error)
{
// The feed is best-effort: the last rendered snapshot stays on screen and the
// snapshot service keeps serving GetSnapshot() for the next page load. Logged
// once here, on the way out of the loop — never per snapshot.
Logger?.LogWarning(
error,
"Live snapshot updates ended for dashboard page {Page}; it keeps the last rendered snapshot.",
GetType().Name);
} }
} }
} }
@@ -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>