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>
28 KiB
mbproxy Web UI Dashboard Redesign — Implementation Plan
Created: 2026-05-15
Status: Complete — all 7 phases done, Gates 0–6 green. dotnet build 0
warnings; dotnet test 452 passed / 0 failed; single-file win-x64 publish
serves the full UI with zero external requests. Not yet committed.
Execution: Sequential, single agent (phases 1→6 in order).
Working artifact — not part of the docs/ source-of-truth tree (per ../../DOCS-GUIDE.md).
Delete or archive once the work lands and docs/Operations/StatusPage.md is updated.
Goal
Replace the single auto-refreshing zero-JS status page with a two-view operator console:
- Fleet dashboard (
GET /) — aggregate fleet health at the top, a filterable/sortable per-PLC KPI table below. Live via SignalR. - Connection detail page (
GET /plc/{name}, opened in a new tab) — every per-PLC counter regrouped into readable cards, the per-upstream-client list, and a real-time debug view: a per-tag live-value table showing the raw PLC-side value vs. the decoded client-side value for each configured BCD tag. Live via SignalR.
GET /status.json is unchanged — scrapers depend on it (see
docs/Operations/StatusPage.md "How to Scrape It"). The old
StatusHtmlRenderer / <meta http-equiv="refresh"> page is retired.
Decisions (requirements review, 2026-05-15)
- Debug view = per-tag live values. Last raw PLC-side value (BCD nibbles), last decoded client-side value, direction, age — one slot per configured BCD tag. No transaction ring buffer.
- On-demand capture. Per-tag capture is armed only while a PLC's detail page has a live SignalR subscriber; disarmed (and slots cleared) when the last viewer leaves. Zero hot-path cost otherwise. No new write/control action — admin stays read-only.
- Bootstrap 5, vanilla JS, no build step. Bootstrap CSS/JS, the SignalR JS client, and the app's HTML/CSS/JS are vendored into the repo and embedded as resources in the single-file binary. Nothing is CDN-fetched.
- Visual direction: refined technical-light. Customized Bootstrap theme (CSS-variable overrides, not stock), monospace for numeric cells, restrained accent palette, status carried by color. Vendored fonts: one display + one mono open-licensed (SIL OFL) woff2, embedded — no Google Fonts fetch.
- Push cadence ~1 s, exposed as
Mbproxy.AdminPushIntervalMs(default 1000). - Hub testing:
StatusHubunit-tested directly with mockedIGroupManager/HubCallerContext;StatusBroadcastertested against an in-process Kestrel. NoSignalR.Clientpackage added to the test project. - UI gates: browser smoke tests driven through the claude-in-chrome MCP against a running service + the dl205 simulator.
- No auth change — admin endpoint stays network-trusted.
Architecture
Backend instrumentation (the genuinely new code)
BcdPduPipeline is stateless today — it rewrites BCD values in flight and keeps
no record of them. The debug view needs a place to record "last raw / last
decoded" per tag.
TagValueCapture (new, src/Mbproxy/Proxy/TagValueCapture.cs) — one
instance per PLC.
- Construction takes the PLC's BCD tag addresses; builds a
FrozenDictionary<ushort,int>mapping each tag address → a slot index, plus a slot array sized to the tag count. - A slot is an immutable record
TagValueSlot(ushort RawValue, int DecodedValue, CaptureDirection Direction, DateTimeOffset UpdatedAtUtc).Record(address, raw, decoded, dir)builds a fresh slot andVolatile.Writes the reference into the array. Reference assignment is atomic and the record is immutable, so a concurrent reader never sees a torn slot — all four fields are coherent. No locks. volatile bool _armedgate:Record(...)returns immediately when disarmed.Arm()/Disarm()flip it;Disarm()also null-clears the slot array so a reopened detail page shows "no traffic yet" instead of stale values.Snapshot()Volatile.Reads every slot for the SignalR push.
TagCaptureRegistry (new, src/Mbproxy/Proxy/TagCaptureRegistry.cs) — a
DI singleton holding name → TagValueCapture. Exposes Arm(name),
Disarm(name), DisarmAll(), TryGet(name), and Rebuild(name, addresses)
(used by the hot-reload path to re-key a capture to a changed tag list,
preserving the armed flag). ProxyWorker, StatusHub, StatusSnapshotBuilder,
and StatusBroadcaster all share this one registry.
PerPlcContext gains internal TagValueCapture? Capture { get; init; },
threaded through WithCurrentRequest. Always wired in production; null in
unit-test contexts that don't exercise it (the pipeline guards with ?.).
BcdPduPipeline records into ctx.Capture? at the four points where it
already holds both the raw and decoded value: FC03/FC04 response decode (16-bit
and 32-bit branches), FC06 request encode, FC16 request encode. Recording is
post-decode (Read) / pre-encode (Write) so the table reads "PLC side vs client
side" correctly in both directions. Cost when disarmed: one nullable-deref +
one volatile-bool read.
SignalR
Microsoft.AspNetCore.SignalR ships in the Microsoft.AspNetCore.App framework
reference already on the project — no new package.
StatusHub(src/Mbproxy/Admin/StatusHub.cs) — hub at/hub/status. Methods:SubscribeFleet()joins groupfleet;SubscribePlc(name)joins groupplc:{name}. A thread-safe per-PLC subscriber counter drives capture arming throughTagCaptureRegistry: 0→1 arms, 1→0 disarms.SubscribePlcfor an unknown PLC name is a no-op (no throw, no arm).OnDisconnectedAsyncdecrements every group the connection was in.StatusBroadcaster(src/Mbproxy/Admin/StatusBroadcaster.cs) — a loop started byAdminEndpointHostwhen the Kestrel app starts, stopped when it stops. EveryAdminPushIntervalMsit builds a snapshot, pushes the fleet summary to groupfleet, and pushes per-PLC detail (counters +TagValueCapture.Snapshot()) to eachplc:{name}group that has subscribers (idle groups skipped).Stop()callsregistry.DisarmAll()so an AdminPort hot-reload — which tears down the WebApplication and every SignalR connection — never leaves a capture stuck armed.- SignalR's default JSON hub protocol is reflection-based. The project is
not
PublishTrimmed(single-file ≠ trimmed), so it works at runtime. The hub DTOs are still declared in aJsonSerializablesource-gen context and thatTypeInfoResolveris registered on the hub protocol options — keeps parity withStatusJsonContextand pre-empts a future trim.
DI wiring across the inner WebApplication
AdminEndpointHost builds a separate WebApplication (CreateSlimBuilder)
with its own DI container. TagCaptureRegistry and StatusSnapshotBuilder are
outer-host singletons; AdminEndpointHost receives both by constructor
injection and re-registers them into the inner container so StatusHub can
resolve them. StatusBroadcaster is created by AdminEndpointHost, closing
over the builder + registry, and pulls IHubContext<StatusHub> from
app.Services after Build().
Asset delivery
- Vendored files committed under
src/Mbproxy/Admin/wwwroot/:vendor/bootstrap.min.css,vendor/bootstrap.bundle.min.js,vendor/signalr.min.js,vendor/<display>.woff2,vendor/<mono>.woff2. - App files:
index.html,plc.html,theme.css(shared variables + chrome),dashboard.css+dashboard.js(fleet view),detail.css+detail.js(detail view). Mbproxy.csprojmarksAdmin\wwwroot\**as<EmbeddedResource>.AdminEndpointHostserves them via a singleMapGet("/assets/{*path}")that streamsAssembly.GetManifestResourceStreamwith a static extension→ content-type map — fewer moving parts than the embedded-file-provider package for ~8 files./assets/*gets a long immutable cache header; the HTML shells getno-cache.
Routes after the redesign
| Route | Serves |
|---|---|
GET / |
Fleet dashboard HTML (index.html) |
GET /plc/{name} |
Detail page HTML (plc.html); JS reads {name} from the path |
GET /assets/{*path} |
Embedded vendored + app assets |
GET /status.json |
Unchanged — source-gen JSON snapshot |
/hub/status |
SignalR hub |
Phase 0 — Prep
- Add
tests/sim/mbproxy.smoke.config.json(or reuse an existing fixture pattern): severalPlcsentries with distinctListenPorts all pointing at127.0.0.1:502(one dl205 simulator instance multiplexed) plus one entry pointed at an unreachable host so the dashboard shows arecoveringrow and aboundrow. At least one PLC carries 16-bit and 32-bit BCD tags so the debug view has content. This config backs the Phase 4/5 chrome smoke tests. - Confirm
tests/sim/run-dl205-sim.ps1starts cleanly on the dev box.
Gate 0: simulator launches; smoke config validates against ReloadValidator
(quick dotnet run-and-check, or a unit test that binds it).
Phase 1 — Backend instrumentation
Owns: new Proxy/TagValueCapture.cs, Proxy/TagCaptureRegistry.cs; modify
Proxy/PerPlcContext.cs, Proxy/BcdPduPipeline.cs, Proxy/ProxyWorker.cs;
new DTOs in new Admin/DebugDto.cs; modify Admin/StatusSnapshotBuilder.cs.
- Implement
TagValueCapture(immutable-slot swap, armed gate, frozen address→index map,Snapshot). - Implement
TagCaptureRegistry(singleton; arm/disarm/disarmAll/rebuild). - Add
CapturetoPerPlcContext+WithCurrentRequest. - Add the four
ctx.Capture?.Record(...)calls inBcdPduPipeline. ProxyWorkerbuilds aTagValueCaptureper PLC, registers each in the registry, wires it into the matchingPerPlcContext; the tag-list hot-reload path callsregistry.Rebuild(...).- Add
TagValueDto/PlcDebugSnapshotDTOs (inDebugDto.cs, with aJsonSerializablecontext) andStatusSnapshotBuilder.BuildPlcDetail(name)returning per-PLC counters + capture snapshot.
Unit tests (tests/Mbproxy.Tests/Proxy/TagValueCaptureTests.cs,
TagCaptureRegistryTests.cs, extend Proxy/.../BcdPduPipeline tests):
TagValueCapture: disarmedRecordis a no-op; armedRecordupdates the matching slot; unknown address ignored;Disarmclears slots; re-arm starts empty; 16-bit and 32-bit slots; concurrency — parallelRecordfrom many threads + concurrentSnapshot, assert every observed slot is internally coherent (raw/decoded/dir/time from oneRecord).TagCaptureRegistry: arm/disarm reach the right capture;DisarmAll;Rebuildpreserves the armed flag and re-keys to the new address set;TryGet/arm for an unknown name is a safe no-op.BcdPduPipeline: with an armed capture, FC03 / FC04 (16-bit + 32-bit) record raw BCD + decoded; FC06 / FC16 record client value + encoded BCD with direction Write; regression — with a disarmed capture and with anullcapture the pipeline still rewrites identically and never throws.StatusSnapshotBuilder.BuildPlcDetailshape, including an unknown PLC name.
Gate 1: dotnet build -c Debug → 0 warnings (TreatWarningsAsErrors).
dotnet test → full suite green, all existing pipeline tests still pass,
new tests above present and passing.
Phase 2 — SignalR hub + broadcaster
Owns: new Admin/StatusHub.cs, Admin/StatusBroadcaster.cs; modify
Admin/AdminEndpointHost.cs, Options/MbproxyOptions.cs,
Configuration/ReloadValidator.cs.
Options/MbproxyOptions.cs: addAdminPushIntervalMs(default 1000);ReloadValidatorrejects values ≤ 0.StatusHubwithSubscribeFleet/SubscribePlc+ the per-PLC subscriber counter →TagCaptureRegistryarm/disarm;OnDisconnectedAsynccleanup.StatusBroadcasterpush loop;Stop()→DisarmAll().AdminEndpointHost.StartAppAsync:AddSignalR()(+ source-gen JSON resolver), re-registerTagCaptureRegistry/StatusSnapshotBuilderinto the inner container,MapHub<StatusHub>("/hub/status"), create + start the broadcaster afterapp.StartAsync.StopCurrentAppAsync: stop the broadcaster before stopping the app. AdminPort hot-reload path inherits this.
Unit tests (Admin/StatusHubTests.cs, Admin/StatusBroadcasterTests.cs,
extend Options/MbproxyOptionsBindingTests.cs,
Configuration/ReloadValidatorTests.cs):
StatusHub(mockIGroupManager,HubCallerContext,IGroupManager-bearingClients):SubscribeFleetjoinsfleet;SubscribePlc("x")joinsplc:xand arms x on the first subscriber; a second subscriber does not re-arm;OnDisconnectedAsyncdisarms only on the last leave; unknown PLC name → no throw, no arm.StatusBroadcaster(mockIHubContext<StatusHub>): pushes tofleeteach tick; skips aplc:xpush when that group has no subscribers; pushes per-PLC detail incl. capture snapshot when subscribed;Stop()disarms all.AdminPushIntervalMs: binding default = 1000;ReloadValidatorrejects 0 and negatives.
Gate 2: build → 0 warnings; dotnet test → full suite green incl. the new
hub/broadcaster tests; service starts and /hub/status negotiate responds 200.
Phase 3 — Asset pipeline + routing
Owns: modify Mbproxy.csproj, Admin/AdminEndpointHost.cs; add vendored +
placeholder app files under src/Mbproxy/Admin/wwwroot/; delete
Admin/StatusHtmlRenderer.cs.
- Vendor Bootstrap 5, the
@microsoft/signalrbrowser bundle, and the two SIL-OFL woff2 fonts intowwwroot/vendor/. Record exact versions + SHA-256 of each vendored file in the progress log (provenance for a firewalled build). - Create placeholder
index.html,plc.html,theme.css,dashboard.css,dashboard.js,detail.css,detail.js(real content in Phases 4/5);theme.csscarries the shared CSS variables +@font-face+ base chrome so Phases 4 and 5 never both edit one CSS file. <EmbeddedResource Include="Admin\wwwroot\**" />in the csproj.- Replace
GET /(serveindex.html), addGET /plc/{name}(serveplc.html) andGET /assets/{*path}(stream embedded resource, content-type map, immutable cache header); deleteStatusHtmlRendererand remove its use.
Tests (extend Admin/AdminEndpointTests.cs, live in-process Kestrel):
GET /→ 200text/html;GET /plc/foo→ 200text/html.GET /assets/vendor/bootstrap.min.css→ 200text/css+ long cache header;.js→text/javascript;.woff2→font/woff2.GET /assets/does-not-exist→ 404.GET /status.jsonstill returns the valid shape (regression).- Delete
StatusHtmlRendererTests.cs; remove/replace theAdminEndpointTestscase that asserted the old PLC-table HTML.
Gate 3: build → 0 warnings; dotnet test green; install/publish.ps1 -Rid win-x64 produces a single-file binary that serves /, /plc/{name}, and every
/assets/* file with correct content types — verified in a browser with
devtools showing zero external network requests.
Phase 4 — Fleet dashboard frontend
Owns: wwwroot/index.html, wwwroot/dashboard.css, wwwroot/dashboard.js.
(Disjoint from Phase 5's files — see Parallel-safety below.)
- Aggregate header — cards: listeners bound/configured, total connected
clients, fleet PDU rate (Δcounter/Δt across successive pushes, computed
client-side), PLCs in
recovering, total backend exceptions, fleet coalesce% and cache%. - KPI table — one row per PLC, Tier-1 columns only: state (color chip), clients, PDU rate, RTT, exceptions, coalesce%, cache%, keepalive health. Full detail lives on the detail page.
- Filter/sort — client-side: name search, state filter, "problems only" toggle (recovering, or non-zero exceptions, or failed heartbeats), sortable columns.
- SignalR client: connect,
SubscribeFleet(), re-render per push, automatic reconnect with a visible connection-state indicator. - Row click →
window.open('/plc/' + encodeURIComponent(name), '_blank'). - Apply the refined technical-light theme (Bootstrap CSS-variable overrides in
theme.css; view-specific rules indashboard.css).
Gate 4 — claude-in-chrome smoke: start the service with the Phase-0 smoke
config + simulator; drive Chrome via the MCP to load / and assert: header
cards render with non-placeholder values; the table has the expected row count;
a counter value changes within ~2 push cycles (live update); the "problems
only" filter hides the healthy rows; a row click opens a /plc/{name} tab.
Build + dotnet test still green.
Phase 5 — Detail page + debug view
Owns: wwwroot/plc.html, wwwroot/detail.css, wwwroot/detail.js.
- Read PLC name from
location.pathname; SignalR connect;SubscribePlc(name). - Grouped counter cards — Listener, Clients (+ per-upstream-client list), PDU traffic, Backend health, Multiplexer, Coalescing, Cache, Keepalive, Bytes. Every per-PLC counter, regrouped for readability.
- Debug view — per-tag live-value table: tag address, width (16/32), raw PLC-side value (hex BCD nibbles), decoded client-side value, direction, age. Stale rows dimmed; "no traffic yet" before the first capture.
- Connection-state indicator; clear "PLC no longer configured" state for an unknown / hot-reload-removed name.
Gate 5 — claude-in-chrome smoke: load /plc/{name} for a tagged PLC; assert
the grouped cards render; the debug table is initially empty, then populates
after the smoke harness issues simulator BCD reads/writes (capture armed on page
open); confirm the connection indicator shows "connected". Optionally verify the
capture disarms after the tab closes (registry state inspected via a test hook
or by reopening and seeing an empty table). Build + dotnet test still green.
Phase 6 — Docs, full regression, cleanup
- Rewrite the "HTML Page Layout" section of
docs/Operations/StatusPage.mdas the two-view + SignalR description; document/plc/{name},/hub/status,/assets/*, the debug view, and on-demand capture. The/status.jsonsection stays as-is. docs/Operations/Configuration.md— addMbproxy.AdminPushIntervalMs.docs/Reference/LogEvents.md— add any newmbproxy.admin.*events (hub start; capture arm/disarm at Debug level).mbproxy/CLAUDE.md+README.md— refresh the admin-endpoint headline bullet (two views, SignalR, debug view).- Confirm
StatusHtmlRenderer*fully removed;StatusSnapshotBuilderTestsupdated forBuildPlcDetail. - Vendored-asset provenance table finalized in the progress log.
Gate 6: full dotnet test green on Windows (and linux-x64 per the
multiplatform plan); docs/ internally consistent; single-file publish serves
the new UI with zero external requests; both chrome smoke flows (Gate 4 + 5)
re-run green end-to-end.
Parallel-safety / file-ownership
Execution is sequential single-agent (chosen). This section documents how the work could be split if delegated, and the ordering constraints that hold regardless.
Hard ordering (must be sequential):
- 1 → 2: Phase 2 (
StatusHub, broadcaster,StatusSnapshotBuilderuse) depends on Phase 1'sTagCaptureRegistry,TagValueCapture, andBuildPlcDetail. - 2 → 3: Phases 2 and 3 both edit
AdminEndpointHost.cs— they cannot run concurrently. Do Phase 2's hub/broadcaster wiring, then Phase 3's route/asset wiring, in the same file sequentially. - 3 → 4, 3 → 5: the frontend phases need the asset routes and the
theme.cssshared base from Phase 3.
Parallelizable (if ever delegated): Phases 4 and 5 touch disjoint files —
Phase 4 owns index.html / dashboard.css / dashboard.js; Phase 5 owns
plc.html / detail.css / detail.js; theme.css is frozen in Phase 3 and
edited by neither. Both phases are pure static-asset edits — no .cs, no
build during the phase — so two agents can work the same checkout with no
obj/bin race; the build + chrome smoke gate runs once after both. No git
worktree isolation needed. If a frontend phase turns out to need a .cs change
(e.g. a missing DTO field), that change is pulled back into a Phase-1/3 fix and
the parallel split is paused — frontend agents never edit .cs.
File-ownership matrix:
| File | Phase |
|---|---|
Proxy/TagValueCapture.cs, TagCaptureRegistry.cs (new) |
1 |
Proxy/PerPlcContext.cs, BcdPduPipeline.cs, ProxyWorker.cs |
1 |
Admin/DebugDto.cs (new), Admin/StatusSnapshotBuilder.cs |
1 |
Admin/StatusHub.cs, StatusBroadcaster.cs (new) |
2 |
Options/MbproxyOptions.cs, Configuration/ReloadValidator.cs |
2 |
Admin/AdminEndpointHost.cs |
2 then 3 (sequential, same file) |
Mbproxy.csproj, Admin/wwwroot/** (vendored + theme.css) |
3 |
Admin/StatusHtmlRenderer.cs (delete) |
3 |
wwwroot/index.html, dashboard.css, dashboard.js |
4 |
wwwroot/plc.html, detail.css, detail.js |
5 |
docs/**, CLAUDE.md, README.md |
6 |
Phase-gate checklist (applies to every phase)
dotnet build -c Debug→ 0 warnings (TreatWarningsAsErrorsis on in both projects — any warning fails the build).dotnet test→ full suite green, the phase's new tests present and passing, no previously-passing test regressed.- The phase-specific functional check listed in its gate above.
- No new NuGet package unless the plan names it (none are required).
Risks / open items
- SignalR + single-file. Reflection JSON works (not trimmed); confirmed at Gate 2. The source-gen resolver registration covers a future trim.
- Inner-container DI.
AdminEndpointHost'sCreateSlimBuilderWebApplication has its own container —TagCaptureRegistryandStatusSnapshotBuildermust be explicitly re-registered there for the hub. Easy to miss; called out in Phase 2 step 4. - Capture arm leak on AdminPort hot-reload. The WebApplication is torn down
and rebuilt;
StatusBroadcaster.Stop()→DisarmAll()guarantees no capture stays armed. Tested in Phase 2. - Hot-reload of the tag list must
Rebuilda PLC'sTagValueCapturefor the new address set while preserving the armed flag — Phase 1 step 5. - PDU-rate cards are Δcounter/Δt computed client-side from successive pushes — no new server counter.
- Vendored-asset size. Bootstrap + SignalR + 2 woff2 ≈ a few hundred KB embedded — negligible against a ~100 MB self-contained binary. The old ≤50 KB page-weight budget was a no-JS constraint and no longer applies.
- Chrome smoke flakiness. The MCP-driven gates wait on push cycles; use explicit waits on counter change, not fixed sleeps, and a generous timeout.
Progress log
- 2026-05-15 — Phase 0 done. Added
tests/sim/mbproxy.smoke.config.json(line-a 16-bit BCD tag, line-b 32-bit BCD tag → dl205 sim on 127.0.0.1:5020; line-dead → unreachable 192.0.2.1 for the "problems only" filter). - 2026-05-15 — Phase 1 done, Gate 1 green. New:
Proxy/TagValueCapture.cs(immutable-slotVolatile.Writeswap, armed gate),Proxy/TagCaptureRegistry.cs,Admin/DebugDto.cs. Modified:PerPlcContext(+Capture),BcdPduPipeline(4Recordhooks — FC03/04 16+32-bit read, FC06/FC16 write),ProxyWorker+ConfigReconciler(registry wiring incl. reseat-rebuild + remove),StatusSnapshotBuilder.BuildDebug,HostingExtensions(DI singleton).dotnet build0 warnings;dotnet test436 passed / 0 failed / 0 skipped (incl. 23 new Phase-1 tests: TagValueCaptureTests ×9, TagCaptureRegistryTests ×6, BcdPduPipelineCaptureTests ×6, StatusSnapshotBuilder BuildDebug ×2). - 2026-05-15 — Phase 2 done, Gate 2 green. New:
Admin/StatusHub.cs,StatusBroadcaster.cs,StatusPushSink.cs(IStatusPushSinkseam +SignalRStatusPushSink),PlcSubscriptionTracker.cs. Modified:MbproxyOptions(+AdminPushIntervalMs, schema +ReloadValidator),AdminEndpointHost(AddSignalR,MapHub<StatusHub>("/hub/status"), broadcaster lifecycle tied to the Kestrel app,DisarmAllon stop),HostingExtensions(PlcSubscriptionTrackersingleton). Decision: kept SignalR's default reflection JSON protocol (project is notPublishTrimmed, so the source-gen resolver is unnecessary — recorded as a deliberate deviation from the plan's "register source-gen resolver" note).dotnet build0 warnings;dotnet test448 passed / 0 failed / 0 skipped (+12: StatusHubTests ×4, StatusBroadcasterTests ×4, ReloadValidator ×2, MbproxyOptionsBinding ×2). Live check: service starts,POST /hub/status/negotiate→ 200,/status.json→ 200. - 2026-05-15 — Phase 3 done, Gate 3 green. Vendored (jsdelivr) into
Admin/wwwroot/(flat, embedded): Bootstrap 5.3.3, SignalR JS 8.0.7, IBM Plex Sans 400/600 + Mono 500 (fontsource 5.1.1) — see provenance table. New routes inAdminEndpointHost:GET /+GET /plc/{name}(embedded SPA shells,no-cache),GET /assets/{path}(embedded streamer, content-type map, immutable cache, traversal-rejected);StatusHtmlRenderer+ its tests deleted;SignalR.AddJsonProtocolcamelCase pinned.csproj:EmbeddedResource Admin\wwwroot\*.*. The full Phase-4/5 frontend (index.html,dashboard.{css,js},plc.html,detail.{css,js},theme.css) was written in this pass too.dotnet build0 warnings;dotnet test452 passed / 0 failed (AdminEndpointTests rewritten: route- content-type + immutable-header + 404 coverage;
StatusHtmlRendererTestsremoved). Single-file-publish browser verification folded into Gate 4/5.
- content-type + immutable-header + 404 coverage;
- 2026-05-15 — Phases 4 + 5 done, Gates 4 + 5 green. Frontend already
written in the Phase-3 pass; this pass ran the browser smoke. Environment
note: the claude-in-chrome MCP browser could not reach
127.0.0.1:8080(Chrome on this box is behind a corporate proxy with no localhost bypass — even the sim's own:8081console failed). Substituted the Playwright MCP browser (own Chromium, no proxy) — a real-browser smoke, just a different driver. Setup: dl205 simulator on:5020, mbproxy on the smoke config, a pymodbus traffic generator (FC03 reads of V1072 on line-a/line-b, TCP touches on line-dead). Gate 4: dashboard renders 6 aggregate cards + 3-row KPI table; live update verified (PDU/s 0 → 52 → 67, uptime ticking); "problems only" filter → "1 of 3" (line-dead, non-zero connectsFailed). Gate 5:/plc/line-arenders all 9 grouped counter cards + per-client line; debug view shows CAPTURE ARMED (on-demand arm on page open) with tag 1072 → raw0x1234(PLC side) / decoded1234(client side);/plc/line-b32-bit tag → raw0x00001234/ decoded1234. Build 0 warnings, full suite still 452 green. - 2026-05-15 — Phase 6 done, Gate 6 green. Docs:
StatusPage.md"Endpoint Surface" + "Web Dashboard" + new "Debug View Data" sections rewritten;Configuration.mdgainedMbproxy.AdminPushIntervalMs;mbproxy/CLAUDE.mdREADME.mdadmin bullets refreshed. No newmbproxy.*log events were added (broadcaster/hub use plainLogError), soLogEvents.mdis unchanged.StatusHtmlRenderer+ tests confirmed removed;StatusSnapshotBuilderTestsupdated.dotnet build0 warnings;dotnet test452 passed / 0 failed. Single-filedotnet publish -c Release -r win-x64→ 105 MB self-containedMbproxy.exe; live check:/,/plc/{name},/assets/{bootstrap.min.css, signalr.min.js,ibm-plex-sans-400.woff2}all 200 with correct content types,/hub/status/negotiate200, unknown asset 404, andindex.htmlcontains zero external URLs (embedded resources resolve fine inside the single-file bundle).
Vendored-asset provenance
Filled in during Phase 3.
Vendored 2026-05-15 from cdn.jsdelivr.net/npm. SHA-256 of the stored files:
| File | Package / source | Version | SHA-256 | License |
|---|---|---|---|---|
bootstrap.min.css |
bootstrap | 5.3.3 | 3c8f27e6009ccfd710a905e6dcf12d0ee3c6f2ac7da05b0572d3e0d12e736fc8 |
MIT |
bootstrap.bundle.min.js |
bootstrap | 5.3.3 | 0833b2e9c3a26c258476c46266e6877fc75218625162e0460be9a3a098a61c6c |
MIT |
signalr.min.js |
@microsoft/signalr | 8.0.7 | e28a720a359b37cb015758d543f908730ed5bbe478db09506bb6887f18313538 |
MIT |
ibm-plex-sans-400.woff2 |
@fontsource/ibm-plex-sans | 5.1.1 | db71f8a28ad8501544fb4e7668e3c6d0b731760b6f20de3525ebaeba597f1922 |
SIL OFL 1.1 |
ibm-plex-sans-600.woff2 |
@fontsource/ibm-plex-sans | 5.1.1 | 31535a91ce3f6b8ed3ddedadab1e49957e2220263a640df1a3f14f6fdfe15eb6 |
SIL OFL 1.1 |
ibm-plex-mono-500.woff2 |
@fontsource/ibm-plex-mono | 5.1.1 | 756026ff72eb76fd971ac4b7504cec55eef62109d2684c2cad8da32170b80b37 |
SIL OFL 1.1 |