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")]