namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; /// /// A singleton, in-process fan-out for live event streams (alarm transitions, script-log /// lines). A per-node SignalR bridge actor subscribes to the cluster's DistributedPubSub topic /// and calls ; Blazor Server components subscribe to /// to render the live tail. /// /// This exists because the AdminUI runs as Blazor Server: a component opening a /// SignalR HubConnection to its own hub would dial the browser's public URL from /// server-side code, which is unreachable behind a reverse proxy (e.g. Traefik mapping host /// :9200 → container :9000) and so fails with "Connection refused". Reading this in-process /// broadcaster instead avoids the network hop entirely. Mirrors the /// IDriverStatusSnapshotStore.SnapshotChanged pattern for stream (vs. last-value) feeds. /// /// /// The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry). public interface IInProcessBroadcaster { /// /// Raised once per with the published item. Handlers run on the /// caller's thread (the bridge actor), so subscribers must marshal to their own sync /// context (Blazor's InvokeAsync). /// event Action? Received; /// Fan the item out to all current subscribers. /// The item to publish to subscribers. void Publish(T item); /// /// Whether the upstream feed (the per-node SignalR bridge's DPS subscription) is currently /// live. Drives the "live" pill on the Blazor pages. False until the bridge's first /// SubscribeAck; flips false again when the bridge stops. /// bool IsConnected { get; } /// /// Raised whenever changes (and only on change), with the new value. /// Handlers run on the caller's thread (the bridge actor), so Blazor subscribers must marshal /// via InvokeAsync. /// event Action? ConnectionStateChanged; /// /// Set by the bridge actor from its DPS-subscription health: true on SubscribeAck /// (subscription live), false on PostStop/failure. Raises /// only when the value actually changes. /// /// The new connection state. void SetConnected(bool connected); } /// Thread-safe singleton implementation of . /// The event payload type. public sealed class InProcessBroadcaster : IInProcessBroadcaster { // Guards _isConnected: the bridge actor sets it on the actor thread; Blazor reads it on the // render thread, so access must be serialised. private readonly object _connectionLock = new(); private bool _isConnected; /// public event Action? Received; /// public event Action? ConnectionStateChanged; /// // Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise. public void Publish(T item) => Received?.Invoke(item); /// public bool IsConnected { get { lock (_connectionLock) { return _isConnected; } } } /// public void SetConnected(bool connected) { Action? handler; lock (_connectionLock) { if (_isConnected == connected) { return; } _isConnected = connected; // Capture inside the lock, invoke outside (mirrors Publish) so a concurrent // unsubscribe can't null the delegate mid-raise and we never hold the lock during a callback. handler = ConnectionStateChanged; } handler?.Invoke(connected); } }