# 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: 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` / `` 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` 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.Write`s 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.Read`s 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` 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/.woff2`, `vendor/.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 ``. - `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 `ListenPort`s 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("/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`): 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. `` 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; `.js` → `text/javascript`; `.woff2` → `font/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 Debug` → **0 warnings** (`TreatWarningsAsErrors` is on in both projects — any warning fails the build). 2. `dotnet test` → **full 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("/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 |