e719dd51c1
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>
68 lines
2.7 KiB
C#
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);
|
|
}
|
|
}
|