0f1f8ad8fa
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver deleted in /raw but not yet deployed - still running, still publishing - vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set, olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is older than 60 s. Tests cover unconfigured+fresh kept (positive control), unconfigured+stale evicted, configured+stale kept, and the deploy hand-off. M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds default (Galaxy 30, OpcUaClient 15, ...) when the key is absent. M2: the budget never undercuts the config's root timeoutMs (+1 s). M5: RawBrowseModal compares the open generation captured before the await, so a close-then-reopen mid-open closes the stale session. M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver node may still be starting) instead of stopping for good. M7: /scripts source fetch failure shows in the dialog, not the error boundary. M8: the export modal maps only its preview rows; the endpoint writes via Results.Text (no second byte[] copy). M9: /hosts config queries are ordered, so SequenceEqual change detection holds. N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults, N5 equipment-import help text now describes RFC 4180 quoting. H3: MonacoEditor documents that themes are page-global. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
116 lines
5.1 KiB
C#
116 lines
5.1 KiB
C#
using Microsoft.Extensions.Logging;
|
|
|
|
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;
|
|
private readonly ILogger<InProcessBroadcaster<T>>? _logger;
|
|
private readonly FailureLogThrottle _failureLog = new();
|
|
|
|
/// <summary>Creates the broadcaster.</summary>
|
|
/// <param name="logger">Optional logger for a subscriber that throws during a raise.</param>
|
|
public InProcessBroadcaster(ILogger<InProcessBroadcaster<T>>? logger = null) => _logger = logger;
|
|
|
|
/// <summary>Raised once per <see cref="Publish"/> with the published item, on the caller's thread.</summary>
|
|
public event Action<T>? Received;
|
|
|
|
/// <summary>Raised whenever <see cref="IsConnected"/> changes, with the new value, on the caller's thread.</summary>
|
|
public event Action<bool>? ConnectionStateChanged;
|
|
|
|
/// <inheritdoc />
|
|
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one subscriber
|
|
// at a time so a throwing one cannot starve the rest or escape onto the bridge actor.
|
|
public void Publish(T item) =>
|
|
SafeMulticast.Invoke(Received, item, ex => _logger?.Log(_failureLog.NextLevel(), ex, "In-process broadcast subscriber threw"));
|
|
|
|
/// <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;
|
|
}
|
|
|
|
SafeMulticast.Invoke(handler, connected,
|
|
ex => _logger?.Log(_failureLog.NextLevel(), ex, "In-process connection-state subscriber threw"));
|
|
}
|
|
}
|