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>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Background loop that drives the admin dashboard's live feed. Every
|
||||
/// <see cref="MbproxyOptions.AdminPushIntervalMs"/> it builds a status snapshot and
|
||||
/// pushes it through an <see cref="IStatusPushSink"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item>the fleet snapshot to every fleet-dashboard subscriber;</item>
|
||||
/// <item>a per-PLC detail payload (status row + tag-value capture) to each PLC that
|
||||
/// currently has a detail-page subscriber — PLCs with no viewer are skipped.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Owned by <see cref="AdminEndpointHost"/>: <see cref="Start"/> is called once
|
||||
/// the Kestrel app is up, <see cref="StopAsync"/> before it stops. <see cref="StopAsync"/>
|
||||
/// disarms every tag-value capture, so an AdminPort hot-reload — which tears down the
|
||||
/// SignalR host and all connections without firing per-connection disconnect cleanup
|
||||
/// deterministically — never leaves a capture armed with no viewer.</para>
|
||||
/// </summary>
|
||||
internal sealed class StatusBroadcaster : IAsyncDisposable
|
||||
{
|
||||
private readonly IStatusPushSink _sink;
|
||||
private readonly StatusSnapshotBuilder _builder;
|
||||
private readonly PlcSubscriptionTracker _tracker;
|
||||
private readonly TagCaptureRegistry _captureRegistry;
|
||||
private readonly IOptionsMonitor<MbproxyOptions> _options;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _loop = Task.CompletedTask;
|
||||
|
||||
public StatusBroadcaster(
|
||||
IStatusPushSink sink,
|
||||
StatusSnapshotBuilder builder,
|
||||
PlcSubscriptionTracker tracker,
|
||||
TagCaptureRegistry captureRegistry,
|
||||
IOptionsMonitor<MbproxyOptions> options,
|
||||
ILogger logger)
|
||||
{
|
||||
_sink = sink;
|
||||
_builder = builder;
|
||||
_tracker = tracker;
|
||||
_captureRegistry = captureRegistry;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Starts the push loop. Idempotent only in the sense that it is called once.</summary>
|
||||
public void Start() => _loop = Task.Run(() => LoopAsync(_cts.Token));
|
||||
|
||||
/// <summary>
|
||||
/// Stops the push loop and disarms every tag-value capture.
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (!_cts.IsCancellationRequested)
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await _loop.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on cancellation.
|
||||
}
|
||||
|
||||
_captureRegistry.DisarmAll();
|
||||
}
|
||||
|
||||
/// <summary>One push cycle. Exposed internally so unit tests can drive it deterministically.</summary>
|
||||
internal async Task PushOnceAsync(CancellationToken ct)
|
||||
{
|
||||
StatusResponse snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = _builder.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "StatusBroadcaster: failed to build status snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _sink.PushFleetAsync(snapshot, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "StatusBroadcaster: fleet push failed");
|
||||
}
|
||||
|
||||
foreach (var plcName in _tracker.ActivePlcs())
|
||||
{
|
||||
try
|
||||
{
|
||||
var plc = snapshot.Plcs.FirstOrDefault(p => p.Name == plcName);
|
||||
var debug = _builder.BuildDebug(plcName);
|
||||
var detail = new PlcDetailResponse(plc, debug);
|
||||
await _sink.PushPlcAsync(plcName, detail, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "StatusBroadcaster: detail push failed for PLC {Plc}", plcName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Re-read the interval each cycle so an AdminPushIntervalMs hot-reload
|
||||
// takes effect without restarting the loop. Floored at 100 ms to avoid a
|
||||
// pathologically tight loop if a bad value slips past validation.
|
||||
int interval = Math.Max(100, _options.CurrentValue.AdminPushIntervalMs);
|
||||
await Task.Delay(interval, ct).ConfigureAwait(false);
|
||||
await PushOnceAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "StatusBroadcaster loop terminated unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user