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:
Joseph Doherty
2026-05-15 10:40:21 -04:00
parent b330faff03
commit e719dd51c1
49 changed files with 3539 additions and 424 deletions
@@ -168,10 +168,10 @@ public sealed class AdminEndpointTests
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
}
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
[Fact(Timeout = 5_000)]
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
public async Task Get_Root_ReturnsDashboardShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
@@ -190,8 +190,79 @@ public sealed class AdminEndpointTests
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
body.ShouldContain("<!DOCTYPE html>");
body.ShouldContain("<!doctype html>");
body.ShouldContain("/assets/dashboard.js");
}
[Fact(Timeout = 5_000)]
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("/assets/detail.js");
}
[Theory(Timeout = 5_000)]
[InlineData("bootstrap.min.css", "text/css")]
[InlineData("signalr.min.js", "text/javascript")]
[InlineData("dashboard.js", "text/javascript")]
[InlineData("theme.css", "text/css")]
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
bytes.Length.ShouldBeGreaterThan(0);
}
[Fact(Timeout = 5_000)]
public async Task Get_UnknownAsset_Returns404()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
@@ -334,9 +405,9 @@ public sealed class AdminEndpointTests
/// <summary>
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later.
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
/// here.) Guards against an accidental MapPost/Map* being added later.
/// </summary>
[Theory(Timeout = 5_000)]
[InlineData("POST")]
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Mbproxy.Admin;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Minimal hand-written test doubles for the SignalR surface <see cref="StatusHub"/>
/// and <see cref="StatusBroadcaster"/> touch. The project carries no mocking framework,
/// so these record just enough to assert behaviour.
/// </summary>
internal sealed class FakeHubCallerContext : HubCallerContext
{
public FakeHubCallerContext(string connectionId) => ConnectionId = connectionId;
public override string ConnectionId { get; }
public override string? UserIdentifier => null;
public override ClaimsPrincipal? User => null;
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features { get; } = new FeatureCollection();
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() { }
}
/// <summary>Records every group join/leave so tests can assert membership changes.</summary>
internal sealed class FakeGroupManager : IGroupManager
{
public List<(string ConnectionId, string Group)> Added { get; } = [];
public List<(string ConnectionId, string Group)> Removed { get; } = [];
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Removed.Add((connectionId, groupName));
return Task.CompletedTask;
}
}
/// <summary>Records every push so <see cref="StatusBroadcaster"/> tests can assert routing.</summary>
internal sealed class FakeStatusPushSink : IStatusPushSink
{
private readonly ConcurrentBag<StatusResponse> _fleet = [];
private readonly ConcurrentBag<(string Plc, PlcDetailResponse Detail)> _plc = [];
public IReadOnlyCollection<StatusResponse> FleetPushes => _fleet;
public IReadOnlyCollection<(string Plc, PlcDetailResponse Detail)> PlcPushes => _plc;
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
{
_fleet.Add(snapshot);
return Task.CompletedTask;
}
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
{
_plc.Add((plcName, detail));
return Task.CompletedTask;
}
}
@@ -0,0 +1,120 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
/// capture disarmed on stop. The SignalR sink is faked; a real
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusBroadcasterTests
{
private sealed record Harness(
IHost Host,
StatusBroadcaster Broadcaster,
FakeStatusPushSink Sink,
StatusSnapshotBuilder Builder,
TagCaptureRegistry Registry,
PlcSubscriptionTracker Tracker) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Broadcaster.DisposeAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await Host.StopAsync(cts.Token); } catch { }
Host.Dispose();
}
}
private static async Task<Harness> BuildAsync()
{
var hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0",
});
hostBuilder.Services.AddSerilog(
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
hostBuilder.AddMbproxyOptions();
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
hostBuilder.Services.AddSingleton<ProxyWorker>();
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
var host = hostBuilder.Build();
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await host.StartAsync(startCts.Token);
var builder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
var options = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
var tracker = new PlcSubscriptionTracker();
var sink = new FakeStatusPushSink();
var broadcaster = new StatusBroadcaster(
sink, builder, tracker, registry, options, NullLogger.Instance);
return new Harness(host, broadcaster, sink, builder, registry, tracker);
}
[Fact]
public async Task PushOnce_AlwaysPushesFleet()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.FleetPushes.Count.ShouldBe(1);
}
[Fact]
public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.PlcPushes.ShouldBeEmpty();
}
[Fact]
public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Tracker.Add("conn-1", "plc-x");
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
push.Plc.ShouldBe("plc-x");
push.Detail.Debug.ShouldNotBeNull();
}
[Fact]
public async Task StopAsync_DisarmsEveryCapture()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Registry.Arm("plc-x");
await h.Broadcaster.StopAsync();
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeFalse();
}
}
@@ -1,128 +0,0 @@
using Mbproxy.Admin;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
/// All tests are pure: no network, no host, no DI.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHtmlRendererTests
{
// ── Helpers ───────────────────────────────────────────────────────────────
private static StatusResponse MakeStatus(
IReadOnlyList<PlcStatus>? plcs = null,
int uptimeSeconds = 42,
string version = "1.2.3")
{
var service = new ServiceFields(
UptimeSeconds: uptimeSeconds,
Version: version,
ConfigLastReloadUtc: null,
ConfigReloadCount: 0,
ConfigReloadRejectedCount: 0);
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
return new StatusResponse(service, listeners, plcs ?? []);
}
private static PlcStatus MakePlc(
string name = "PLC-A",
string state = "bound",
string? lastBindError = null,
int recoveryAttempts = 0,
IReadOnlyList<ClientSnapshot>? clients = null)
{
var noClients = (IReadOnlyList<ClientSnapshot>)[];
return new PlcStatus(
Name: name,
Host: "10.0.0.1",
ListenPort: 5020,
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2, 0),
Backend: new PlcBackendStatus(
ConnectsSuccess: 0, ConnectsFailed: 0,
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0),
LastRoundTripMs: 3.5,
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
DisconnectCascades: 0, QueueDepth: 0,
CoalescedHitCount: 0, CoalescedMissCount: 0,
CoalescedResponseToDeadUpstream: 0,
CacheHitCount: 0, CacheMissCount: 0,
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0,
BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0,
BackendIdleDisconnects: 0),
Bytes: new PlcBytesStatus(1024, 2048));
}
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
[Fact]
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
{
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
string html = StatusHtmlRenderer.Render(status);
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
html.ShouldContain("<!DOCTYPE html>");
html.ShouldContain("</html>");
html.ShouldContain("PLC-A");
html.ShouldContain("bound");
}
// ── 2. Recovering state highlights error ─────────────────────────────────
[Fact]
public void Render_RecoveringPlc_HighlightsState()
{
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
var status = MakeStatus([plc]);
string html = StatusHtmlRenderer.Render(status);
// State should be orange.
html.ShouldContain("class=\"recovering\"");
html.ShouldContain("Address already in use");
html.ShouldContain("attempt 3");
}
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
[Fact]
public void Render_PageWeightUnder50KB_For54Plcs()
{
const int plcCount = 54;
// Build 54 realistic PLC rows with 2 clients each.
var plcs = new List<PlcStatus>(plcCount);
for (int i = 0; i < plcCount; i++)
{
var clients = new List<ClientSnapshot>
{
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
};
plcs.Add(MakePlc(
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
state: i % 5 == 0 ? "recovering" : "bound",
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
recoveryAttempts: i % 5 == 0 ? 2 : 0,
clients: clients));
}
var status = MakeStatus(plcs);
string html = StatusHtmlRenderer.Render(status);
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
// Assert ≤ 50 KB.
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
}
}
@@ -0,0 +1,91 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
/// no SignalR host is started.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHubTests
{
private static StatusHub MakeHub(
string connectionId,
PlcSubscriptionTracker tracker,
TagCaptureRegistry registry,
out FakeGroupManager groups)
{
groups = new FakeGroupManager();
return new StatusHub(tracker, registry)
{
Context = new FakeHubCallerContext(connectionId),
Groups = groups,
};
}
[Fact]
public async Task SubscribeFleet_JoinsFleetGroup()
{
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
await hub.SubscribeFleet();
groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup));
}
[Fact]
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await hub.SubscribePlc("plc-1");
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
}
[Fact]
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
{
var tracker = new PlcSubscriptionTracker();
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub1 = MakeHub("conn-1", tracker, registry, out _);
var hub2 = MakeHub("conn-2", tracker, registry, out _);
await hub1.SubscribePlc("plc-1");
await hub2.SubscribePlc("plc-1");
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
// First viewer leaves — a second viewer remains, so capture stays armed.
await hub1.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
// Last viewer leaves — capture disarms.
await hub2.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
}
[Fact]
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
{
var registry = new TagCaptureRegistry(); // no captures registered
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
registry.TryGet("ghost", out _).ShouldBeFalse();
}
}
@@ -218,18 +218,60 @@ public sealed class StatusSnapshotBuilderTests
result.Service.ConfigReloadCount.ShouldBe(1);
}
// ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────
[Fact]
public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
{
var (host, builder) = await BuildAsync([]);
await using var _ = new AsyncHostDispose(host);
var debug = builder.BuildDebug("no-such-plc");
debug.CaptureArmed.ShouldBeFalse();
debug.Tags.ShouldBeEmpty();
}
// ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────
[Fact]
public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
{
int port = PickFreePort();
var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
await using var _ = new AsyncHostDispose(host);
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");
var debug = builder.BuildDebug("PLC-A");
debug.CaptureArmed.ShouldBeFalse(); // no detail page open
var tag = debug.Tags.ShouldHaveSingleItem();
tag.Address.ShouldBe(1072);
tag.Width.ShouldBe(16);
tag.HasValue.ShouldBeFalse();
tag.RawHex.ShouldBe("—");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
(string name, int port)[] plcs,
int startupWaitMs = 200,
int backendPort = 502)
int backendPort = 502,
int? bcd16Address = null)
{
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
};
if (bcd16Address is { } addr)
{
config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
}
for (int i = 0; i < plcs.Length; i++)
{
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;