feat(dashboard): in-process snapshot feed replaces the pages' loopback /hubs/snapshot hop
This commit is contained in:
@@ -1,80 +1,96 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Blazor dashboard pages that watch gateway metrics
|
||||
/// snapshots. The previous implementation polled
|
||||
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> directly; we
|
||||
/// now subscribe to <see cref="DashboardSnapshotHub"/> so updates are
|
||||
/// pushed and disconnects survive reconnects via SignalR's
|
||||
/// auto-reconnect.
|
||||
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
|
||||
/// Pages subscribe to the in-process <see cref="IDashboardSnapshotFeed"/>, which
|
||||
/// multicasts a single <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/>
|
||||
/// enumeration to every circuit. An earlier implementation had each page open its
|
||||
/// own SignalR connection to <c>/hubs/snapshot</c> — a loopback WebSocket back into
|
||||
/// this same process, per page. The snapshot hub and its publisher remain for
|
||||
/// external (non-circuit) clients; server-rendered pages no longer use them.
|
||||
/// </summary>
|
||||
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
private HubConnection? _hub;
|
||||
/// <summary>
|
||||
/// Upper bound on waiting for the watch loop while disposing. The loop marshals
|
||||
/// renders through the renderer's dispatcher and disposal can run on that same
|
||||
/// dispatcher, so the wait is bounded rather than unconditional.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan WatchDrainTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Snapshot service used to seed the initial render before the hub connects.</summary>
|
||||
private readonly CancellationTokenSource _watchCancellation = new();
|
||||
private Task? _watchTask;
|
||||
|
||||
/// <summary>Snapshot service used to seed the initial render before the first feed update.</summary>
|
||||
[Inject]
|
||||
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
|
||||
|
||||
/// <summary>Factory that builds the SignalR connection (mints the hub bearer token).</summary>
|
||||
/// <summary>Shared in-process snapshot feed this page renders from.</summary>
|
||||
[Inject]
|
||||
protected DashboardHubConnectionFactory HubFactory { get; set; } = null!;
|
||||
protected IDashboardSnapshotFeed SnapshotFeed { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent gateway metric snapshot. Synchronously seeded from
|
||||
/// <see cref="IDashboardSnapshotService.GetSnapshot"/> for the very
|
||||
/// first render, then refreshed by hub push.
|
||||
/// <see cref="IDashboardSnapshotService.GetSnapshot"/> for the very first
|
||||
/// render, then refreshed from the feed.
|
||||
/// </summary>
|
||||
protected DashboardSnapshot? Snapshot { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
Snapshot = SnapshotService.GetSnapshot();
|
||||
await ConnectHubAsync().ConfigureAwait(false);
|
||||
|
||||
// Deliberately not awaited: the watch loop runs for the lifetime of the page
|
||||
// and is cancelled and drained by DisposeAsync.
|
||||
_watchTask = WatchSnapshotsAsync(_watchCancellation.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the SignalR hub connection created for this page, tolerating disposal-time errors.</summary>
|
||||
/// <summary>Cancels the snapshot subscription created for this page, tolerating disposal-time errors.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hub.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal-time errors are best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = HubFactory.Create("/hubs/snapshot");
|
||||
_hub.On<DashboardSnapshot>(DashboardSnapshotHub.SnapshotMessage, async snapshot =>
|
||||
{
|
||||
Snapshot = snapshot;
|
||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync().ConfigureAwait(false);
|
||||
await _watchCancellation.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_watchTask is not null)
|
||||
{
|
||||
await _watchTask.WaitAsync(WatchDrainTimeout).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; the initial GetSnapshot() seed remains
|
||||
// valid and the snapshot service keeps populating its cache for
|
||||
// the next reconnect cycle.
|
||||
// Disposal-time errors (including a drain timeout) are best-effort.
|
||||
}
|
||||
|
||||
_watchCancellation.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task WatchSnapshotsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (DashboardSnapshot snapshot in SnapshotFeed
|
||||
.WatchAsync(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
Snapshot = snapshot;
|
||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The page is going away.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The feed is best-effort: the last rendered snapshot stays on screen and
|
||||
// the snapshot service keeps serving GetSnapshot() for the next page load.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user