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