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

515 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 |