feat(dashboard): in-process session event subscription feeds the viewer registry — SessionDetailsPage drops its /hubs/events hop
This commit is contained in:
+77
-49
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user