e719dd51c1
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>
515 lines
28 KiB
Markdown
515 lines
28 KiB
Markdown
# 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 |
|