Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs
T
Joseph Doherty 9cad9ed0fc
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
2026-07-07 12:38:39 -04:00

105 lines
4.2 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// 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 <see cref="Publish"/>; Blazor Server components subscribe to <see cref="Received"/>
/// to render the live tail.
/// <para>
/// This exists because the AdminUI runs as Blazor <em>Server</em>: a component opening a
/// SignalR <c>HubConnection</c> 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
/// <c>IDriverStatusSnapshotStore.SnapshotChanged</c> pattern for stream (vs. last-value) feeds.
/// </para>
/// </summary>
/// <typeparam name="T">The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry).</typeparam>
public interface IInProcessBroadcaster<T>
{
/// <summary>
/// Raised once per <see cref="Publish"/> 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 <c>InvokeAsync</c>).
/// </summary>
event Action<T>? Received;
/// <summary>Fan the item out to all current <see cref="Received"/> subscribers.</summary>
/// <param name="item">The item to publish to subscribers.</param>
void Publish(T item);
/// <summary>
/// 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
/// <c>SubscribeAck</c>; flips false again when the bridge stops.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Raised whenever <see cref="IsConnected"/> 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 <c>InvokeAsync</c>.
/// </summary>
event Action<bool>? ConnectionStateChanged;
/// <summary>
/// Set by the bridge actor from its DPS-subscription health: <c>true</c> on <c>SubscribeAck</c>
/// (subscription live), <c>false</c> on <c>PostStop</c>/failure. Raises
/// <see cref="ConnectionStateChanged"/> only when the value actually changes.
/// </summary>
/// <param name="connected">The new connection state.</param>
void SetConnected(bool connected);
}
/// <summary>Thread-safe singleton implementation of <see cref="IInProcessBroadcaster{T}"/>.</summary>
/// <typeparam name="T">The event payload type.</typeparam>
public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
{
// 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;
/// <inheritdoc />
public event Action<T>? Received;
/// <inheritdoc />
public event Action<bool>? ConnectionStateChanged;
/// <inheritdoc />
// Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise.
public void Publish(T item) => Received?.Invoke(item);
/// <inheritdoc />
public bool IsConnected
{
get
{
lock (_connectionLock)
{
return _isConnected;
}
}
}
/// <inheritdoc />
public void SetConnected(bool connected)
{
Action<bool>? 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);
}
}