# 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` / `<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.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<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 `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<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;
  `.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<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 |
