Files
wwtools/mbproxy/src/Mbproxy/Admin/StatusHub.cs
T
Joseph Doherty e719dd51c1 mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column
wall and no way to drill into one connection. This adds a Bootstrap fleet
dashboard (filterable/sortable KPI table) and a per-PLC detail page with a
real-time debug view of raw PLC-side BCD vs. decoded client-side values,
streamed live over a SignalR feed. The debug view is fed by an on-demand
per-tag value capture, armed only while a detail page is open. All assets
(Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged
on firewalled networks; GET /status.json is untouched for scrapers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:02 -04:00

68 lines
2.7 KiB
C#

using Mbproxy.Proxy;
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Admin;
/// <summary>
/// SignalR hub backing the live admin dashboard. Two subscription scopes:
/// <list type="bullet">
/// <item><see cref="SubscribeFleet"/> — the fleet dashboard (<c>GET /</c>) joins the
/// <see cref="FleetGroup"/> group and receives a <c>"fleet"</c> message every
/// push tick.</item>
/// <item><see cref="SubscribePlc"/> — a connection-detail page (<c>GET /plc/{name}</c>)
/// joins <see cref="PlcGroup"/> and receives a <c>"plc"</c> message. The first
/// subscriber to a PLC arms that PLC's tag-value capture; the last to leave
/// disarms it (on-demand capture).</item>
/// </list>
///
/// <para>The hub itself is transient (one instance per call). Cross-call state — the
/// subscriber counts that drive capture arming — lives in the singleton
/// <see cref="PlcSubscriptionTracker"/>. The actual pushes are issued by
/// <see cref="StatusBroadcaster"/>, not the hub.</para>
/// </summary>
internal sealed class StatusHub : Hub
{
/// <summary>SignalR group name for fleet-dashboard subscribers.</summary>
public const string FleetGroup = "fleet";
/// <summary>SignalR group name for a single PLC's detail-page subscribers.</summary>
public static string PlcGroup(string plcName) => "plc:" + plcName;
private readonly PlcSubscriptionTracker _tracker;
private readonly TagCaptureRegistry _captureRegistry;
public StatusHub(PlcSubscriptionTracker tracker, TagCaptureRegistry captureRegistry)
{
_tracker = tracker;
_captureRegistry = captureRegistry;
}
/// <summary>Subscribes the calling connection to fleet-wide status pushes.</summary>
public Task SubscribeFleet()
=> Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
/// <summary>
/// Subscribes the calling connection to one PLC's detail pushes and arms that PLC's
/// tag-value capture if this is its first viewer.
/// </summary>
public async Task SubscribePlc(string plcName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, PlcGroup(plcName)).ConfigureAwait(false);
if (_tracker.Add(Context.ConnectionId, plcName))
_captureRegistry.Arm(plcName); // no-op for an unknown PLC name
}
/// <summary>
/// On disconnect, drops every subscription the connection held and disarms the
/// capture of any PLC whose last viewer just left.
/// </summary>
public override Task OnDisconnectedAsync(Exception? exception)
{
foreach (var plcName in _tracker.RemoveConnection(Context.ConnectionId))
_captureRegistry.Disarm(plcName);
return base.OnDisconnectedAsync(exception);
}
}