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:
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user