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:
@@ -0,0 +1,514 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user