using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
///
/// SignalR hub for per-session MxEvent push. Clients call
/// to join the group for a specific
/// session; sends messages to
/// session:{id} as events arrive from the live gRPC stream.
///
///
/// Group membership is mirrored into
/// because SignalR does not expose it, and the broadcaster consults the
/// registry to skip all mirror work for sessions nobody is watching.
///
/// Registry tracking which sessions have live subscribers.
/// Per-session visibility gate consulted before any group join.
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub(
EventsHubViewerRegistry viewerRegistry,
IDashboardSessionAcl sessionAcl) : Hub
{
/// Method name used to push individual MxEvent values to clients.
public const string EventMessage = "MxEvent";
/// Computes the SignalR group name for a given session id.
/// The session id.
/// The group name for the session.
public static string GroupName(string sessionId) => $"session:{sessionId}";
///
/// Subscribes the calling SignalR connection to the per-session events
/// group, so that events broadcast by
/// for that session reach this
/// client.
///
///
/// The hub-level (HubClientsPolicy)
/// only checks that the caller carries one of the dashboard roles, which by
/// itself would let any Viewer subscribe to any session id they name. The
/// per-session decision is 's.
/// The admin bypass is evaluated first, so an
/// Administrator joins any session id they name; every check below it
/// applies to non-Admin callers only. For those: a Viewer sees a session
/// only when its tags intersect their granted tags, an untagged session
/// follows Dashboard:UntaggedSessionVisibility, and a session id the
/// registry does not have is denied outright — the phantom-id denial is
/// therefore a non-Admin rule, not a universal one.
/// A denied caller is not joined to the group and is
/// not registered with , so the mirror
/// stays off for a session nobody is legitimately watching. The same ACL
/// gates the in-process seam used by the session-details page, so neither
/// path is the weaker one.
///
/// Session id to subscribe the caller to.
/// A task representing the subscription operation.
/// The caller may not observe this session.
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return Task.CompletedTask;
}
if (!sessionAcl.CanViewSession(Context.User, sessionId))
{
// Surfaced rather than swallowed so a client can tell "denied" from "no events yet".
// The message names neither the session's tags nor the caller's grant.
throw new HubException("Not authorized for this session.");
}
// Register before joining the group: the reverse order would leave a window
// in which this connection is a group member but the broadcaster's gate still
// reports the session unwatched, silently dropping events it should receive.
viewerRegistry.AddViewer(Context.ConnectionId, sessionId);
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
}
/// Unsubscribes the calling SignalR connection from the per-session events group.
/// Session id to unsubscribe the caller from.
/// A task representing the unsubscription operation.
public async Task UnsubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
// Leave the group first, deregister after — the mirror stays enabled for the
// brief overlap rather than dropping events still owed to other subscribers.
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId)).ConfigureAwait(false);
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
}
///
public override Task OnDisconnectedAsync(Exception? exception)
{
// Release every session subscription the dropped connection held. A browser tab that
// closes never calls UnsubscribeSession, so without this the session would look watched
// forever and the mirror would keep cloning and sending events to an empty group.
viewerRegistry.ReleaseConnection(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}