using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
///
/// Broadcasts MxEvents to clients subscribed to the
/// session's group. Fire-and-forget: we hand the send to the hub context
/// and return immediately so the source gRPC stream is never blocked.
/// Errors are logged once and dropped — keeping the SignalR mirror best-effort
/// preserves the gRPC contract that exists today.
///
///
/// When MxGateway:Dashboard:ShowTagValues is false (the default), tag
/// values are stripped from a redacted copy of the event before it reaches any
/// dashboard client. The source is shared with the gRPC
/// event path and the reconnect replay ring, so it is never mutated in place —
/// the redaction is applied to a deep clone. This closes the value-leak seam at
/// the mirror independently of the still-outstanding per-session hub ACL
/// (see ).
///
public sealed class DashboardEventBroadcaster(
IHubContext hubContext,
IOptions options,
ILogger logger) : IDashboardEventBroadcaster
{
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
///
public void Publish(string sessionId, MxEvent mxEvent)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
{
return;
}
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
// Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw
// from SendAsync (e.g. an implementation that throws before returning the Task)
// cannot escape Publish. The interface contract is never-throw; fire-and-forget.
Task send;
try
{
send = hubContext.Clients
.Group(EventsHub.GroupName(sessionId))
.SendAsync(EventsHub.EventMessage, outbound);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} threw synchronously.", sessionId);
return;
}
if (!send.IsCompletedSuccessfully)
{
_ = send.ContinueWith(
t =>
{
if (t.Exception is { } ex)
{
logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId);
}
},
TaskScheduler.Default);
}
}
///
/// Produces a deep clone of with every tag-value
/// field cleared, leaving tag reference, quality, status, and timestamps
/// intact so the dashboard still renders the event without the value. The
/// source event is left untouched because it is shared downstream with the
/// gRPC stream and the replay ring.
///
/// The source event to redact a copy of.
/// A redacted deep clone of the event.
private static MxEvent RedactValues(MxEvent source)
{
MxEvent redacted = source.Clone();
redacted.Value = null;
if (redacted.BodyCase == MxEvent.BodyOneofCase.OnAlarmTransition)
{
redacted.OnAlarmTransition.CurrentValue = null;
redacted.OnAlarmTransition.LimitValue = null;
}
return redacted;
}
}