feat(dashboard): in-process session event subscription feeds the viewer registry — SessionDetailsPage drops its /hubs/events hop

This commit is contained in:
Joseph Doherty
2026-08-15 20:15:52 -04:00
parent d44fe1d6b5
commit e23f816bfb
5 changed files with 501 additions and 58 deletions
@@ -1,11 +1,11 @@
@page "/sessions/{SessionId}"
@inherits DashboardPageBase
@implements IAsyncDisposable
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.MxGateway.Contracts.Proto
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService
@inject IDashboardEventBroadcaster EventBroadcaster
<PageTitle>Dashboard Session</PageTitle>
@@ -157,7 +157,8 @@ else
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
private HubConnection? _eventsHub;
private IDashboardEventSubscription? _eventSubscription;
private CancellationTokenSource? _eventPumpCancellation;
private bool _eventsConnected;
private string? _subscribedSessionId;
private readonly LinkedList<MxEvent> _recentEvents = new();
@@ -179,13 +180,17 @@ else
CanManage = SessionAdminService.CanManage(authenticationState.User);
}
protected override async Task OnParametersSetAsync()
protected override Task OnParametersSetAsync()
{
// Attach/detach are synchronous now that the feed is in-process; the override
// stays on the async lifecycle member so the base class's contract is untouched.
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
{
await DetachEventsHubAsync().ConfigureAwait(false);
await AttachEventsHubAsync().ConfigureAwait(false);
DetachEvents();
AttachEvents();
}
return Task.CompletedTask;
}
private PendingConfirm? PendingAction { get; set; }
@@ -261,68 +266,91 @@ else
string ConfirmButtonClass,
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> Action);
private async Task AttachEventsHubAsync()
// The dashboard runs in the same process as the broadcaster, so this page reads
// the session's mirrored events straight from it. It used to open a loopback
// SignalR connection to /hubs/events — mint a hub token, negotiate, hold a
// WebSocket, serialize every event — to reach data already sitting in memory.
// The subscription still registers with EventsHubViewerRegistry, so the
// broadcaster's "nobody is watching" gate keeps working for both audiences.
// ACL posture is unchanged from the hub path: any dashboard Viewer may watch
// any session (SEC-25 tracks the per-session ACL for both seams).
private void AttachEvents()
{
if (string.IsNullOrWhiteSpace(SessionId))
if (string.IsNullOrWhiteSpace(SessionId) || EventBroadcaster is not IDashboardSessionEventSubscriber subscriber)
{
return;
}
_eventsHub = HubFactory.Create("/hubs/events");
_eventsHub.On<MxEvent>(EventsHub.EventMessage, async mxEvent =>
{
_recentEvents.AddFirst(mxEvent);
while (_recentEvents.Count > MaxRecentEvents)
{
_recentEvents.RemoveLast();
}
_eventSubscription = subscriber.Subscribe(SessionId);
_eventPumpCancellation = new CancellationTokenSource();
_eventsConnected = true;
_subscribedSessionId = SessionId;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
});
_eventsHub.Closed += _ =>
{
_eventsConnected = false;
return InvokeAsync(StateHasChanged);
};
_eventsHub.Reconnected += _ =>
{
_eventsConnected = true;
return InvokeAsync(StateHasChanged);
};
_ = PumpEventsAsync(_eventSubscription, _eventPumpCancellation.Token);
}
private async Task PumpEventsAsync(IDashboardEventSubscription subscription, CancellationToken cancellationToken)
{
try
{
await _eventsHub.StartAsync().ConfigureAwait(false);
await _eventsHub.SendAsync("SubscribeSession", SessionId).ConfigureAwait(false);
_eventsConnected = true;
_subscribedSessionId = SessionId;
while (await subscription.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
// Drain what is queued and render once: a burst costs one render pass,
// not one per event. Reading past the display cap would be wasted work,
// and anything left queued is picked up on the next pass.
List<MxEvent> batch = new();
while (batch.Count < MaxRecentEvents && subscription.Reader.TryRead(out MxEvent? mxEvent))
{
batch.Add(mxEvent);
}
if (batch.Count == 0)
{
continue;
}
await InvokeAsync(() =>
{
foreach (MxEvent mxEvent in batch)
{
_recentEvents.AddFirst(mxEvent);
}
while (_recentEvents.Count > MaxRecentEvents)
{
_recentEvents.RemoveLast();
}
StateHasChanged();
}).ConfigureAwait(false);
}
}
catch
catch (OperationCanceledException)
{
_eventsConnected = false;
// The page navigated to another session or was disposed.
}
catch (ObjectDisposedException)
{
// The renderer went away while a batch was being dispatched.
}
}
private async Task DetachEventsHubAsync()
private void DetachEvents()
{
HubConnection? hub = _eventsHub;
_eventsHub = null;
IDashboardEventSubscription? subscription = _eventSubscription;
CancellationTokenSource? cancellation = _eventPumpCancellation;
_eventSubscription = null;
_eventPumpCancellation = null;
_eventsConnected = false;
_subscribedSessionId = null;
_recentEvents.Clear();
if (hub is not null)
{
try
{
await hub.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Disposal-time errors are best-effort.
}
}
// Cancel first so the pump stops touching the renderer, then dispose the
// subscription — that is what releases the viewer registration and lets the
// broadcaster go back to skipping mirror work for this session.
cancellation?.Cancel();
cancellation?.Dispose();
subscription?.Dispose();
}
private static string EventStatusLabel(MxEvent evt)
@@ -334,7 +362,7 @@ else
public new async ValueTask DisposeAsync()
{
await DetachEventsHubAsync().ConfigureAwait(false);
DetachEvents();
await base.DisposeAsync().ConfigureAwait(false);
}
}