using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components; /// /// Base class for Blazor dashboard pages that watch gateway metrics snapshots. /// Pages subscribe to the in-process , which /// multicasts a single /// enumeration to every circuit. An earlier implementation had each page open its /// own SignalR connection to /hubs/snapshot — 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. /// public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable { /// /// 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. /// private static readonly TimeSpan WatchDrainTimeout = TimeSpan.FromSeconds(5); private readonly CancellationTokenSource _watchCancellation = new(); private Task? _watchTask; /// Snapshot service used to seed the initial render before the first feed update. [Inject] protected IDashboardSnapshotService SnapshotService { get; set; } = null!; /// Shared in-process snapshot feed this page renders from. [Inject] protected IDashboardSnapshotFeed SnapshotFeed { get; set; } = null!; /// Logger used to report a snapshot subscription that ended or would not drain. [Inject] protected ILogger? Logger { get; set; } /// /// The most recent gateway metric snapshot. Synchronously seeded from /// for the very first /// render, then refreshed from the feed. /// protected DashboardSnapshot? Snapshot { get; private set; } /// protected override Task OnInitializedAsync() { Snapshot = SnapshotService.GetSnapshot(); // 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; } /// Cancels the snapshot subscription created for this page, tolerating disposal-time errors. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { try { await _watchCancellation.CancelAsync().ConfigureAwait(false); if (_watchTask is not null) { await _watchTask.WaitAsync(WatchDrainTimeout).ConfigureAwait(false); } } catch (TimeoutException) { // Accepted limitation: the abandoned loop still holds its feed subscription, so // the feed's idle gate stays open until it does unwind. There is no way to force // a detach — the loop is parked on a dispatcher that is not draining — so the // warning is the operator's only signal that a circuit teardown wedged. Logger?.LogWarning( "Dashboard page {Page} did not release its snapshot subscription within {Timeout}; " + "the shared snapshot feed stays active until it unwinds.", GetType().Name, WatchDrainTimeout); } catch { // Other disposal-time errors 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 (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); } } }