diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs index 906db2a3..572c8bf1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs @@ -38,13 +38,22 @@ public sealed class AlertSignalRBridge : ReceiveActor _hub = hub; _broadcaster = broadcaster; ReceiveAsync(ForwardAsync); - Receive(_ => { /* DPS confirmation */ }); + // DPS subscription is now live — mark the feed connected so the Blazor "live" pill lights up. + Receive(_ => _broadcaster.SetConnected(true)); } /// protected override void PreStart() => DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self)); + /// + protected override void PostStop() + { + // Bridge stopping — the feed is no longer live, drop the "live" pill. + _broadcaster.SetConnected(false); + base.PostStop(); + } + private async Task ForwardAsync(AlarmTransitionEvent msg) { // In-process fan-out first — this is what the Blazor Server Alerts page reads. The hub push diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs index 46e0d9e8..ba0533ce 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs @@ -26,16 +26,78 @@ public interface IInProcessBroadcaster /// Fan the item out to all current 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); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs index 8fac9fb6..69ae2790 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs @@ -32,13 +32,22 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor _hub = hub; _broadcaster = broadcaster; ReceiveAsync(ForwardAsync); - Receive(_ => { /* DPS confirmation */ }); + // DPS subscription is now live — mark the feed connected so the Blazor "live" pill lights up. + Receive(_ => _broadcaster.SetConnected(true)); } /// protected override void PreStart() => DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self)); + /// + protected override void PostStop() + { + // Bridge stopping — the feed is no longer live, drop the "live" pill. + _broadcaster.SetConnected(false); + base.PostStop(); + } + private async Task ForwardAsync(ScriptLogEntry msg) { // In-process fan-out first — this is what the Blazor Server Script log page reads. The hub diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs index 263a31da..78e530c7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/InProcessBroadcasterTests.cs @@ -48,4 +48,52 @@ public sealed class InProcessBroadcasterTests var broadcaster = new InProcessBroadcaster(); Should.NotThrow(() => broadcaster.Publish(42)); } + + [Fact] + public void New_broadcaster_is_not_connected() + { + var broadcaster = new InProcessBroadcaster(); + + broadcaster.IsConnected.ShouldBeFalse(); + } + + [Fact] + public void SetConnected_true_flips_state_and_raises_once() + { + var broadcaster = new InProcessBroadcaster(); + var raised = new List(); + broadcaster.ConnectionStateChanged += raised.Add; + + broadcaster.SetConnected(true); + + broadcaster.IsConnected.ShouldBeTrue(); + raised.ShouldBe([true]); + } + + [Fact] + public void SetConnected_same_value_does_not_raise() + { + var broadcaster = new InProcessBroadcaster(); + var raised = new List(); + broadcaster.ConnectionStateChanged += raised.Add; + + broadcaster.SetConnected(true); + broadcaster.SetConnected(true); + + raised.ShouldBe([true]); + } + + [Fact] + public void SetConnected_false_after_true_raises_false() + { + var broadcaster = new InProcessBroadcaster(); + var raised = new List(); + broadcaster.ConnectionStateChanged += raised.Add; + + broadcaster.SetConnected(true); + broadcaster.SetConnected(false); + + broadcaster.IsConnected.ShouldBeFalse(); + raised[^1].ShouldBeFalse(); + } }