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; } }