Files
wwtools/mbproxy/plans/2026-05-15-webui-dashboard.md
Joseph Doherty e719dd51c1 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>
2026-05-15 10:41:02 -04:00

28 KiB
Raw Permalink Blame History

mbproxy Web UI Dashboard Redesign — Implementation Plan

Created: 2026-05-15 Status: Complete — all 7 phases done, Gates 06 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:

  1. Fleet dashboard (GET /) — aggregate fleet health at the top, a filterable/sortable per-PLC KPI table below. Live via SignalR.
  2. 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: StatusHub unit-tested directly with mocked IGroupManager / HubCallerContext; StatusBroadcaster tested against an in-process Kestrel. No SignalR.Client package 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 and Volatile.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 _armed gate: 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 group fleet; SubscribePlc(name) joins group plc:{name}. A thread-safe per-PLC subscriber counter drives capture arming through TagCaptureRegistry: 0→1 arms, 1→0 disarms. SubscribePlc for an unknown PLC name is a no-op (no throw, no arm). OnDisconnectedAsync decrements every group the connection was in.
  • StatusBroadcaster (src/Mbproxy/Admin/StatusBroadcaster.cs) — a loop started by AdminEndpointHost when the Kestrel app starts, stopped when it stops. Every AdminPushIntervalMs it builds a snapshot, pushes the fleet summary to group fleet, and pushes per-PLC detail (counters + TagValueCapture.Snapshot()) to each plc:{name} group that has subscribers (idle groups skipped). Stop() calls registry.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 a JsonSerializable source-gen context and that TypeInfoResolver is registered on the hub protocol options — keeps parity with StatusJsonContext and 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.csproj marks Admin\wwwroot\** as <EmbeddedResource>.
  • AdminEndpointHost serves them via a single MapGet("/assets/{*path}") that streams Assembly.GetManifestResourceStream with 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 get no-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

  1. Add tests/sim/mbproxy.smoke.config.json (or reuse an existing fixture pattern): several Plcs entries with distinct ListenPorts all pointing at 127.0.0.1:502 (one dl205 simulator instance multiplexed) plus one entry pointed at an unreachable host so the dashboard shows a recovering row and a bound row. 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.
  2. Confirm tests/sim/run-dl205-sim.ps1 starts 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.

  1. Implement TagValueCapture (immutable-slot swap, armed gate, frozen address→index map, Snapshot).
  2. Implement TagCaptureRegistry (singleton; arm/disarm/disarmAll/rebuild).
  3. Add Capture to PerPlcContext + WithCurrentRequest.
  4. Add the four ctx.Capture?.Record(...) calls in BcdPduPipeline.
  5. ProxyWorker builds a TagValueCapture per PLC, registers each in the registry, wires it into the matching PerPlcContext; the tag-list hot-reload path calls registry.Rebuild(...).
  6. Add TagValueDto / PlcDebugSnapshot DTOs (in DebugDto.cs, with a JsonSerializable context) and StatusSnapshotBuilder.BuildPlcDetail(name) returning per-PLC counters + capture snapshot.

Unit tests (tests/Mbproxy.Tests/Proxy/TagValueCaptureTests.cs, TagCaptureRegistryTests.cs, extend Proxy/.../BcdPduPipeline tests):

  • TagValueCapture: disarmed Record is a no-op; armed Record updates the matching slot; unknown address ignored; Disarm clears slots; re-arm starts empty; 16-bit and 32-bit slots; concurrency — parallel Record from many threads + concurrent Snapshot, assert every observed slot is internally coherent (raw/decoded/dir/time from one Record).
  • TagCaptureRegistry: arm/disarm reach the right capture; DisarmAll; Rebuild preserves 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 a null capture the pipeline still rewrites identically and never throws.
  • StatusSnapshotBuilder.BuildPlcDetail shape, 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.

  1. Options/MbproxyOptions.cs: add AdminPushIntervalMs (default 1000); ReloadValidator rejects values ≤ 0.
  2. StatusHub with SubscribeFleet / SubscribePlc + the per-PLC subscriber counter → TagCaptureRegistry arm/disarm; OnDisconnectedAsync cleanup.
  3. StatusBroadcaster push loop; Stop()DisarmAll().
  4. AdminEndpointHost.StartAppAsync: AddSignalR() (+ source-gen JSON resolver), re-register TagCaptureRegistry/StatusSnapshotBuilder into the inner container, MapHub<StatusHub>("/hub/status"), create + start the broadcaster after app.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 (mock IGroupManager, HubCallerContext, IGroupManager-bearing Clients): SubscribeFleet joins fleet; SubscribePlc("x") joins plc:x and arms x on the first subscriber; a second subscriber does not re-arm; OnDisconnectedAsync disarms only on the last leave; unknown PLC name → no throw, no arm.
  • StatusBroadcaster (mock IHubContext<StatusHub>): pushes to fleet each tick; skips a plc:x push when that group has no subscribers; pushes per-PLC detail incl. capture snapshot when subscribed; Stop() disarms all.
  • AdminPushIntervalMs: binding default = 1000; ReloadValidator rejects 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.

  1. Vendor Bootstrap 5, the @microsoft/signalr browser bundle, and the two SIL-OFL woff2 fonts into wwwroot/vendor/. Record exact versions + SHA-256 of each vendored file in the progress log (provenance for a firewalled build).
  2. Create placeholder index.html, plc.html, theme.css, dashboard.css, dashboard.js, detail.css, detail.js (real content in Phases 4/5); theme.css carries the shared CSS variables + @font-face + base chrome so Phases 4 and 5 never both edit one CSS file.
  3. <EmbeddedResource Include="Admin\wwwroot\**" /> in the csproj.
  4. Replace GET / (serve index.html), add GET /plc/{name} (serve plc.html) and GET /assets/{*path} (stream embedded resource, content-type map, immutable cache header); delete StatusHtmlRenderer and remove its use.

Tests (extend Admin/AdminEndpointTests.cs, live in-process Kestrel):

  • GET / → 200 text/html; GET /plc/foo → 200 text/html.
  • GET /assets/vendor/bootstrap.min.css → 200 text/css + long cache header; .jstext/javascript; .woff2font/woff2.
  • GET /assets/does-not-exist → 404.
  • GET /status.json still returns the valid shape (regression).
  • Delete StatusHtmlRendererTests.cs; remove/replace the AdminEndpointTests case 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.)

  1. 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%.
  2. 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.
  3. Filter/sort — client-side: name search, state filter, "problems only" toggle (recovering, or non-zero exceptions, or failed heartbeats), sortable columns.
  4. SignalR client: connect, SubscribeFleet(), re-render per push, automatic reconnect with a visible connection-state indicator.
  5. Row click → window.open('/plc/' + encodeURIComponent(name), '_blank').
  6. Apply the refined technical-light theme (Bootstrap CSS-variable overrides in theme.css; view-specific rules in dashboard.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.

  1. Read PLC name from location.pathname; SignalR connect; SubscribePlc(name).
  2. 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.
  3. 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.
  4. 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

  1. Rewrite the "HTML Page Layout" section of docs/Operations/StatusPage.md as the two-view + SignalR description; document /plc/{name}, /hub/status, /assets/*, the debug view, and on-demand capture. The /status.json section stays as-is.
  2. docs/Operations/Configuration.md — add Mbproxy.AdminPushIntervalMs.
  3. docs/Reference/LogEvents.md — add any new mbproxy.admin.* events (hub start; capture arm/disarm at Debug level).
  4. mbproxy/CLAUDE.md + README.md — refresh the admin-endpoint headline bullet (two views, SignalR, debug view).
  5. Confirm StatusHtmlRenderer* fully removed; StatusSnapshotBuilderTests updated for BuildPlcDetail.
  6. 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, StatusSnapshotBuilder use) depends on Phase 1's TagCaptureRegistry, TagValueCapture, and BuildPlcDetail.
  • 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.css shared 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)

  1. dotnet build -c Debug0 warnings (TreatWarningsAsErrors is on in both projects — any warning fails the build).
  2. dotnet testfull suite green, the phase's new tests present and passing, no previously-passing test regressed.
  3. The phase-specific functional check listed in its gate above.
  4. 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's CreateSlimBuilder WebApplication has its own container — TagCaptureRegistry and StatusSnapshotBuilder must 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 Rebuild a PLC's TagValueCapture for 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-slot Volatile.Write swap, armed gate), Proxy/TagCaptureRegistry.cs, Admin/DebugDto.cs. Modified: PerPlcContext (+Capture), BcdPduPipeline (4 Record hooks — FC03/04 16+32-bit read, FC06/FC16 write), ProxyWorker + ConfigReconciler (registry wiring incl. reseat-rebuild + remove), StatusSnapshotBuilder.BuildDebug, HostingExtensions (DI singleton). dotnet build 0 warnings; dotnet test 436 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 (IStatusPushSink seam + SignalRStatusPushSink), PlcSubscriptionTracker.cs. Modified: MbproxyOptions (+AdminPushIntervalMs, schema + ReloadValidator), AdminEndpointHost (AddSignalR, MapHub<StatusHub>("/hub/status"), broadcaster lifecycle tied to the Kestrel app, DisarmAll on stop), HostingExtensions (PlcSubscriptionTracker singleton). Decision: kept SignalR's default reflection JSON protocol (project is not PublishTrimmed, so the source-gen resolver is unnecessary — recorded as a deliberate deviation from the plan's "register source-gen resolver" note). dotnet build 0 warnings; dotnet test 448 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 in AdminEndpointHost: 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.AddJsonProtocol camelCase 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 build 0 warnings; dotnet test 452 passed / 0 failed (AdminEndpointTests rewritten: route
    • content-type + immutable-header + 404 coverage; StatusHtmlRendererTests removed). Single-file-publish browser verification folded into Gate 4/5.
  • 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 :8081 console 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-a renders all 9 grouped counter cards + per-client line; debug view shows CAPTURE ARMED (on-demand arm on page open) with tag 1072 → raw 0x1234 (PLC side) / decoded 1234 (client side); /plc/line-b 32-bit tag → raw 0x00001234 / decoded 1234. 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.md gained Mbproxy.AdminPushIntervalMs; mbproxy/CLAUDE.md
    • README.md admin bullets refreshed. No new mbproxy.* log events were added (broadcaster/hub use plain LogError), so LogEvents.md is unchanged. StatusHtmlRenderer + tests confirmed removed; StatusSnapshotBuilderTests updated. dotnet build 0 warnings; dotnet test 452 passed / 0 failed. Single-file dotnet publish -c Release -r win-x64 → 105 MB self-contained Mbproxy.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/negotiate 200, unknown asset 404, and index.html contains 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