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>
This commit is contained in:
Joseph Doherty
2026-05-15 10:40:21 -04:00
parent b330faff03
commit e719dd51c1
49 changed files with 3539 additions and 424 deletions
+514
View File
@@ -0,0 +1,514 @@
# 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 |