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
+1 -1
View File
@@ -32,7 +32,7 @@ The full architecture is documented under **[`docs/`](docs/)** — see the `Arch
- **Polly bounded retries** on backend connect (3 attempts at 100ms / 500ms / 2000ms). No retries on mid-request failures (FC06/FC16 are non-idempotent on BCD tags). A per-request watchdog in the multiplexer surfaces Modbus exception 0x0B to the upstream client if a backend response never arrives within `BackendRequestTimeoutMs`.
- **Backend disconnect cascades upstream**: when the shared backend socket dies, every attached upstream pipe is closed in the same cycle (counter `BackendDisconnectCascades`); clients reconnect on their next request.
- **Keepalive / connection monitoring** (ON by default, `Connection.Keepalive`): OS `SO_KEEPALIVE` on backend and accepted upstream sockets, plus a per-PLC application heartbeat — a synthetic FC03 qty=1 read fired on an idle backend socket (`BackendHeartbeatIdleMs`). An unanswered heartbeat proactively tears the backend down (counters `backendHeartbeatsSent/Failed`, `backendIdleDisconnects`). The DL260 has no FC08, so the probe is a real register read. See [`docs/Architecture/Keepalive.md`](docs/Architecture/Keepalive.md).
- **Read-only Kestrel admin port** (default 8080) exposes `GET /` (auto-refreshing HTML) and `GET /status.json` with service-wide and per-PLC counters (including Phase-9 mux fields, Phase-10 coalescing fields, and Phase-11 cache fields `cacheHitCount`, `cacheMissCount`, `cacheInvalidations`, `cacheEntryCount`, `cacheBytes`).
- **Read-only Kestrel admin port** (default 8080) serves a SignalR-backed web dashboard — `GET /` (filterable fleet KPI table), `GET /plc/{name}` (per-PLC grouped counters + a real-time debug view of raw PLC-side BCD vs. decoded client-side values), `/hub/status` (live feed, `Mbproxy.AdminPushIntervalMs` cadence), `/assets/*` (embedded Bootstrap/SignalR/fonts, no CDN) — plus the unchanged `GET /status.json` twin with service-wide and per-PLC counters (Phase-9 mux, Phase-10 coalescing, Phase-11 cache fields `cacheHitCount`/`cacheMissCount`/`cacheInvalidations`/`cacheEntryCount`/`cacheBytes`). The debug view's per-tag value capture (`TagValueCapture`/`TagCaptureRegistry`) is armed on-demand only while a detail page is open. Admin stays strictly read-only — no control actions.
Anything beyond this short list lives in the `docs/` tree: the appsettings.json schema in [`docs/Operations/Configuration.md`](docs/Operations/Configuration.md), config propagation in [`docs/Features/HotReload.md`](docs/Features/HotReload.md), stable log event names in [`docs/Reference/LogEvents.md`](docs/Reference/LogEvents.md), the status counter catalog in [`docs/Operations/StatusPage.md`](docs/Operations/StatusPage.md), and the simulator-backed test fixture in [`docs/Testing/Simulator.md`](docs/Testing/Simulator.md). Open the relevant page before writing code; keep it in sync when decisions change.
+2 -2
View File
@@ -50,7 +50,7 @@ The `docs/` tree is organized by topic. Start with [`Architecture/Overview.md`](
### Operations
- [`Operations/Configuration.md`](docs/Operations/Configuration.md) — Full `appsettings.json` reference: every `Mbproxy:*` key, default, and validation rule.
- [`Operations/StatusPage.md`](docs/Operations/StatusPage.md) — Admin endpoint surface (`/`, `/status.json`) with every JSON field documented.
- [`Operations/StatusPage.md`](docs/Operations/StatusPage.md) — Admin endpoint surface: the SignalR-backed web dashboard (`/`, `/plc/{name}`, `/hub/status`) and the `/status.json` twin, with every JSON field documented.
- [`Operations/Troubleshooting.md`](docs/Operations/Troubleshooting.md) — Diagnosis playbook keyed to log events and status counters.
### Reference
@@ -106,7 +106,7 @@ cd src/Mbproxy
dotnet run --configuration Debug
```
Edit `src/Mbproxy/appsettings.json` to configure PLCs before running. The admin status page will be at `http://localhost:8080/` by default.
Edit `src/Mbproxy/appsettings.json` to configure PLCs before running. The admin dashboard will be at `http://localhost:8080/` by default — a live SignalR-backed fleet view; click any PLC row for its per-connection detail page and real-time BCD debug view.
## Install
+12 -2
View File
@@ -109,9 +109,19 @@ Port for the read-only HTTP status server. Binds to all interfaces on startup.
`ReloadValidator` rejects values outside `[1, 65535]` and rejects collisions with any `Plcs[i].ListenPort`. Source: `MbproxyOptions.AdminPort`.
The server exposes `GET /` (auto-refreshing HTML) and `GET /status.json`. See [`./StatusPage.md`](./StatusPage.md) for the schema.
The server exposes the SignalR-backed web dashboard (`GET /`, `GET /plc/{name}`, `GET /assets/{path}`, `/hub/status`) and the JSON twin `GET /status.json`. See [`./StatusPage.md`](./StatusPage.md) for the endpoint surface and schema.
Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — there are no `POST` / `PUT` / `DELETE` routes — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.
Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — no admin actions are exposed — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.
## `Mbproxy.AdminPushIntervalMs`
Server-push cadence (milliseconds) for the admin dashboard's SignalR feed. Every interval `StatusBroadcaster` builds a status snapshot and pushes it to connected dashboard / detail-page clients.
| Field | Type | Default | Range |
|-------|------|---------|-------|
| `AdminPushIntervalMs` | int | `1000` | `> 0` |
`MbproxyOptionsValidator` and `ReloadValidator` both reject values `<= 0`. The broadcaster additionally floors the effective interval at 100 ms. Source: `MbproxyOptions.AdminPushIntervalMs`.
## `Mbproxy.Plcs[]`
+35 -13
View File
@@ -1,17 +1,20 @@
# Status Page
The status page is the operator-facing view of the running service: an auto-refreshing HTML dashboard at `GET /` and a JSON twin at `GET /status.json` that monitoring scrapers consume. This document describes the endpoint surface, every wire-level field, and how counters map back to architecture decisions.
The status page is the operator-facing view of the running service: a live web dashboard backed by SignalR, plus a JSON twin at `GET /status.json` that monitoring scrapers consume. This document describes the endpoint surface, every wire-level field, and how counters map back to architecture decisions.
## Endpoint Surface
The admin endpoint is owned by `AdminEndpointHost` (see `src/Mbproxy/Admin/AdminEndpointHost.cs`). It exposes exactly two routes:
The admin endpoint is owned by `AdminEndpointHost` (see `src/Mbproxy/Admin/AdminEndpointHost.cs`). It exposes:
- `GET /`a single self-contained HTML document with a `<meta http-equiv="refresh" content="5">` tag. The page refreshes every five seconds by reload, not by JavaScript polling. There is no JS bundle, no external CSS, no remote fonts, and no favicon fetch.
- `GET /`the **fleet dashboard** SPA shell: aggregate fleet health cards and a filterable/sortable per-PLC KPI table.
- `GET /plc/{name}` — the **connection-detail** SPA shell for one PLC: every per-PLC counter grouped into readable cards, the connected-client list, and a real-time debug view (per-tag PLC-side raw BCD vs. client-side decoded value).
- `GET /assets/{path}` — embedded static assets: Bootstrap 5, the SignalR JS client, two vendored IBM Plex woff2 fonts, and the dashboard's own HTML/CSS/JS. Everything is embedded in the binary; nothing is fetched from a CDN, so the UI works on a firewalled network. Served with a long immutable cache header.
- `GET /status.json` — the same in-memory snapshot serialized as JSON via the source-generated `StatusJsonContext` (camelCase property names).
- `/hub/status` — the SignalR hub. The two SPA shells open a hub connection and subscribe: the dashboard to the `fleet` group, a detail page to its `plc:{name}` group. A `StatusBroadcaster` loop pushes a fresh snapshot every `Mbproxy.AdminPushIntervalMs` (default 1000 ms).
The endpoint is **read-only**. There are no admin actions exposed — no kick-client, no force-reload, no listener restart, no log download. Reload happens automatically via `IOptionsMonitor`; listener recovery is owned by the supervisor. Authentication lives at the network layer: the service binds to `IPAddress.Any` on the admin port and assumes the deployment runs in a trusted internal segment behind a firewall.
The endpoint is **read-only**. There are no admin actions exposed — no kick-client, no force-reload, no listener restart, no log download. The detail-page debug view is the one feature with a runtime side effect, and it is benign and read-only: a PLC's tag-value capture is *armed* (begins recording last-seen values) only while at least one detail page is subscribed to it, and *disarmed* when the last viewer leaves. Reload happens automatically via `IOptionsMonitor`; listener recovery is owned by the supervisor. Authentication lives at the network layer: the service binds to `IPAddress.Any` on the admin port and assumes the deployment runs in a trusted internal segment behind a firewall.
Both routes call `StatusSnapshotBuilder.Build()` for every request. The builder reads atomic counters directly from the supervisor map and per-PLC `ProxyCounters`; it holds no locks and performs no I/O.
`GET /status.json` and every SignalR push call `StatusSnapshotBuilder.Build()`. The builder reads atomic counters directly from the supervisor map and per-PLC `ProxyCounters`; it holds no locks and performs no I/O.
## Port and Configuration
@@ -291,19 +294,38 @@ A representative two-PLC deployment, ~2 hours into a run:
}
```
## HTML Page Layout
## Web Dashboard
The HTML renderer is `StatusHtmlRenderer.Render(StatusResponse)` in `src/Mbproxy/Admin/StatusHtmlRenderer.cs`. The page is one document, inline CSS in a `<style>` block, no external resources of any kind — operators can serve it behind a corporate firewall without whitelisting a CDN.
The UI is a Bootstrap 5 single-page app served from embedded assets under `src/Mbproxy/Admin/wwwroot/` (`index.html` / `plc.html` shells, `theme.css` + per-view CSS/JS, vendored Bootstrap / SignalR client / IBM Plex fonts). It is built as vanilla JS — no framework, no build step. Updates arrive over the SignalR `/hub/status` feed (`StatusBroadcaster`, ~1 s cadence); there is no page reload and no JavaScript polling.
Structure:
### Fleet dashboard (`GET /`)
1. **Header summary** — version, formatted uptime (`Nh MMm SSs`), `bound/configured` listener tally, last reload timestamp, reload count with a `(N rejected)` suffix when applicable.
2. **PLC table** — one row per configured PLC. Columns: Name, Host, Port, State (colour-coded — `bound` = green, `recovering` = orange, `stopped` = grey), Clients (count plus a comma-separated list of `remote (N PDUs)`), PDUs forwarded, FC03/FC04/FC06/FC16/FC? counts, BCD slots, Partial BCD, exception codes 01/02/03/04, RTT (ms), bytes in/out, multiplexer columns (in-flight, max in-flight, TxId wraps, cascades, queue), coalescing ratio cell, cache ratio cell, keepalive cell.
3. **State cell error detail** — when `state == "recovering"`, the cell also shows `lastBindError` and `(attempt N)` in a small red span.
1. **App bar** service version, formatted uptime, accepted-reload count, and a live SignalR connection-state pill.
2. **Aggregate strip** — six cards: listeners bound/configured, total connected clients, fleet PDU/s (rate derived client-side from successive snapshots), PLCs in `recovering`, total backend exceptions, fleet cache hit ratio. The recovering / exceptions cards highlight when non-zero.
3. **KPI table** — one row per configured PLC, Tier-1 columns only: PLC name, backend `host:listenPort`, state chip (`bound` green / `recovering` amber / `stopped` grey), clients, PDU/s, RTT ms, exception total, coalesce %, cache %, keepalive. The table is client-side filterable (name/host search, state, "problems only") and sortable. Clicking a row opens that PLC's detail page in a new tab.
The coalescing and cache cells each render as `<pct>% (<hits>)`. When neither has been exercised (`hit + miss == 0`), the cell renders an em-dash to keep the column narrow. The keepalive cell shows the heartbeat-sent count, with `(fail N, idle-disc N)` appended only when either is non-zero. Page weight is bounded by the design budget (≤ 50 KB for a 54-PLC fleet).
### Connection detail (`GET /plc/{name}`)
The page does not depend on JavaScript. Refresh is driven entirely by the `<meta http-equiv="refresh" content="5">` tag, so any browser — including text-mode browsers — sees the same view.
1. **Identity header** — PLC name, `host:listenPort`, state chip. If the PLC was removed by a hot-reload, a "no longer configured" notice replaces the counter cards.
2. **Grouped counter cards** — every per-PLC counter from the JSON schema above, regrouped for readability: Listener, Clients (with the per-connection list), PDU traffic, Backend health, Multiplexer, Read coalescing, Response cache, Keepalive, Bytes.
3. **Debug view** — a per-tag table showing, for each configured BCD tag, the last raw PLC-side value (BCD nibbles in hex), the decoded client-side value, the direction (read/write), and the age of the observation. The header carries a capture-armed indicator. See *Debug View Data* below.
## Debug View Data
The detail page's debug view is fed by an **on-demand per-tag value capture** (`Proxy/TagValueCapture.cs`, one per PLC, held in `Proxy/TagCaptureRegistry.cs`). The `BcdPduPipeline` records the last raw/decoded value for each configured BCD tag — but only while the capture is *armed*. `StatusHub` arms a PLC's capture when the first detail page subscribes and disarms it (clearing all slots) when the last viewer leaves, so the hot path carries zero cost when nobody is watching. The per-PLC payload is `PlcDetailResponse` (`src/Mbproxy/Admin/DebugDto.cs`):
| JSON path | Type | Meaning |
|---|---|---|
| `plc` | `PlcStatus?` | The standard per-PLC status row, or `null` if the PLC was removed by a hot-reload. |
| `debug.captureArmed` | `bool` | Whether a detail page currently has the capture armed. |
| `debug.tags[].address` | `int` | BCD tag PDU address. |
| `debug.tags[].width` | `int` | 16 or 32. |
| `debug.tags[].hasValue` | `bool` | `false` until the first observation since the capture was armed. |
| `debug.tags[].direction` | `string` | `"read"` (FC03/FC04) or `"write"` (FC06/FC16). |
| `debug.tags[].rawHex` | `string` | Raw PLC-side value as BCD nibbles — `0xLLLL` (16-bit) or `0xHHHHLLLL` (32-bit). |
| `debug.tags[].decodedValue` | `long` | Decoded binary integer the client reads/wrote. |
| `debug.tags[].updatedAtUtc` | `string?` | ISO-8601 time of the observation; `null` when no traffic yet. |
| `debug.tags[].ageSeconds` | `double?` | Seconds since the observation; `null` when no traffic yet. |
## How to Scrape It
+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 |
+137 -9
View File
@@ -1,9 +1,12 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using Mbproxy.Options;
using Mbproxy.Proxy;
namespace Mbproxy.Admin;
@@ -23,7 +26,10 @@ namespace Mbproxy.Admin;
/// <item><see cref="StopAsync"/> shuts down the current Kestrel app with a 2 s deadline.</item>
/// </list>
///
/// <para>Routes: exactly two — <c>GET /</c> (HTML) and <c>GET /status.json</c> (JSON).</para>
/// <para>Routes: <c>GET /</c> and <c>GET /plc/{name}</c> (embedded SPA shells),
/// <c>GET /assets/{path}</c> (embedded Bootstrap / SignalR / fonts / app JS+CSS),
/// <c>GET /status.json</c> (JSON snapshot for scrapers), and the SignalR hub at
/// <c>/hub/status</c> driving the live dashboard feed.</para>
///
/// <para>Registered as a plain singleton (not <see cref="IHostedService"/>) so
/// <see cref="Proxy.ProxyWorker"/> can drive its lifecycle explicitly. This is required to
@@ -35,12 +41,18 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
{
private readonly IOptionsMonitor<MbproxyOptions> _optionsMonitor;
private readonly StatusSnapshotBuilder _builder;
private readonly TagCaptureRegistry _captureRegistry;
private readonly PlcSubscriptionTracker _subscriptionTracker;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<AdminEndpointHost> _logger;
// The currently-running Kestrel app; null when stopped or when bind failed.
private WebApplication? _app;
// SignalR push loop for the live dashboard. Lifecycle is tied to _app: created
// when the Kestrel app starts, stopped (and captures disarmed) before it stops.
private StatusBroadcaster? _broadcaster;
// Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
private readonly SemaphoreSlim _lock = new(1, 1);
@@ -60,12 +72,16 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
public AdminEndpointHost(
IOptionsMonitor<MbproxyOptions> optionsMonitor,
StatusSnapshotBuilder builder,
TagCaptureRegistry captureRegistry,
PlcSubscriptionTracker subscriptionTracker,
ILoggerFactory loggerFactory)
{
_optionsMonitor = optionsMonitor;
_builder = builder;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<AdminEndpointHost>();
_optionsMonitor = optionsMonitor;
_builder = builder;
_captureRegistry = captureRegistry;
_subscriptionTracker = subscriptionTracker;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<AdminEndpointHost>();
}
public async Task StartAsync(CancellationToken cancellationToken)
@@ -170,14 +186,43 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
k.Listen(System.Net.IPAddress.Any, port);
});
// SignalR hub for the live dashboard. The inner WebApplication has its own
// DI container, so the singletons StatusHub depends on are re-registered here.
// camelCase payloads keep the wire shape identical to GET /status.json.
builder.Services
.AddSignalR()
.AddJsonProtocol(o =>
o.PayloadSerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.CamelCase);
builder.Services.AddSingleton(_captureRegistry);
builder.Services.AddSingleton(_subscriptionTracker);
var app = builder.Build();
// ── Routes ───────────────────────────────────────────────────────
app.MapGet("/", (HttpContext ctx) =>
// GET / — fleet dashboard SPA shell
// GET /plc/{name} — connection-detail SPA shell (name read client-side)
// GET /assets/... — embedded Bootstrap / SignalR / fonts / app JS+CSS
// GET /status.json — unchanged JSON snapshot for scrapers
// /hub/status — SignalR hub for the live feed
app.MapGet("/", (HttpContext ctx) => ServeHtmlShell(ctx, "index.html"));
app.MapGet("/plc/{name}", (string name, HttpContext ctx) => ServeHtmlShell(ctx, "plc.html"));
app.MapGet("/assets/{path}", (string path, HttpContext ctx) =>
{
var snapshot = _builder.Build();
string html = StatusHtmlRenderer.Render(snapshot);
return Results.Content(html, "text/html; charset=utf-8");
// Flat asset directory — a path segment with a slash or "." traversal
// can never match a resource, but reject it explicitly anyway.
if (path.Contains('/') || path.Contains('\\') || path.Contains(".."))
return Results.NotFound();
var bytes = ReadAssetCached(path);
if (bytes is null)
return Results.NotFound();
// Vendored assets are content-addressed by filename+version → immutable.
ctx.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Bytes(bytes, ContentTypeFor(path));
});
app.MapGet("/status.json", (HttpContext ctx) =>
@@ -187,9 +232,22 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
return Results.Content(json, "application/json");
});
app.MapHub<StatusHub>("/hub/status");
await app.StartAsync(ct).ConfigureAwait(false);
_app = app;
// Start the SignalR push loop now that the hub is reachable.
var hubContext = app.Services.GetRequiredService<IHubContext<StatusHub>>();
_broadcaster = new StatusBroadcaster(
new SignalRStatusPushSink(hubContext),
_builder,
_subscriptionTracker,
_captureRegistry,
_optionsMonitor,
_loggerFactory.CreateLogger<StatusBroadcaster>());
_broadcaster.Start();
LogAdminStarted(_logger, port);
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -205,6 +263,21 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
/// </summary>
private async Task StopCurrentAppAsync()
{
// Stop the SignalR push loop first — this also disarms every tag-value capture,
// so an AdminPort hot-reload that tears down this app never leaves one armed.
if (_broadcaster is { } broadcaster)
{
_broadcaster = null;
try
{
await broadcaster.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort.
}
}
if (_app is null) return;
var app = _app;
@@ -223,6 +296,55 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
await app.DisposeAsync().ConfigureAwait(false);
}
// ── Embedded asset serving ───────────────────────────────────────────────
private const string AssetResourcePrefix = "Mbproxy.Admin.wwwroot.";
// Embedded resources are immutable for the process lifetime; cache the decoded
// bytes so a 232 KB Bootstrap stylesheet is not re-materialised per request.
// `byte[]?` value so a known-miss is cached too. Static — shared across app rebuilds.
private static readonly ConcurrentDictionary<string, byte[]?> AssetCache = new(StringComparer.Ordinal);
/// <summary>
/// Serves an embedded HTML shell (<c>index.html</c> / <c>plc.html</c>) with a
/// <c>no-cache</c> header so a redeployed UI is picked up on the next load.
/// </summary>
private static IResult ServeHtmlShell(HttpContext ctx, string fileName)
{
var bytes = ReadAssetCached(fileName);
if (bytes is null)
return Results.NotFound();
ctx.Response.Headers.CacheControl = "no-cache";
return Results.Bytes(bytes, "text/html; charset=utf-8");
}
/// <summary>Reads an embedded <c>wwwroot</c> asset, caching the bytes (and misses).</summary>
private static byte[]? ReadAssetCached(string fileName)
=> AssetCache.GetOrAdd(fileName, static name =>
{
var asm = typeof(AdminEndpointHost).Assembly;
using var stream = asm.GetManifestResourceStream(AssetResourcePrefix + name);
if (stream is null)
return null;
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
});
private static string ContentTypeFor(string fileName)
=> Path.GetExtension(fileName).ToLowerInvariant() switch
{
".html" => "text/html; charset=utf-8",
".css" => "text/css; charset=utf-8",
".js" => "text/javascript; charset=utf-8",
".json" => "application/json; charset=utf-8",
".woff2" => "font/woff2",
".svg" => "image/svg+xml",
".ico" => "image/x-icon",
_ => "application/octet-stream",
};
// ── IAsyncDisposable ─────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
@@ -233,6 +355,12 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
_optionsChangeRegistration?.Dispose();
_optionsChangeRegistration = null;
if (_broadcaster is { } broadcaster)
{
_broadcaster = null;
await broadcaster.DisposeAsync().ConfigureAwait(false);
}
if (_app is { } app)
{
_app = null;
+61
View File
@@ -0,0 +1,61 @@
using System.Text.Json.Serialization;
namespace Mbproxy.Admin;
// ── Wire DTOs for the connection-detail debug view ───────────────────────────
// Pushed over SignalR to subscribers of a single PLC's detail page. camelCase via
// JsonKnownNamingPolicy.CamelCase on the source-gen context below.
/// <summary>
/// Per-PLC payload pushed to detail-page subscribers: the standard per-PLC status
/// row plus the real-time debug view's tag-value table. <see cref="Plc"/> is
/// <c>null</c> when the detail page is open for a PLC that is no longer in the
/// configuration (removed by a hot-reload) — the page renders a "no longer
/// configured" state.
/// </summary>
public sealed record PlcDetailResponse(
PlcStatus? Plc,
PlcDebugSnapshot Debug);
/// <summary>
/// Snapshot of one PLC's tag-value capture. <see cref="CaptureArmed"/> is <c>false</c>
/// when no detail page has armed the capture (e.g. a snapshot taken in the gap before
/// the SignalR subscription completes); the table is still returned so the view can
/// render one row per configured BCD tag.
/// </summary>
public sealed record PlcDebugSnapshot(
bool CaptureArmed,
IReadOnlyList<TagValueDto> Tags);
/// <summary>
/// One configured BCD tag's last observed value. <see cref="HasValue"/> is <c>false</c>
/// before any traffic has been captured for the tag since the capture was armed — the
/// view renders such rows as "no traffic yet".
/// </summary>
public sealed record TagValueDto(
int Address,
int Width,
bool HasValue,
/// <summary><c>"read"</c> (FC03/FC04) or <c>"write"</c> (FC06/FC16).</summary>
string Direction,
/// <summary>
/// Raw PLC-side value as BCD nibbles in hex — <c>0xLLLL</c> for a 16-bit tag,
/// <c>0xHHHHLLLL</c> (high word, low word) for a 32-bit tag. <c>"—"</c> when
/// <see cref="HasValue"/> is false.
/// </summary>
string RawHex,
/// <summary>Decoded binary integer the upstream client reads / wrote.</summary>
long DecodedValue,
/// <summary>ISO-8601 UTC time of the observation; <c>null</c> when no traffic yet.</summary>
string? UpdatedAtUtc,
/// <summary>Seconds since the observation; <c>null</c> when no traffic yet.</summary>
double? AgeSeconds);
// ── Source-generation context ─────────────────────────────────────────────────
[JsonSerializable(typeof(PlcDetailResponse))]
[JsonSerializable(typeof(PlcDebugSnapshot))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class DebugJsonContext : JsonSerializerContext;
@@ -0,0 +1,83 @@
namespace Mbproxy.Admin;
/// <summary>
/// Tracks which SignalR connections are subscribed to which PLC detail pages, so the
/// admin layer knows (a) when to arm / disarm a PLC's tag-value capture — capture is
/// armed only while at least one detail page is open — and (b) which PLC groups the
/// <see cref="StatusBroadcaster"/> needs to push to.
///
/// <para>Registered as a DI singleton; <see cref="StatusHub"/> instances (transient,
/// one per hub call) share this single tracker. All methods are thread-safe under a
/// single lock — subscription churn is low-frequency (one event per detail-page
/// open/close), so lock contention is a non-issue.</para>
/// </summary>
internal sealed class PlcSubscriptionTracker
{
private readonly object _gate = new();
// PLC name → number of connections currently subscribed to its detail page.
private readonly Dictionary<string, int> _plcCounts = new(StringComparer.Ordinal);
// Connection id → the set of PLC names that connection is subscribed to.
private readonly Dictionary<string, HashSet<string>> _byConnection = new(StringComparer.Ordinal);
/// <summary>
/// Records that <paramref name="connectionId"/> subscribed to <paramref name="plcName"/>.
/// Returns <c>true</c> when this is the PLC's first subscriber (count 0 → 1), the
/// signal to arm its capture. Returns <c>false</c> for a redundant re-subscribe.
/// </summary>
public bool Add(string connectionId, string plcName)
{
lock (_gate)
{
if (!_byConnection.TryGetValue(connectionId, out var set))
_byConnection[connectionId] = set = new HashSet<string>(StringComparer.Ordinal);
if (!set.Add(plcName))
return false; // this connection was already subscribed to this PLC
int count = _plcCounts.GetValueOrDefault(plcName);
_plcCounts[plcName] = count + 1;
return count == 0;
}
}
/// <summary>
/// Drops every subscription held by <paramref name="connectionId"/> (called from
/// <see cref="StatusHub.OnDisconnectedAsync"/>). Returns the PLC names whose
/// subscriber count fell to 0 — the signal to disarm their captures.
/// </summary>
public IReadOnlyList<string> RemoveConnection(string connectionId)
{
lock (_gate)
{
if (!_byConnection.Remove(connectionId, out var set))
return Array.Empty<string>();
var dropped = new List<string>();
foreach (var plcName in set)
{
int count = _plcCounts.GetValueOrDefault(plcName);
if (count <= 1)
{
_plcCounts.Remove(plcName);
dropped.Add(plcName);
}
else
{
_plcCounts[plcName] = count - 1;
}
}
return dropped;
}
}
/// <summary>PLC names that currently have at least one detail-page subscriber.</summary>
public IReadOnlyList<string> ActivePlcs()
{
lock (_gate)
{
return _plcCounts.Count == 0 ? Array.Empty<string>() : _plcCounts.Keys.ToArray();
}
}
}
@@ -0,0 +1,142 @@
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Options;
namespace Mbproxy.Admin;
/// <summary>
/// Background loop that drives the admin dashboard's live feed. Every
/// <see cref="MbproxyOptions.AdminPushIntervalMs"/> it builds a status snapshot and
/// pushes it through an <see cref="IStatusPushSink"/>:
/// <list type="bullet">
/// <item>the fleet snapshot to every fleet-dashboard subscriber;</item>
/// <item>a per-PLC detail payload (status row + tag-value capture) to each PLC that
/// currently has a detail-page subscriber — PLCs with no viewer are skipped.</item>
/// </list>
///
/// <para>Owned by <see cref="AdminEndpointHost"/>: <see cref="Start"/> is called once
/// the Kestrel app is up, <see cref="StopAsync"/> before it stops. <see cref="StopAsync"/>
/// disarms every tag-value capture, so an AdminPort hot-reload — which tears down the
/// SignalR host and all connections without firing per-connection disconnect cleanup
/// deterministically — never leaves a capture armed with no viewer.</para>
/// </summary>
internal sealed class StatusBroadcaster : IAsyncDisposable
{
private readonly IStatusPushSink _sink;
private readonly StatusSnapshotBuilder _builder;
private readonly PlcSubscriptionTracker _tracker;
private readonly TagCaptureRegistry _captureRegistry;
private readonly IOptionsMonitor<MbproxyOptions> _options;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private Task _loop = Task.CompletedTask;
public StatusBroadcaster(
IStatusPushSink sink,
StatusSnapshotBuilder builder,
PlcSubscriptionTracker tracker,
TagCaptureRegistry captureRegistry,
IOptionsMonitor<MbproxyOptions> options,
ILogger logger)
{
_sink = sink;
_builder = builder;
_tracker = tracker;
_captureRegistry = captureRegistry;
_options = options;
_logger = logger;
}
/// <summary>Starts the push loop. Idempotent only in the sense that it is called once.</summary>
public void Start() => _loop = Task.Run(() => LoopAsync(_cts.Token));
/// <summary>
/// Stops the push loop and disarms every tag-value capture.
/// </summary>
public async Task StopAsync()
{
if (!_cts.IsCancellationRequested)
await _cts.CancelAsync().ConfigureAwait(false);
try
{
await _loop.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected on cancellation.
}
_captureRegistry.DisarmAll();
}
/// <summary>One push cycle. Exposed internally so unit tests can drive it deterministically.</summary>
internal async Task PushOnceAsync(CancellationToken ct)
{
StatusResponse snapshot;
try
{
snapshot = _builder.Build();
}
catch (Exception ex)
{
_logger.LogError(ex, "StatusBroadcaster: failed to build status snapshot");
return;
}
try
{
await _sink.PushFleetAsync(snapshot, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "StatusBroadcaster: fleet push failed");
}
foreach (var plcName in _tracker.ActivePlcs())
{
try
{
var plc = snapshot.Plcs.FirstOrDefault(p => p.Name == plcName);
var debug = _builder.BuildDebug(plcName);
var detail = new PlcDetailResponse(plc, debug);
await _sink.PushPlcAsync(plcName, detail, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "StatusBroadcaster: detail push failed for PLC {Plc}", plcName);
}
}
}
private async Task LoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
// Re-read the interval each cycle so an AdminPushIntervalMs hot-reload
// takes effect without restarting the loop. Floored at 100 ms to avoid a
// pathologically tight loop if a bad value slips past validation.
int interval = Math.Max(100, _options.CurrentValue.AdminPushIntervalMs);
await Task.Delay(interval, ct).ConfigureAwait(false);
await PushOnceAsync(ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
catch (Exception ex)
{
_logger.LogError(ex, "StatusBroadcaster loop terminated unexpectedly");
}
}
public async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
_cts.Dispose();
}
}
@@ -1,249 +0,0 @@
using System.Text;
namespace Mbproxy.Admin;
/// <summary>
/// Renders a <see cref="StatusResponse"/> as a self-contained HTML page.
///
/// <para>Constraints (see <c>docs/Operations/StatusPage.md</c>):</para>
/// <list type="bullet">
/// <item>No external assets (CSS/JS/fonts/favicons) — firewalled networks only.</item>
/// <item><c>&lt;meta http-equiv="refresh" content="5"&gt;</c> for auto-refresh.</item>
/// <item>Page weight ≤ 50 KB for a 54-PLC fleet.</item>
/// <item>Listener state colour-coded: bound=green, recovering=orange, stopped=grey.</item>
/// <item>Connected clients rendered as compact <c>[remote (n PDUs)]</c> list (not nested table).</item>
/// </list>
/// </summary>
internal static class StatusHtmlRenderer
{
private const string Css = """
body{font-family:monospace;font-size:13px;margin:1em}
h1{font-size:1.1em;margin-bottom:.3em}
.meta{color:#555;margin-bottom:.8em;font-size:12px}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ccc;padding:3px 6px;white-space:nowrap}
th{background:#f0f0f0;text-align:left}
tr:nth-child(even)td{background:#fafafa}
.bound{color:green;font-weight:bold}
.recovering{color:darkorange;font-weight:bold}
.stopped{color:grey}
.err{font-size:11px;color:#a00}
.clients{font-size:11px;color:#333}
""";
/// <summary>
/// Renders the status page as a complete HTML document string.
/// May allocate; intended for the status-page read path only.
/// </summary>
public static string Render(StatusResponse status)
{
var sb = new StringBuilder(4096);
sb.Append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">");
sb.Append("<meta http-equiv=\"refresh\" content=\"5\">");
sb.Append("<title>mbproxy status</title>");
sb.Append("<style>").Append(Css).Append("</style>");
sb.Append("</head><body>");
// ── Header ────────────────────────────────────────────────────────────
sb.Append("<h1>mbproxy status</h1>");
sb.Append("<div class=\"meta\">");
sb.Append("Version: ").Append(HtmlEncode(status.Service.Version));
sb.Append(" &nbsp;|&nbsp; Uptime: ").Append(FormatUptime(status.Service.UptimeSeconds));
sb.Append(" &nbsp;|&nbsp; Listeners: ")
.Append(status.Listeners.Bound).Append('/').Append(status.Listeners.Configured)
.Append(" bound");
if (status.Service.ConfigLastReloadUtc.HasValue)
{
sb.Append(" &nbsp;|&nbsp; Last reload: ")
.Append(HtmlEncode(status.Service.ConfigLastReloadUtc.Value.ToString("yyyy-MM-dd HH:mm:ss") + "Z"));
}
sb.Append(" &nbsp;|&nbsp; Reloads: ").Append(status.Service.ConfigReloadCount);
if (status.Service.ConfigReloadRejectedCount > 0)
sb.Append(" (").Append(status.Service.ConfigReloadRejectedCount).Append(" rejected)");
sb.Append("</div>");
// ── PLC table ─────────────────────────────────────────────────────────
if (status.Plcs.Count == 0)
{
sb.Append("<p><em>No PLCs configured.</em></p>");
}
else
{
sb.Append("<table>");
sb.Append("<thead><tr>");
sb.Append("<th>Name</th><th>Host</th><th>Port</th><th>State</th>");
sb.Append("<th>Clients</th><th>PDUs fwd</th><th>FC03</th><th>FC04</th>");
sb.Append("<th>FC06</th><th>FC16</th><th>FC?</th><th>BCD slots</th>");
sb.Append("<th>Partial BCD</th><th>Invalid BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th><th>Ex ?</th>");
sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>");
// Multiplexer telemetry columns.
sb.Append("<th>In-flight</th><th>Max in-flight</th><th>TxId wraps</th>");
sb.Append("<th>Cascades</th><th>Queue</th>");
// Coalescing column. Single cell carries hit / (hit + miss) ratio as a
// percentage plus the raw hit count for context. Kept compact (one cell) to
// stay under the 50 KB page-weight budget.
sb.Append("<th>Coal</th>");
// Cache column. Single cell carries hit-ratio percent plus raw hit count;
// an em-dash when no cache-eligible reads have occurred. Page-weight budget
// assertion stays under 50 KB for the 54-PLC fleet.
sb.Append("<th>Cache</th>");
// Keepalive column — heartbeats sent, with failure / idle-disconnect counts
// shown only when non-zero.
sb.Append("<th>Keepalive</th>");
sb.Append("</tr></thead><tbody>");
foreach (var plc in status.Plcs)
{
sb.Append("<tr>");
sb.Append("<td>").Append(HtmlEncode(plc.Name)).Append("</td>");
sb.Append("<td>").Append(HtmlEncode(plc.Host)).Append("</td>");
sb.Append("<td>").Append(plc.ListenPort).Append("</td>");
// State cell with colour coding
string stateClass = plc.Listener.State switch
{
"bound" => "bound",
"recovering" => "recovering",
_ => "stopped",
};
sb.Append("<td><span class=\"").Append(stateClass).Append("\">")
.Append(HtmlEncode(plc.Listener.State)).Append("</span>");
if (plc.Listener.State == "recovering" && plc.Listener.LastBindError is { } err)
{
sb.Append("<br><span class=\"err\">")
.Append(HtmlEncode(err))
.Append(" (attempt ").Append(plc.Listener.RecoveryAttempts).Append(")")
.Append("</span>");
}
sb.Append("</td>");
// Connected clients
sb.Append("<td><span class=\"clients\">");
sb.Append(plc.Clients.Connected);
if (plc.Clients.RemoteEndpoints.Count > 0)
{
sb.Append("<br>");
bool first = true;
foreach (var c in plc.Clients.RemoteEndpoints)
{
if (!first) sb.Append(", ");
sb.Append(HtmlEncode(c.Remote))
.Append(" (").Append(c.PdusForwarded).Append(')');
first = false;
}
}
sb.Append("</span></td>");
// Counter cells
sb.Append("<td>").Append(plc.Pdus.Forwarded).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc03).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc04).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc06).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc16).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Other).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.RewrittenSlots).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.PartialBcdWarnings).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.InvalidBcdWarnings).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code01).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code02).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code03).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code04).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.CodeOther).Append("</td>");
sb.Append("<td>").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamIn).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamOut).Append("</td>");
// Multiplexer telemetry cells.
sb.Append("<td>").Append(plc.Backend.InFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.MaxInFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.TxIdWraps).Append("</td>");
sb.Append("<td>").Append(plc.Backend.DisconnectCascades).Append("</td>");
sb.Append("<td>").Append(plc.Backend.QueueDepth).Append("</td>");
// Coalescing ratio cell — "<pct>% (<hit>)". When no coalesced reads have
// been seen, render an em-dash to keep the cell narrow.
long coalHit = plc.Backend.CoalescedHitCount;
long coalMiss = plc.Backend.CoalescedMissCount;
sb.Append("<td>");
if (coalHit + coalMiss == 0)
{
sb.Append("&mdash;");
}
else
{
int pct = (int)Math.Round(100.0 * coalHit / (coalHit + coalMiss));
sb.Append(pct).Append("% (").Append(coalHit).Append(')');
}
sb.Append("</td>");
// Cache ratio cell — same pattern as coalescing.
long cacheHit = plc.Backend.CacheHitCount;
long cacheMiss = plc.Backend.CacheMissCount;
sb.Append("<td>");
if (cacheHit + cacheMiss == 0)
{
sb.Append("&mdash;");
}
else
{
int pct = (int)Math.Round(100.0 * cacheHit / (cacheHit + cacheMiss));
sb.Append(pct).Append("% (").Append(cacheHit).Append(')');
}
sb.Append("</td>");
// Keepalive cell — heartbeats sent; failures + idle-disconnects appended
// only when non-zero to keep the cell narrow.
long hbSent = plc.Backend.BackendHeartbeatsSent;
long hbFailed = plc.Backend.BackendHeartbeatsFailed;
long hbIdle = plc.Backend.BackendIdleDisconnects;
sb.Append("<td>");
if (hbSent == 0 && hbFailed == 0 && hbIdle == 0)
{
sb.Append("&mdash;");
}
else
{
sb.Append(hbSent);
if (hbFailed > 0 || hbIdle > 0)
sb.Append(" (fail ").Append(hbFailed)
.Append(", idle-disc ").Append(hbIdle).Append(')');
}
sb.Append("</td>");
sb.Append("</tr>");
}
sb.Append("</tbody></table>");
}
sb.Append("</body></html>");
return sb.ToString();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatUptime(long seconds)
{
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes:D2}m {ts.Seconds:D2}s";
if (ts.TotalMinutes >= 1)
return $"{ts.Minutes}m {ts.Seconds:D2}s";
return $"{seconds}s";
}
private static string HtmlEncode(string s)
{
// Fast path: no special chars.
if (!ContainsHtmlSpecial(s)) return s;
return s
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;");
}
private static bool ContainsHtmlSpecial(string s)
{
foreach (char c in s)
if (c is '&' or '<' or '>' or '"') return true;
return false;
}
}
+67
View File
@@ -0,0 +1,67 @@
using Mbproxy.Proxy;
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Admin;
/// <summary>
/// SignalR hub backing the live admin dashboard. Two subscription scopes:
/// <list type="bullet">
/// <item><see cref="SubscribeFleet"/> — the fleet dashboard (<c>GET /</c>) joins the
/// <see cref="FleetGroup"/> group and receives a <c>"fleet"</c> message every
/// push tick.</item>
/// <item><see cref="SubscribePlc"/> — a connection-detail page (<c>GET /plc/{name}</c>)
/// joins <see cref="PlcGroup"/> and receives a <c>"plc"</c> message. The first
/// subscriber to a PLC arms that PLC's tag-value capture; the last to leave
/// disarms it (on-demand capture).</item>
/// </list>
///
/// <para>The hub itself is transient (one instance per call). Cross-call state — the
/// subscriber counts that drive capture arming — lives in the singleton
/// <see cref="PlcSubscriptionTracker"/>. The actual pushes are issued by
/// <see cref="StatusBroadcaster"/>, not the hub.</para>
/// </summary>
internal sealed class StatusHub : Hub
{
/// <summary>SignalR group name for fleet-dashboard subscribers.</summary>
public const string FleetGroup = "fleet";
/// <summary>SignalR group name for a single PLC's detail-page subscribers.</summary>
public static string PlcGroup(string plcName) => "plc:" + plcName;
private readonly PlcSubscriptionTracker _tracker;
private readonly TagCaptureRegistry _captureRegistry;
public StatusHub(PlcSubscriptionTracker tracker, TagCaptureRegistry captureRegistry)
{
_tracker = tracker;
_captureRegistry = captureRegistry;
}
/// <summary>Subscribes the calling connection to fleet-wide status pushes.</summary>
public Task SubscribeFleet()
=> Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
/// <summary>
/// Subscribes the calling connection to one PLC's detail pushes and arms that PLC's
/// tag-value capture if this is its first viewer.
/// </summary>
public async Task SubscribePlc(string plcName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, PlcGroup(plcName)).ConfigureAwait(false);
if (_tracker.Add(Context.ConnectionId, plcName))
_captureRegistry.Arm(plcName); // no-op for an unknown PLC name
}
/// <summary>
/// On disconnect, drops every subscription the connection held and disarms the
/// capture of any PLC whose last viewer just left.
/// </summary>
public override Task OnDisconnectedAsync(Exception? exception)
{
foreach (var plcName in _tracker.RemoveConnection(Context.ConnectionId))
_captureRegistry.Disarm(plcName);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Admin;
/// <summary>
/// The outbound side of the admin live feed. Defined as an interface so
/// <see cref="StatusBroadcaster"/>'s loop logic (snapshot building, group selection,
/// disarm-on-stop) is unit-testable without standing up a SignalR host — tests inject
/// a recording fake; production uses <see cref="SignalRStatusPushSink"/>.
/// </summary>
internal interface IStatusPushSink
{
/// <summary>Pushes a fleet snapshot to every fleet-dashboard subscriber.</summary>
Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct);
/// <summary>Pushes one PLC's detail payload to that PLC's detail-page subscribers.</summary>
Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct);
}
/// <summary>
/// Production <see cref="IStatusPushSink"/> — forwards pushes onto the SignalR
/// <see cref="StatusHub"/> groups via <see cref="IHubContext{THub}"/>.
/// </summary>
internal sealed class SignalRStatusPushSink : IStatusPushSink
{
private readonly IHubContext<StatusHub> _hub;
public SignalRStatusPushSink(IHubContext<StatusHub> hub) => _hub = hub;
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
=> _hub.Clients.Group(StatusHub.FleetGroup).SendAsync("fleet", snapshot, ct);
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
=> _hub.Clients.Group(StatusHub.PlcGroup(plcName)).SendAsync("plc", detail, ct);
}
@@ -19,17 +19,60 @@ internal sealed class StatusSnapshotBuilder
private readonly ServiceCounters _serviceCounters;
private readonly AssemblyVersionAccessor _version;
private readonly ProxyWorker _proxyWorker;
private readonly TagCaptureRegistry _captureRegistry;
public StatusSnapshotBuilder(
IOptionsMonitor<MbproxyOptions> options,
ServiceCounters serviceCounters,
AssemblyVersionAccessor version,
ProxyWorker proxyWorker)
ProxyWorker proxyWorker,
TagCaptureRegistry captureRegistry)
{
_options = options;
_options = options;
_serviceCounters = serviceCounters;
_version = version;
_proxyWorker = proxyWorker;
_version = version;
_proxyWorker = proxyWorker;
_captureRegistry = captureRegistry;
}
/// <summary>
/// Builds the connection-detail debug snapshot for one PLC: the last value observed
/// for every configured BCD tag. Returns an empty, disarmed snapshot when
/// <paramref name="plcName"/> is unknown (e.g. a detail page open for a PLC removed
/// by hot-reload).
/// </summary>
public PlcDebugSnapshot BuildDebug(string plcName)
{
if (!_captureRegistry.TryGet(plcName, out var capture))
return new PlcDebugSnapshot(CaptureArmed: false, Tags: Array.Empty<TagValueDto>());
var now = DateTimeOffset.UtcNow;
var tags = capture.Snapshot()
.Select(o => ToTagDto(o, now))
.ToList();
return new PlcDebugSnapshot(capture.IsArmed, tags);
}
private static TagValueDto ToTagDto(TagValueObservation o, DateTimeOffset now)
{
bool hasValue = o.UpdatedAtUtc.HasValue;
string rawHex = !hasValue
? "—"
: o.Width == 32
? $"0x{o.RawHigh:X4}{o.RawLow:X4}"
: $"0x{o.RawLow:X4}";
return new TagValueDto(
Address: o.Address,
Width: o.Width,
HasValue: hasValue,
Direction: o.Direction == CaptureDirection.Write ? "write" : "read",
RawHex: rawHex,
DecodedValue: o.DecodedValue,
UpdatedAtUtc: o.UpdatedAtUtc?.ToString("o"),
AgeSeconds: o.UpdatedAtUtc is { } at ? (now - at).TotalSeconds : null);
}
/// <summary>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
/* ============================================================================
Fleet dashboard view-specific styling (Phase 4).
Shared tokens and chrome live in theme.css.
========================================================================= */
/* ── Aggregate strip ─────────────────────────────────────────────────────── */
.agg-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) {
.agg-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 620px) {
.agg-grid { grid-template-columns: repeat(2, 1fr); }
}
.agg-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 0.7rem 0.9rem;
}
.agg-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.agg-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.agg-sub {
font-size: 0.85rem;
font-weight: 400;
color: var(--ink-faint);
}
/* highlight the problem cards when non-zero */
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
.agg-card.alert .agg-value { color: var(--bad); }
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
.agg-card.caution .agg-value { color: #b56a00; }
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
.toolbar .spacer { flex: 1; }
.tb-search { max-width: 280px; }
.tb-state { max-width: 150px; }
.tb-check {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
user-select: none;
}
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
/* ── KPI table ───────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
.kpi-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.kpi-table th,
.kpi-table td {
padding: 0.45rem 0.8rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--rule);
}
.kpi-table th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
position: sticky;
top: 0;
}
.kpi-table th.num,
.kpi-table td.num { text-align: right; font-family: var(--mono); }
.kpi-table th.sortable { cursor: pointer; user-select: none; }
.kpi-table th.sortable:hover { color: var(--ink); }
.kpi-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
.kpi-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
.kpi-table tbody tr { cursor: pointer; transition: background 0.08s; }
.kpi-table tbody tr:hover { background: #f3f6fd; }
.kpi-table tbody tr:last-child td { border-bottom: none; }
.kpi-table .plc-name { font-weight: 600; }
.kpi-table .plc-host { color: var(--ink-soft); font-family: var(--mono); font-size: 0.8rem; }
.empty-row {
text-align: center !important;
color: var(--ink-faint);
padding: 1.6rem !important;
font-style: italic;
}
/* faint divider between value and unit in ratio cells */
.ratio-sub { color: var(--ink-faint); font-size: 0.76rem; }
@@ -0,0 +1,272 @@
/* ============================================================================
Fleet dashboard live view over the SignalR /hub/status feed (Phase 4).
Vanilla JS, no framework. Subscribes to the "fleet" group and re-renders the
aggregate strip + filterable/sortable PLC table on every push.
========================================================================= */
'use strict';
(function () {
// ── State ──────────────────────────────────────────────────────────────
let latest = null; // last StatusResponse
const prevPdu = new Map(); // plc name → { forwarded, t } for rate calc
const rateByName = new Map(); // plc name → PDU/s
const filter = { search: '', state: '', problemsOnly: false };
const sort = { key: 'name', dir: 1 };
// ── Helpers ────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
function num(n) {
if (n === null || n === undefined) return '—';
return n.toLocaleString('en-US');
}
function ratio(hit, miss) {
const total = (hit || 0) + (miss || 0);
if (total === 0) return null;
return Math.round((100 * hit) / total);
}
function exceptionTotal(b) {
const e = b.exceptionsByCode || {};
return (e.code01 || 0) + (e.code02 || 0) + (e.code03 || 0) +
(e.code04 || 0) + (e.codeOther || 0);
}
function isProblem(plc) {
return plc.listener.state === 'recovering'
|| exceptionTotal(plc.backend) > 0
|| (plc.backend.backendHeartbeatsFailed || 0) > 0
|| (plc.backend.connectsFailed || 0) > 0;
}
function stateChip(state) {
const cls = state === 'bound' ? 'chip-ok'
: state === 'recovering' ? 'chip-warn'
: 'chip-idle';
return `<span class="chip ${cls}">${state}</span>`;
}
function ratioCell(hit, miss) {
const r = ratio(hit, miss);
if (r === null) return '<span class="s-idle">—</span>';
return `${r}%<span class="ratio-sub"> ${num(hit)}</span>`;
}
function keepaliveCell(b) {
const sent = b.backendHeartbeatsSent || 0;
const failed = b.backendHeartbeatsFailed || 0;
if (sent === 0 && failed === 0) return '<span class="s-idle">—</span>';
if (failed > 0) return `<span class="s-bad">${num(sent)} · ${failed} fail</span>`;
return num(sent);
}
// ── Rate computation ───────────────────────────────────────────────────
function updateRates(snapshot) {
const now = performance.now();
for (const plc of snapshot.plcs) {
const cur = plc.pdus.forwarded;
const prev = prevPdu.get(plc.name);
if (prev && now > prev.t) {
const dt = (now - prev.t) / 1000;
const rate = Math.max(0, (cur - prev.forwarded) / dt);
rateByName.set(plc.name, rate);
}
prevPdu.set(plc.name, { forwarded: cur, t: now });
}
}
function rateOf(name) {
return rateByName.has(name) ? rateByName.get(name) : null;
}
// ── Aggregate strip ────────────────────────────────────────────────────
function renderAggregates(s) {
$('ag-bound').textContent = s.listeners.bound;
$('ag-configured').textContent = '/ ' + s.listeners.configured;
let clients = 0, exceptions = 0, recovering = 0;
let cacheHit = 0, cacheMiss = 0, fleetRate = 0;
for (const plc of s.plcs) {
clients += plc.clients.connected;
exceptions += exceptionTotal(plc.backend);
if (plc.listener.state === 'recovering') recovering++;
cacheHit += plc.backend.cacheHitCount || 0;
cacheMiss += plc.backend.cacheMissCount || 0;
const r = rateOf(plc.name);
if (r !== null) fleetRate += r;
}
$('ag-clients').textContent = num(clients);
$('ag-pdurate').textContent = rateByName.size ? Math.round(fleetRate).toLocaleString('en-US') : '—';
$('ag-recovering').textContent = recovering;
$('ag-exceptions').textContent = num(exceptions);
const cr = ratio(cacheHit, cacheMiss);
$('ag-cache').textContent = cr === null ? '—' : cr + '%';
$('ag-bound').className = s.listeners.bound < s.listeners.configured ? 's-warn' : '';
$('ag-recovering-card').className = 'agg-card' + (recovering > 0 ? ' caution' : '');
$('ag-exceptions-card').className = 'agg-card' + (exceptions > 0 ? ' alert' : '');
}
// ── Table ──────────────────────────────────────────────────────────────
function sortKey(plc, key) {
switch (key) {
case 'name': return plc.name.toLowerCase();
case 'host': return plc.host.toLowerCase();
case 'state': return plc.listener.state;
case 'clients': return plc.clients.connected;
case 'pdurate': return rateOf(plc.name) || 0;
case 'rtt': return plc.backend.lastRoundTripMs || 0;
case 'exceptions': return exceptionTotal(plc.backend);
case 'coalesce': return ratio(plc.backend.coalescedHitCount, plc.backend.coalescedMissCount) || -1;
case 'cache': return ratio(plc.backend.cacheHitCount, plc.backend.cacheMissCount) || -1;
case 'keepalive': return plc.backend.backendHeartbeatsSent || 0;
default: return plc.name;
}
}
function visiblePlcs(s) {
let rows = s.plcs.slice();
const q = filter.search.trim().toLowerCase();
if (q) rows = rows.filter(p => p.name.toLowerCase().includes(q) || p.host.toLowerCase().includes(q));
if (filter.state) rows = rows.filter(p => p.listener.state === filter.state);
if (filter.problemsOnly) rows = rows.filter(isProblem);
rows.sort((a, b) => {
const ka = sortKey(a, sort.key), kb = sortKey(b, sort.key);
if (ka < kb) return -1 * sort.dir;
if (ka > kb) return 1 * sort.dir;
return a.name.localeCompare(b.name);
});
return rows;
}
function renderTable(s) {
const rows = visiblePlcs(s);
const tbody = $('plc-rows');
$('row-count').textContent = `${rows.length} of ${s.plcs.length}`;
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-row">No PLCs match the current filter.</td></tr>';
return;
}
tbody.innerHTML = rows.map(plc => {
const b = plc.backend;
const rate = rateOf(plc.name);
const excn = exceptionTotal(b);
const recovHint = plc.listener.state === 'recovering' && plc.listener.lastBindError
? ` title="${escapeAttr(plc.listener.lastBindError)}"` : '';
return `<tr data-name="${escapeAttr(plc.name)}"${recovHint}>
<td class="plc-name">${escapeHtml(plc.name)}</td>
<td class="plc-host">${escapeHtml(plc.host)}:${plc.listenPort}</td>
<td>${stateChip(plc.listener.state)}</td>
<td class="num">${plc.clients.connected}</td>
<td class="num">${rate === null ? '—' : Math.round(rate)}</td>
<td class="num">${(b.lastRoundTripMs || 0).toFixed(1)}</td>
<td class="num ${excn > 0 ? 's-bad' : ''}">${excn}</td>
<td class="num">${ratioCell(b.coalescedHitCount, b.coalescedMissCount)}</td>
<td class="num">${ratioCell(b.cacheHitCount, b.cacheMissCount)}</td>
<td class="num">${keepaliveCell(b)}</td>
</tr>`;
}).join('');
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;');
}
// ── Render orchestration ───────────────────────────────────────────────
function render() {
if (!latest) return;
renderAggregates(latest);
renderTable(latest);
}
function onSnapshot(s) {
latest = s;
updateRates(s);
const up = s.service.uptimeSeconds;
$('svc-meta').textContent =
`v${s.service.version} · up ${formatUptime(up)} · reloads ${s.service.configReloadCount}`;
render();
}
function formatUptime(sec) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// ── Connection-state pill ──────────────────────────────────────────────
function setConn(state, text) {
const pill = $('conn');
pill.dataset.state = state;
$('conn-text').textContent = text || state;
}
// ── Wire up filter / sort controls ─────────────────────────────────────
function wireControls() {
$('f-search').addEventListener('input', e => { filter.search = e.target.value; render(); });
$('f-state').addEventListener('change', e => { filter.state = e.target.value; render(); });
$('f-problems').addEventListener('change', e => { filter.problemsOnly = e.target.checked; render(); });
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sort.key === key) { sort.dir *= -1; }
else { sort.key = key; sort.dir = 1; }
document.querySelectorAll('th.sortable').forEach(h => h.classList.remove('sorted-asc', 'sorted-desc'));
th.classList.add(sort.dir === 1 ? 'sorted-asc' : 'sorted-desc');
render();
});
});
$('plc-rows').addEventListener('click', e => {
const tr = e.target.closest('tr[data-name]');
if (!tr) return;
window.open('/plc/' + encodeURIComponent(tr.dataset.name), '_blank');
});
}
// ── SignalR ────────────────────────────────────────────────────────────
function connect() {
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hub/status')
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
.build();
connection.on('fleet', onSnapshot);
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribeFleet'); });
connection.onclose(() => setConn('disconnected', 'disconnected'));
async function start() {
try {
setConn('connecting', 'connecting');
await connection.start();
await connection.invoke('SubscribeFleet');
setConn('connected');
} catch {
setConn('disconnected', 'retrying');
setTimeout(start, 3000);
}
}
start();
}
// ── Boot ───────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
wireControls();
document.querySelector('th[data-sort="name"]').classList.add('sorted-asc');
connect();
});
})();
@@ -0,0 +1,139 @@
/* ============================================================================
Connection-detail page view-specific styling (Phase 5).
Shared tokens and chrome live in theme.css.
========================================================================= */
/* ── PLC identity header ─────────────────────────────────────────────────── */
.plc-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
}
.plc-title { font-size: 1.35rem; font-weight: 600; line-height: 1.1; }
.plc-sub {
margin-top: 0.2rem;
font-family: var(--mono);
font-size: 0.82rem;
color: var(--ink-soft);
}
.plc-head-state .chip { font-size: 0.78rem; padding: 0.25rem 0.65rem; }
.notice {
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
color: #b56a00;
background: var(--warn-bg);
border-color: #efd6a6;
}
/* ── Grouped counter cards ───────────────────────────────────────────────── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 0.85rem;
margin-bottom: 1rem;
}
.metric-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
overflow: hidden;
}
.metric-card .panel-head { margin: 0; }
.kv {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
.kv:nth-child(even) { background: #fbfbf9; }
.kv .k { color: var(--ink-soft); }
.kv .v { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.kv .v.warn { color: var(--warn); }
.kv .v.bad { color: var(--bad); }
.kv .v.ok { color: var(--ok); }
/* client list inside the Clients card */
.client-line {
padding: 0.3rem 0.9rem;
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
border-top: 1px dashed var(--rule);
}
.client-line .pdu { color: var(--ink-faint); }
/* ── Debug view ──────────────────────────────────────────────────────────── */
.debug-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.capture-state {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.12rem 0.5rem;
border-radius: 4px;
}
.capture-state[data-armed="true"] { color: var(--ok); background: var(--ok-bg); }
.capture-state[data-armed="false"] { color: var(--ink-soft); background: var(--idle-bg); }
.table-wrap { overflow-x: auto; }
.debug-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.debug-table th,
.debug-table td {
padding: 0.45rem 0.9rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--rule);
}
.debug-table th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
}
.debug-table th.num,
.debug-table td.num { text-align: right; font-family: var(--mono); }
.debug-table tbody tr:last-child td { border-bottom: none; }
.debug-table .raw { color: var(--accent-deep); }
.debug-table .dec { font-weight: 600; }
.debug-table tr.stale td { color: var(--ink-faint); }
.debug-table tr.no-traffic td { color: var(--ink-faint); font-style: italic; }
.dir-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
.dir-write { color: #8a5a00; background: var(--warn-bg); }
.empty-row {
text-align: center !important;
color: var(--ink-faint);
padding: 1.6rem !important;
font-style: italic;
}
+251
View File
@@ -0,0 +1,251 @@
/* ============================================================================
Connection-detail page live per-PLC view over /hub/status (Phase 5).
Subscribes to one PLC's "plc" group; renders grouped counter cards and the
real-time debug view (per-tag PLC-side raw BCD vs client-side decoded value).
========================================================================= */
'use strict';
(function () {
// ── PLC name from the URL path: /plc/{name} ────────────────────────────
const plcName = decodeURIComponent(location.pathname.replace(/^\/plc\//, ''));
const $ = (id) => document.getElementById(id);
document.title = `mbproxy — ${plcName}`;
$('crumb-name').textContent = plcName;
$('plc-name').textContent = plcName;
// ── Helpers ────────────────────────────────────────────────────────────
function num(n) {
if (n === null || n === undefined) return '—';
return n.toLocaleString('en-US');
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
function hex4(n) { return '0x' + (n & 0xffff).toString(16).toUpperCase().padStart(4, '0'); }
function formatAge(sec) {
if (sec === null || sec === undefined) return '—';
if (sec < 1) return 'now';
if (sec < 60) return sec.toFixed(1) + 's';
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m ${Math.floor(sec % 60)}s`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
function shortTime(iso) {
try { return new Date(iso).toLocaleTimeString('en-US', { hour12: false }); }
catch { return iso; }
}
// ── Card renderer ──────────────────────────────────────────────────────
// rows: array of [key, value, optionalClass]; extra: raw HTML appended.
function card(title, rows, extra) {
const body = rows.map(([k, v, cls]) =>
`<div class="kv"><span class="k">${escapeHtml(k)}</span>` +
`<span class="v ${cls || ''}">${v}</span></div>`).join('');
return `<div class="metric-card">
<div class="panel-head">${escapeHtml(title)}</div>
${body}${extra || ''}
</div>`;
}
function stateChip(state) {
const cls = state === 'bound' ? 'chip-ok'
: state === 'recovering' ? 'chip-warn'
: 'chip-idle';
return `<span class="chip ${cls}">${state}</span>`;
}
// ── Render: PLC counters ───────────────────────────────────────────────
function renderPlc(plc) {
$('notice').hidden = true;
$('cards').hidden = false;
$('plc-sub').textContent = `${plc.host}:${plc.listenPort}`;
$('plc-state').innerHTML = stateChip(plc.listener.state);
const b = plc.backend, p = plc.pdus, l = plc.listener;
const e = b.exceptionsByCode || {};
const excnTotal = (e.code01 || 0) + (e.code02 || 0) + (e.code03 || 0) +
(e.code04 || 0) + (e.codeOther || 0);
const cards = [];
// Listener
cards.push(card('Listener', [
['State', stateChip(l.state)],
['Recovery attempts', num(l.recoveryAttempts), l.recoveryAttempts > 0 ? 'warn' : ''],
['Last bind error', l.lastBindError ? escapeHtml(l.lastBindError) : '—',
l.lastBindError ? 'bad' : ''],
]));
// Clients
const clientLines = (plc.clients.remoteEndpoints || []).map(c =>
`<div class="client-line">${escapeHtml(c.remote)}` +
`<span class="pdu"> · ${num(c.pdusForwarded)} PDUs · since ${shortTime(c.connectedAtUtc)}</span></div>`
).join('');
cards.push(card('Clients', [
['Connected', num(plc.clients.connected)],
], clientLines));
// PDU traffic
cards.push(card('PDU traffic', [
['Forwarded', num(p.forwarded)],
['FC03 read HR', num(p.byFc.fc03)],
['FC04 read IR', num(p.byFc.fc04)],
['FC06 write single', num(p.byFc.fc06)],
['FC16 write multiple', num(p.byFc.fc16)],
['Other FCs', num(p.byFc.other)],
['BCD slots rewritten', num(p.rewrittenSlots)],
['Partial-BCD warnings', num(p.partialBcdWarnings), p.partialBcdWarnings > 0 ? 'warn' : ''],
['Invalid-BCD warnings', num(p.invalidBcdWarnings), p.invalidBcdWarnings > 0 ? 'warn' : ''],
]));
// Backend health
cards.push(card('Backend health', [
['Connects ok', num(b.connectsSuccess)],
['Connects failed', num(b.connectsFailed), b.connectsFailed > 0 ? 'bad' : ''],
['Round-trip (EWMA)', (b.lastRoundTripMs || 0).toFixed(1) + ' ms'],
['Exceptions total', num(excnTotal), excnTotal > 0 ? 'bad' : ''],
['· 01 illegal function', num(e.code01)],
['· 02 illegal address', num(e.code02)],
['· 03 illegal value', num(e.code03)],
['· 04 device failure', num(e.code04)],
['· other', num(e.codeOther)],
]));
// Multiplexer
cards.push(card('Multiplexer', [
['In flight', num(b.inFlight)],
['Max in flight', num(b.maxInFlight)],
['TxId wraps', num(b.txIdWraps)],
['Disconnect cascades', num(b.disconnectCascades), b.disconnectCascades > 0 ? 'warn' : ''],
['Queue depth', num(b.queueDepth), b.queueDepth > 0 ? 'warn' : ''],
]));
// Coalescing
cards.push(card('Read coalescing', [
['Hits', num(b.coalescedHitCount)],
['Misses', num(b.coalescedMissCount)],
['Hit ratio', ratioText(b.coalescedHitCount, b.coalescedMissCount)],
['Resp. to dead upstream', num(b.coalescedResponseToDeadUpstream)],
]));
// Cache
cards.push(card('Response cache', [
['Hits', num(b.cacheHitCount)],
['Misses', num(b.cacheMissCount)],
['Hit ratio', ratioText(b.cacheHitCount, b.cacheMissCount)],
['Invalidations', num(b.cacheInvalidations)],
['Entries', num(b.cacheEntryCount)],
['Approx. bytes', num(b.cacheBytes)],
]));
// Keepalive
cards.push(card('Keepalive', [
['Heartbeats sent', num(b.backendHeartbeatsSent)],
['Heartbeats failed', num(b.backendHeartbeatsFailed), b.backendHeartbeatsFailed > 0 ? 'bad' : ''],
['Idle disconnects', num(b.backendIdleDisconnects), b.backendIdleDisconnects > 0 ? 'warn' : ''],
]));
// Bytes
cards.push(card('Bytes', [
['Upstream in', num(plc.bytes.upstreamIn)],
['Upstream out', num(plc.bytes.upstreamOut)],
]));
$('cards').innerHTML = cards.join('');
}
function ratioText(hit, miss) {
const total = (hit || 0) + (miss || 0);
if (total === 0) return '—';
return Math.round((100 * hit) / total) + '%';
}
// ── Render: PLC removed by hot-reload ──────────────────────────────────
function renderMissing() {
$('notice').hidden = false;
$('cards').hidden = true;
$('cards').innerHTML = '';
$('plc-sub').textContent = 'not configured';
$('plc-state').innerHTML = '<span class="chip chip-idle">removed</span>';
}
// ── Render: debug view ─────────────────────────────────────────────────
function renderDebug(debug) {
const cap = $('capture-state');
cap.dataset.armed = String(debug.captureArmed);
cap.textContent = debug.captureArmed ? 'capture armed' : 'capture idle';
const tbody = $('debug-rows');
if (!debug.tags || debug.tags.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-row">No BCD tags configured for this PLC.</td></tr>';
return;
}
tbody.innerHTML = debug.tags.map(t => {
if (!t.hasValue) {
return `<tr class="no-traffic">
<td>${t.address} <span class="ratio-sub">${hex4(t.address)}</span></td>
<td>${t.width}-bit</td>
<td colspan="3">no traffic yet</td>
<td class="num"></td>
</tr>`;
}
const dirCls = t.direction === 'write' ? 'dir-write' : 'dir-read';
const stale = (t.ageSeconds || 0) > 30 ? ' stale' : '';
return `<tr class="${stale.trim()}">
<td>${t.address} <span class="ratio-sub">${hex4(t.address)}</span></td>
<td>${t.width}-bit</td>
<td><span class="dir-tag ${dirCls}">${t.direction}</span></td>
<td class="num raw">${escapeHtml(t.rawHex)}</td>
<td class="num dec">${num(t.decodedValue)}</td>
<td class="num">${formatAge(t.ageSeconds)}</td>
</tr>`;
}).join('');
}
// ── Snapshot handler ───────────────────────────────────────────────────
function onDetail(detail) {
if (detail.plc) renderPlc(detail.plc);
else renderMissing();
renderDebug(detail.debug || { captureArmed: false, tags: [] });
}
// ── Connection-state pill ──────────────────────────────────────────────
function setConn(state, text) {
$('conn').dataset.state = state;
$('conn-text').textContent = text || state;
}
// ── SignalR ────────────────────────────────────────────────────────────
function connect() {
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hub/status')
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
.build();
connection.on('plc', onDetail);
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribePlc', plcName); });
connection.onclose(() => setConn('disconnected', 'disconnected'));
async function start() {
try {
setConn('connecting', 'connecting');
await connection.start();
await connection.invoke('SubscribePlc', plcName);
setConn('connected');
} catch {
setConn('disconnected', 'retrying');
setTimeout(start, 3000);
}
}
start();
}
document.addEventListener('DOMContentLoaded', connect);
})();
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mbproxy — fleet</title>
<link rel="stylesheet" href="/assets/bootstrap.min.css">
<link rel="stylesheet" href="/assets/theme.css">
<link rel="stylesheet" href="/assets/dashboard.css">
</head>
<body>
<header class="app-bar">
<span class="brand"><span class="mark">&#9646;</span> mbproxy</span>
<span class="crumb">fleet</span>
<span class="spacer"></span>
<span class="meta" id="svc-meta">&mdash;</span>
<span class="conn-pill" id="conn" data-state="connecting">
<span class="dot"></span><span id="conn-text">connecting</span>
</span>
</header>
<main class="page">
<!-- Aggregate fleet health -->
<section class="agg-grid rise" id="agg" style="animation-delay:.02s">
<div class="agg-card">
<div class="agg-label">Listeners</div>
<div class="agg-value"><span id="ag-bound">&mdash;</span><span class="agg-sub" id="ag-configured"></span></div>
</div>
<div class="agg-card">
<div class="agg-label">Clients</div>
<div class="agg-value" id="ag-clients">&mdash;</div>
</div>
<div class="agg-card">
<div class="agg-label">PDU / s</div>
<div class="agg-value numeric" id="ag-pdurate">&mdash;</div>
</div>
<div class="agg-card" id="ag-recovering-card">
<div class="agg-label">Recovering</div>
<div class="agg-value" id="ag-recovering">&mdash;</div>
</div>
<div class="agg-card" id="ag-exceptions-card">
<div class="agg-label">Backend exceptions</div>
<div class="agg-value numeric" id="ag-exceptions">&mdash;</div>
</div>
<div class="agg-card">
<div class="agg-label">Cache hit</div>
<div class="agg-value numeric" id="ag-cache">&mdash;</div>
</div>
</section>
<!-- PLC table -->
<section class="panel rise" style="animation-delay:.1s">
<div class="toolbar">
<input type="search" id="f-search" class="form-control form-control-sm tb-search"
placeholder="Filter by name or host&hellip;" autocomplete="off">
<select id="f-state" class="form-select form-select-sm tb-state">
<option value="">All states</option>
<option value="bound">Bound</option>
<option value="recovering">Recovering</option>
<option value="stopped">Stopped</option>
</select>
<label class="tb-check">
<input type="checkbox" id="f-problems"> Problems only
</label>
<span class="spacer"></span>
<span class="tb-count" id="row-count"></span>
</div>
<div class="table-wrap">
<table class="kpi-table">
<thead>
<tr>
<th data-sort="name" class="sortable">PLC</th>
<th data-sort="host" class="sortable">Backend</th>
<th data-sort="state" class="sortable">State</th>
<th data-sort="clients" class="sortable num">Clients</th>
<th data-sort="pdurate" class="sortable num">PDU/s</th>
<th data-sort="rtt" class="sortable num">RTT ms</th>
<th data-sort="exceptions" class="sortable num">Excns</th>
<th data-sort="coalesce" class="sortable num">Coalesce</th>
<th data-sort="cache" class="sortable num">Cache</th>
<th data-sort="keepalive" class="sortable num">Keepalive</th>
</tr>
</thead>
<tbody id="plc-rows">
<tr><td colspan="10" class="empty-row">Waiting for first snapshot&hellip;</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="/assets/signalr.min.js"></script>
<script src="/assets/bootstrap.bundle.min.js"></script>
<script src="/assets/dashboard.js"></script>
</body>
</html>
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mbproxy — connection</title>
<link rel="stylesheet" href="/assets/bootstrap.min.css">
<link rel="stylesheet" href="/assets/theme.css">
<link rel="stylesheet" href="/assets/detail.css">
</head>
<body>
<header class="app-bar">
<span class="brand"><span class="mark">&#9646;</span> mbproxy</span>
<a class="crumb" href="/">fleet</a>
<span class="crumb">&rsaquo;</span>
<span class="crumb" id="crumb-name">&mdash;</span>
<span class="spacer"></span>
<span class="conn-pill" id="conn" data-state="connecting">
<span class="dot"></span><span id="conn-text">connecting</span>
</span>
</header>
<main class="page">
<!-- PLC identity header -->
<section class="plc-head rise" id="plc-head" style="animation-delay:.02s">
<div>
<div class="plc-title" id="plc-name">&mdash;</div>
<div class="plc-sub" id="plc-sub">&mdash;</div>
</div>
<div class="plc-head-state" id="plc-state"></div>
</section>
<!-- "PLC not configured" notice (shown when a hot-reload removed the PLC) -->
<section class="panel notice rise" id="notice" hidden>
This PLC is no longer in the configuration. It was likely removed by a
configuration hot-reload. Counters and the debug view are unavailable.
</section>
<!-- Grouped counter cards -->
<section class="card-grid rise" id="cards" style="animation-delay:.08s"></section>
<!-- Real-time debug view -->
<section class="panel rise" id="debug-panel" style="animation-delay:.14s">
<div class="panel-head debug-head">
<span>Debug view &mdash; per-tag PLC-side vs client-side values</span>
<span class="capture-state" id="capture-state">&mdash;</span>
</div>
<div class="table-wrap">
<table class="debug-table">
<thead>
<tr>
<th>Tag (PDU addr)</th>
<th>Width</th>
<th>Direction</th>
<th class="num">PLC side (raw BCD)</th>
<th class="num">Client side (decoded)</th>
<th class="num">Age</th>
</tr>
</thead>
<tbody id="debug-rows">
<tr><td colspan="6" class="empty-row">Waiting for first snapshot&hellip;</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="/assets/signalr.min.js"></script>
<script src="/assets/bootstrap.bundle.min.js"></script>
<script src="/assets/detail.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
/* ============================================================================
mbproxy admin console shared theme
Refined technical-light: warm-neutral paper, hairline rules, IBM Plex type,
monospace numerics, status carried by colour. Layered over Bootstrap 5 via
--bs-* variable overrides. Owned by Phase 3; the dashboard and detail views
add only view-specific rules in dashboard.css / detail.css.
========================================================================= */
/* ── Vendored fonts (embedded woff2, no network fetch) ───────────────────── */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/ibm-plex-sans-400.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/ibm-plex-sans-600.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/assets/ibm-plex-mono-500.woff2') format('woff2');
}
/* ── Design tokens ───────────────────────────────────────────────────────── */
:root {
--paper: #f4f4f1;
--card: #ffffff;
--ink: #1b1d21;
--ink-soft: #5a6066;
--ink-faint: #8b9097;
--rule: #e4e4df;
--rule-strong: #d2d2cb;
--accent: #2f5fd0;
--accent-deep: #1e3f99;
--ok: #2f9e44;
--warn: #e8920c;
--bad: #e03131;
--idle: #868e96;
--ok-bg: #e9f6ec;
--warn-bg: #fdf1dd;
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
/* Bootstrap overrides */
--bs-body-bg: var(--paper);
--bs-body-color: var(--ink);
--bs-body-font-family: var(--sans);
--bs-body-font-size: 0.9rem;
--bs-primary: var(--accent);
--bs-border-color: var(--rule);
--bs-emphasis-color: var(--ink);
}
/* ── Base ────────────────────────────────────────────────────────────────── */
body {
background:
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
.numeric,
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-deep); text-decoration: underline; }
/* ── App chrome: top bar ─────────────────────────────────────────────────── */
.app-bar {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--rule-strong);
}
.app-bar .brand {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.app-bar .brand .mark { color: var(--accent); }
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
.app-bar .spacer { flex: 1; }
.app-bar .meta {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* ── Connection-state pill (SignalR link health) ─────────────────────────── */
.conn-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
background: var(--card);
}
.conn-pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--idle);
}
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ── Status text helpers ─────────────────────────────────────────────────── */
.s-ok { color: var(--ok); }
.s-warn { color: var(--warn); }
.s-bad { color: var(--bad); }
.s-idle { color: var(--idle); }
/* State chip — used for listener state and health badges */
.chip {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
border: 1px solid transparent;
}
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
/* ── Cards ───────────────────────────────────────────────────────────────── */
.panel {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
}
.panel-head {
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
/* Generic page padding */
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
/* Staggered panel reveal on first paint */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; }
@@ -46,6 +46,7 @@ internal sealed partial class ConfigReconciler : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<ConfigReconciler> _logger;
private readonly ServiceCounters _serviceCounters;
private readonly Proxy.TagCaptureRegistry _captureRegistry;
// The supervisor dictionary is set by ProxyWorker after initial startup.
// ConcurrentDictionary so the per-PLC Add/Remove/Restart task continuations inside
@@ -91,12 +92,14 @@ internal sealed partial class ConfigReconciler : IDisposable
public ConfigReconciler(
IOptionsMonitor<MbproxyOptions> monitor,
ILoggerFactory loggerFactory,
ServiceCounters serviceCounters)
ServiceCounters serviceCounters,
Proxy.TagCaptureRegistry captureRegistry)
{
_monitor = monitor;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ConfigReconciler>();
_serviceCounters = serviceCounters;
_captureRegistry = captureRegistry;
// Subscribe to OnChange. The callback must return immediately — enqueue only.
_changeRegistration = _monitor.OnChange((_, _) =>
@@ -253,6 +256,7 @@ internal sealed partial class ConfigReconciler : IDisposable
{
if (!_supervisors.TryRemove(name, out var s))
return;
_captureRegistry.Remove(name);
try
{
using var stopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -303,6 +307,7 @@ internal sealed partial class ConfigReconciler : IDisposable
Counters = new Proxy.ProxyCounters(),
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
Capture = _captureRegistry.GetOrCreate(plcNew.Name, result.Map),
};
// Build and start new supervisor.
@@ -356,6 +361,9 @@ internal sealed partial class ConfigReconciler : IDisposable
// Any reseat (tag-map change) constructs a fresh cache. The
// supervisor disposes the old one inside ReplaceContextAsync.
Cache = BuildCacheIfNeeded(newMap, next.Cache),
// Rebuild the capture for the new tag set; GetOrCreate preserves the
// armed flag so an open detail page keeps capturing across the reload.
Capture = _captureRegistry.GetOrCreate(name, newMap),
};
using var reseatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -391,6 +399,7 @@ internal sealed partial class ConfigReconciler : IDisposable
Counters = new Proxy.ProxyCounters(),
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
Capture = _captureRegistry.GetOrCreate(plcNew.Name, result.Map),
};
var recoveryPipeline = PolicyFactory.BuildListenerRecovery(
@@ -72,6 +72,9 @@ internal static class ReloadValidator
errs.Add($"AdminPort {adminPort} collides with ListenPort of PLC '{clashPlc}'.");
}
if (next.AdminPushIntervalMs <= 0)
errs.Add($"AdminPushIntervalMs must be > 0; got {next.AdminPushIntervalMs}.");
// ── 4. Per-PLC tag-map build ──────────────────────────────────────────
// BcdTagMapBuilder.Build is the single source of truth for tag-list
// well-formedness; we must not duplicate its validation logic here.
+6
View File
@@ -32,6 +32,10 @@ internal static class HostingExtensions
// Service-wide counters (read by the status page).
builder.Services.AddSingleton<ServiceCounters>();
// Per-PLC tag-value captures backing the connection-detail debug view.
// Shared between the proxy hot path and the admin SignalR layer.
builder.Services.AddSingleton<Proxy.TagCaptureRegistry>();
// Hot-reload reconciler (singleton; subscribes to IOptionsMonitor.OnChange).
builder.Services.AddSingleton<ConfigReconciler>();
@@ -57,6 +61,8 @@ internal static class HostingExtensions
{
builder.Services.AddSingleton<AssemblyVersionAccessor>();
builder.Services.AddSingleton<StatusSnapshotBuilder>();
// Tracks detail-page SignalR subscribers; drives on-demand capture arming.
builder.Services.AddSingleton<PlcSubscriptionTracker>();
builder.Services.AddSingleton<AdminEndpointHost>();
return builder;
}
+10
View File
@@ -58,6 +58,16 @@
<InternalsVisibleTo Include="Mbproxy.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Admin web-UI assets — Bootstrap, the SignalR JS client, vendored fonts, and the
dashboard's own HTML/CSS/JS. Embedded into the assembly so the single-file binary
serves the whole UI with no CDN dependency (firewalled networks). Resource names
are Mbproxy.Admin.wwwroot.<filename>; AdminEndpointHost streams them on
GET /assets/<filename>. The directory is intentionally flat to keep the
resource-name → request-path mapping trivial. -->
<EmbeddedResource Include="Admin\wwwroot\*.*" />
</ItemGroup>
<!-- Link the platform-appropriate install template as the published appsettings.json so
the binary ships with a fully-commented, usable example config (PLCs, BCD tags, all
sections present) instead of an empty stub. The .NET configuration loader supports
@@ -7,6 +7,14 @@ public sealed class MbproxyOptions
public BcdTagListOptions BcdTags { get; init; } = new();
public IReadOnlyList<PlcOptions> Plcs { get; init; } = [];
public int AdminPort { get; init; } = 8080;
/// <summary>
/// Server-push cadence (milliseconds) for the admin dashboard's SignalR feed.
/// Every interval the admin endpoint builds a status snapshot and pushes it to
/// connected dashboard / detail-page clients. Must be &gt; 0. Defaults to 1000.
/// </summary>
public int AdminPushIntervalMs { get; init; } = 1000;
public ConnectionOptions Connection { get; init; } = new();
public ResilienceOptions Resilience { get; init; } = new();
@@ -106,6 +114,9 @@ public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
errors.Add(
$"Connection.GracefulShutdownTimeoutMs must be > 0; got {options.Connection.GracefulShutdownTimeoutMs}.");
if (options.AdminPushIntervalMs <= 0)
errors.Add($"AdminPushIntervalMs must be > 0; got {options.AdminPushIntervalMs}.");
// Keepalive section ranges. Cross-field rules (heartbeat interval vs request
// timeout) are enforced in ReloadValidator.
var ka = options.Connection.Keepalive;
@@ -141,6 +141,7 @@ internal sealed class BcdPduPipeline : IPduPipeline
pdu[3] = (byte)(encoded >> 8);
pdu[4] = (byte)(encoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
ctx.Capture?.Record(address, encoded, 0, value, CaptureDirection.Write);
}
/// <summary>
@@ -247,6 +248,7 @@ internal sealed class BcdPduPipeline : IPduPipeline
pdu[highByteOff] = (byte)(bcdHigh >> 8);
pdu[highByteOff + 1] = (byte)(bcdHigh & 0xFF);
ctx.Counters.AddRewrittenSlots(2);
ctx.Capture?.Record(tag.Address, bcdLow, bcdHigh, binaryValue, CaptureDirection.Write);
}
else
{
@@ -276,6 +278,7 @@ internal sealed class BcdPduPipeline : IPduPipeline
pdu[byteOff] = (byte)(encoded >> 8);
pdu[byteOff + 1] = (byte)(encoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
ctx.Capture?.Record(tag.Address, encoded, 0, clientValue, CaptureDirection.Write);
}
}
}
@@ -402,6 +405,7 @@ internal sealed class BcdPduPipeline : IPduPipeline
pdu[highByteOff] = (byte)(decodedHigh >> 8);
pdu[highByteOff + 1] = (byte)(decodedHigh & 0xFF);
ctx.Counters.AddRewrittenSlots(2);
ctx.Capture?.Record(tag.Address, rawLow, rawHigh, decoded, CaptureDirection.Read);
}
else
{
@@ -430,6 +434,7 @@ internal sealed class BcdPduPipeline : IPduPipeline
pdu[byteOff] = (byte)(decoded >> 8);
pdu[byteOff + 1] = (byte)(decoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
ctx.Capture?.Record(tag.Address, raw, 0, decoded, CaptureDirection.Read);
}
}
}
@@ -52,6 +52,16 @@ internal class PerPlcContext : PduContext
/// </summary>
internal ResponseCache? Cache { get; init; }
/// <summary>
/// Optional per-PLC tag-value capture feeding the connection-detail debug view.
/// Wired in production from <see cref="TagCaptureRegistry"/>; <c>null</c> in unit
/// tests that don't exercise it. The <see cref="BcdPduPipeline"/> records into it
/// with <c>?.</c>, and the capture itself no-ops unless a detail page has armed it,
/// so the cost on the hot path with no viewer is one nullable-deref + one volatile
/// read.
/// </summary>
internal TagValueCapture? Capture { get; init; }
/// <summary>
/// Returns a shallow clone of this context with <see cref="CurrentRequest"/> set to
/// <paramref name="req"/>. The clone is cheap (one allocation per response) and avoids
@@ -65,5 +75,6 @@ internal class PerPlcContext : PduContext
Logger = Logger,
CurrentRequest = req,
Cache = Cache,
Capture = Capture,
};
}
+13 -6
View File
@@ -47,6 +47,10 @@ internal sealed partial class ProxyWorker : BackgroundService
private readonly IServiceProvider _services;
private AdminEndpointHost? _admin;
// Per-PLC tag-value captures for the connection-detail debug view. Populated as
// each PerPlcContext is built; the admin SignalR layer arms/disarms entries.
private readonly TagCaptureRegistry _captureRegistry;
// Supervisors are managed jointly by ProxyWorker (initial bootstrap) and
// ConfigReconciler (subsequent hot-reload changes). The dictionary is shared via
// ConfigReconciler.Attach() after initial startup.
@@ -71,14 +75,16 @@ internal sealed partial class ProxyWorker : BackgroundService
ILogger<ProxyWorker> logger,
ILoggerFactory loggerFactory,
ConfigReconciler reconciler,
TagCaptureRegistry captureRegistry,
IServiceProvider services)
{
_options = options;
_pipeline = pipeline;
_logger = logger;
_loggerFactory = loggerFactory;
_reconciler = reconciler;
_services = services;
_options = options;
_pipeline = pipeline;
_logger = logger;
_loggerFactory = loggerFactory;
_reconciler = reconciler;
_captureRegistry = captureRegistry;
_services = services;
// Admin endpoint resolved lazily in ExecuteAsync (see field comment).
}
@@ -123,6 +129,7 @@ internal sealed partial class ProxyWorker : BackgroundService
Counters = new ProxyCounters(),
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plc.Name}"),
Cache = cache,
Capture = _captureRegistry.GetOrCreate(plc.Name, result.Map),
};
}
@@ -0,0 +1,71 @@
using System.Collections.Concurrent;
using Mbproxy.Bcd;
namespace Mbproxy.Proxy;
/// <summary>
/// Process-wide registry of per-PLC <see cref="TagValueCapture"/> instances — the
/// shared seam between the proxy hot path (which records tag values) and the admin
/// layer (which arms captures on detail-page open and reads their snapshots).
///
/// <para>Registered as a DI singleton. <see cref="ProxyWorker"/> and
/// <see cref="Configuration.ConfigReconciler"/> call <see cref="GetOrCreate"/> as they
/// build each <see cref="PerPlcContext"/>; <see cref="Configuration.ConfigReconciler"/>
/// calls <see cref="Remove"/> for hot-reload-removed PLCs. The admin
/// <c>StatusHub</c> / <c>StatusBroadcaster</c> call <see cref="Arm"/> /
/// <see cref="Disarm"/> / <see cref="DisarmAll"/> / <see cref="TryGet"/>.</para>
/// </summary>
internal sealed class TagCaptureRegistry
{
private readonly ConcurrentDictionary<string, TagValueCapture> _captures =
new(StringComparer.Ordinal);
/// <summary>
/// Returns the capture for <paramref name="plcName"/>, creating it on first call.
/// A subsequent call (hot-reload reseat/restart, where the tag set may have changed)
/// rebuilds the capture for <paramref name="map"/>'s current tags, preserving the
/// armed flag so an open detail page keeps capturing across the reload.
/// </summary>
public TagValueCapture GetOrCreate(string plcName, BcdTagMap map)
=> _captures.AddOrUpdate(
plcName,
_ => new TagValueCapture(map.All),
(_, existing) =>
{
var rebuilt = new TagValueCapture(map.All);
if (existing.IsArmed)
rebuilt.Arm();
return rebuilt;
});
/// <summary>Drops the capture for a hot-reload-removed PLC.</summary>
public void Remove(string plcName) => _captures.TryRemove(plcName, out _);
/// <summary>Looks up a PLC's capture; false when the PLC is unknown.</summary>
public bool TryGet(string plcName, out TagValueCapture capture)
=> _captures.TryGetValue(plcName, out capture!);
/// <summary>Arms a PLC's capture. No-op for an unknown PLC name.</summary>
public void Arm(string plcName)
{
if (_captures.TryGetValue(plcName, out var c))
c.Arm();
}
/// <summary>Disarms a PLC's capture. No-op for an unknown PLC name.</summary>
public void Disarm(string plcName)
{
if (_captures.TryGetValue(plcName, out var c))
c.Disarm();
}
/// <summary>
/// Disarms every capture. Called when the admin endpoint stops (e.g. AdminPort
/// hot-reload tears down the SignalR host) so no capture is left armed with no viewer.
/// </summary>
public void DisarmAll()
{
foreach (var c in _captures.Values)
c.Disarm();
}
}
@@ -0,0 +1,147 @@
using System.Collections.Frozen;
using Mbproxy.Bcd;
namespace Mbproxy.Proxy;
/// <summary>Direction of a captured tag-value observation.</summary>
public enum CaptureDirection
{
/// <summary>FC03/FC04 response — the proxy decoded BCD nibbles → binary for the client.</summary>
Read,
/// <summary>FC06/FC16 request — the proxy encoded the client's binary → BCD for the PLC.</summary>
Write,
}
/// <summary>
/// One immutable observation of a BCD tag's value as it last crossed the proxy.
///
/// <para>"PLC side" is always the BCD-encoded form on the Modbus wire to/from the
/// device; "client side" is always the decoded binary integer the upstream client
/// reads or wrote. <see cref="RawHigh"/> is 0 for a 16-bit tag.</para>
/// </summary>
public sealed record TagValueObservation(
ushort Address,
byte Width,
ushort RawLow,
ushort RawHigh,
int DecodedValue,
CaptureDirection Direction,
DateTimeOffset? UpdatedAtUtc);
/// <summary>
/// Per-PLC, on-demand store of the last value seen for each configured BCD tag — the
/// data source behind the connection-detail page's real-time debug view.
///
/// <para><b>On-demand.</b> The capture starts disarmed: <see cref="Record"/> is an
/// immediate no-op (one volatile-bool read) until <see cref="Arm"/> is called. The
/// SignalR layer arms a PLC's capture only while its detail page has a live
/// subscriber and <see cref="Disarm"/>s it — clearing all slots — when the last
/// viewer leaves, so a reopened page shows "no traffic yet" rather than stale data.</para>
///
/// <para><b>Concurrency.</b> <see cref="Record"/> is called from many upstream-read
/// tasks (FC06/FC16 requests) and the single backend reader task (FC03/FC04
/// responses); <see cref="Snapshot"/> runs on the admin-push thread. Each slot holds a
/// reference to an immutable <see cref="TagValueObservation"/>, swapped with
/// <see cref="Volatile.Write{T}(ref T, T)"/> — reference assignment is atomic and the
/// record is immutable, so a reader never sees a torn slot. No locks.</para>
/// </summary>
internal sealed class TagValueCapture
{
// Tag address → slot index. Frozen for allocation-free O(1) lookup on the hot path.
private readonly FrozenDictionary<ushort, int> _addressToSlot;
// Slot index → tag identity. Parallel to _slots; immutable after construction.
private readonly ushort[] _addresses;
private readonly byte[] _widths;
// Slot index → last observation (null = no traffic captured yet). Each element is
// swapped via Volatile.Write; never mutated in place.
private readonly TagValueObservation?[] _slots;
private volatile bool _armed;
/// <summary>
/// Builds a capture for one PLC's resolved BCD tag set. One slot per tag, ordered
/// ascending by address for a stable debug-view row order.
/// </summary>
public TagValueCapture(IEnumerable<BcdTag> tags)
{
var ordered = tags
.GroupBy(t => t.Address)
.Select(g => g.First())
.OrderBy(t => t.Address)
.ToArray();
_addresses = new ushort[ordered.Length];
_widths = new byte[ordered.Length];
_slots = new TagValueObservation?[ordered.Length];
var index = new Dictionary<ushort, int>(ordered.Length);
for (int i = 0; i < ordered.Length; i++)
{
_addresses[i] = ordered[i].Address;
_widths[i] = ordered[i].Width;
index[ordered[i].Address] = i;
}
_addressToSlot = index.ToFrozenDictionary();
}
/// <summary>Number of BCD tag slots this capture tracks.</summary>
public int TagCount => _slots.Length;
/// <summary>True while a detail-page subscriber has armed this capture.</summary>
public bool IsArmed => _armed;
/// <summary>Arms capture — <see cref="Record"/> begins storing observations.</summary>
public void Arm() => _armed = true;
/// <summary>
/// Disarms capture and clears every slot, so the next <see cref="Snapshot"/> after a
/// re-arm reflects only post-re-arm traffic.
/// </summary>
public void Disarm()
{
_armed = false;
for (int i = 0; i < _slots.Length; i++)
Volatile.Write(ref _slots[i], null);
}
/// <summary>
/// Records the last value of the BCD tag at <paramref name="address"/>. No-op when
/// disarmed or when <paramref name="address"/> is not a tracked tag.
/// </summary>
/// <param name="rawLow">BCD-encoded low word as it sits on the PLC wire.</param>
/// <param name="rawHigh">BCD-encoded high word (0 for a 16-bit tag).</param>
/// <param name="decoded">Decoded binary integer the client reads/wrote.</param>
public void Record(ushort address, ushort rawLow, ushort rawHigh, int decoded, CaptureDirection direction)
{
if (!_armed)
return;
if (!_addressToSlot.TryGetValue(address, out int idx))
return;
Volatile.Write(
ref _slots[idx],
new TagValueObservation(
_addresses[idx], _widths[idx], rawLow, rawHigh, decoded, direction,
DateTimeOffset.UtcNow));
}
/// <summary>
/// Point-in-time projection of every tracked tag. Slots with no traffic yet are
/// returned with <see cref="TagValueObservation.UpdatedAtUtc"/> = <c>null</c> and
/// zero values, so the debug view always renders one row per configured tag.
/// </summary>
public IReadOnlyList<TagValueObservation> Snapshot()
{
var result = new TagValueObservation[_slots.Length];
for (int i = 0; i < _slots.Length; i++)
{
result[i] = Volatile.Read(ref _slots[i])
?? new TagValueObservation(
_addresses[i], _widths[i], 0, 0, 0, CaptureDirection.Read, null);
}
return result;
}
}
@@ -168,10 +168,10 @@ public sealed class AdminEndpointTests
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
}
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
[Fact(Timeout = 5_000)]
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
public async Task Get_Root_ReturnsDashboardShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
@@ -190,8 +190,79 @@ public sealed class AdminEndpointTests
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
body.ShouldContain("<!DOCTYPE html>");
body.ShouldContain("<!doctype html>");
body.ShouldContain("/assets/dashboard.js");
}
[Fact(Timeout = 5_000)]
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("/assets/detail.js");
}
[Theory(Timeout = 5_000)]
[InlineData("bootstrap.min.css", "text/css")]
[InlineData("signalr.min.js", "text/javascript")]
[InlineData("dashboard.js", "text/javascript")]
[InlineData("theme.css", "text/css")]
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
bytes.Length.ShouldBeGreaterThan(0);
}
[Fact(Timeout = 5_000)]
public async Task Get_UnknownAsset_Returns404()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
@@ -334,9 +405,9 @@ public sealed class AdminEndpointTests
/// <summary>
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later.
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
/// here.) Guards against an accidental MapPost/Map* being added later.
/// </summary>
[Theory(Timeout = 5_000)]
[InlineData("POST")]
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Mbproxy.Admin;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Minimal hand-written test doubles for the SignalR surface <see cref="StatusHub"/>
/// and <see cref="StatusBroadcaster"/> touch. The project carries no mocking framework,
/// so these record just enough to assert behaviour.
/// </summary>
internal sealed class FakeHubCallerContext : HubCallerContext
{
public FakeHubCallerContext(string connectionId) => ConnectionId = connectionId;
public override string ConnectionId { get; }
public override string? UserIdentifier => null;
public override ClaimsPrincipal? User => null;
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features { get; } = new FeatureCollection();
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() { }
}
/// <summary>Records every group join/leave so tests can assert membership changes.</summary>
internal sealed class FakeGroupManager : IGroupManager
{
public List<(string ConnectionId, string Group)> Added { get; } = [];
public List<(string ConnectionId, string Group)> Removed { get; } = [];
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Removed.Add((connectionId, groupName));
return Task.CompletedTask;
}
}
/// <summary>Records every push so <see cref="StatusBroadcaster"/> tests can assert routing.</summary>
internal sealed class FakeStatusPushSink : IStatusPushSink
{
private readonly ConcurrentBag<StatusResponse> _fleet = [];
private readonly ConcurrentBag<(string Plc, PlcDetailResponse Detail)> _plc = [];
public IReadOnlyCollection<StatusResponse> FleetPushes => _fleet;
public IReadOnlyCollection<(string Plc, PlcDetailResponse Detail)> PlcPushes => _plc;
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
{
_fleet.Add(snapshot);
return Task.CompletedTask;
}
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
{
_plc.Add((plcName, detail));
return Task.CompletedTask;
}
}
@@ -0,0 +1,120 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
/// capture disarmed on stop. The SignalR sink is faked; a real
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusBroadcasterTests
{
private sealed record Harness(
IHost Host,
StatusBroadcaster Broadcaster,
FakeStatusPushSink Sink,
StatusSnapshotBuilder Builder,
TagCaptureRegistry Registry,
PlcSubscriptionTracker Tracker) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Broadcaster.DisposeAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await Host.StopAsync(cts.Token); } catch { }
Host.Dispose();
}
}
private static async Task<Harness> BuildAsync()
{
var hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0",
});
hostBuilder.Services.AddSerilog(
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
hostBuilder.AddMbproxyOptions();
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
hostBuilder.Services.AddSingleton<ProxyWorker>();
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
var host = hostBuilder.Build();
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await host.StartAsync(startCts.Token);
var builder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
var options = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
var tracker = new PlcSubscriptionTracker();
var sink = new FakeStatusPushSink();
var broadcaster = new StatusBroadcaster(
sink, builder, tracker, registry, options, NullLogger.Instance);
return new Harness(host, broadcaster, sink, builder, registry, tracker);
}
[Fact]
public async Task PushOnce_AlwaysPushesFleet()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.FleetPushes.Count.ShouldBe(1);
}
[Fact]
public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.PlcPushes.ShouldBeEmpty();
}
[Fact]
public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Tracker.Add("conn-1", "plc-x");
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
push.Plc.ShouldBe("plc-x");
push.Detail.Debug.ShouldNotBeNull();
}
[Fact]
public async Task StopAsync_DisarmsEveryCapture()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Registry.Arm("plc-x");
await h.Broadcaster.StopAsync();
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeFalse();
}
}
@@ -1,128 +0,0 @@
using Mbproxy.Admin;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
/// All tests are pure: no network, no host, no DI.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHtmlRendererTests
{
// ── Helpers ───────────────────────────────────────────────────────────────
private static StatusResponse MakeStatus(
IReadOnlyList<PlcStatus>? plcs = null,
int uptimeSeconds = 42,
string version = "1.2.3")
{
var service = new ServiceFields(
UptimeSeconds: uptimeSeconds,
Version: version,
ConfigLastReloadUtc: null,
ConfigReloadCount: 0,
ConfigReloadRejectedCount: 0);
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
return new StatusResponse(service, listeners, plcs ?? []);
}
private static PlcStatus MakePlc(
string name = "PLC-A",
string state = "bound",
string? lastBindError = null,
int recoveryAttempts = 0,
IReadOnlyList<ClientSnapshot>? clients = null)
{
var noClients = (IReadOnlyList<ClientSnapshot>)[];
return new PlcStatus(
Name: name,
Host: "10.0.0.1",
ListenPort: 5020,
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2, 0),
Backend: new PlcBackendStatus(
ConnectsSuccess: 0, ConnectsFailed: 0,
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0),
LastRoundTripMs: 3.5,
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
DisconnectCascades: 0, QueueDepth: 0,
CoalescedHitCount: 0, CoalescedMissCount: 0,
CoalescedResponseToDeadUpstream: 0,
CacheHitCount: 0, CacheMissCount: 0,
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0,
BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0,
BackendIdleDisconnects: 0),
Bytes: new PlcBytesStatus(1024, 2048));
}
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
[Fact]
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
{
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
string html = StatusHtmlRenderer.Render(status);
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
html.ShouldContain("<!DOCTYPE html>");
html.ShouldContain("</html>");
html.ShouldContain("PLC-A");
html.ShouldContain("bound");
}
// ── 2. Recovering state highlights error ─────────────────────────────────
[Fact]
public void Render_RecoveringPlc_HighlightsState()
{
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
var status = MakeStatus([plc]);
string html = StatusHtmlRenderer.Render(status);
// State should be orange.
html.ShouldContain("class=\"recovering\"");
html.ShouldContain("Address already in use");
html.ShouldContain("attempt 3");
}
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
[Fact]
public void Render_PageWeightUnder50KB_For54Plcs()
{
const int plcCount = 54;
// Build 54 realistic PLC rows with 2 clients each.
var plcs = new List<PlcStatus>(plcCount);
for (int i = 0; i < plcCount; i++)
{
var clients = new List<ClientSnapshot>
{
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
};
plcs.Add(MakePlc(
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
state: i % 5 == 0 ? "recovering" : "bound",
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
recoveryAttempts: i % 5 == 0 ? 2 : 0,
clients: clients));
}
var status = MakeStatus(plcs);
string html = StatusHtmlRenderer.Render(status);
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
// Assert ≤ 50 KB.
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
}
}
@@ -0,0 +1,91 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
/// no SignalR host is started.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHubTests
{
private static StatusHub MakeHub(
string connectionId,
PlcSubscriptionTracker tracker,
TagCaptureRegistry registry,
out FakeGroupManager groups)
{
groups = new FakeGroupManager();
return new StatusHub(tracker, registry)
{
Context = new FakeHubCallerContext(connectionId),
Groups = groups,
};
}
[Fact]
public async Task SubscribeFleet_JoinsFleetGroup()
{
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
await hub.SubscribeFleet();
groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup));
}
[Fact]
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await hub.SubscribePlc("plc-1");
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
}
[Fact]
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
{
var tracker = new PlcSubscriptionTracker();
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub1 = MakeHub("conn-1", tracker, registry, out _);
var hub2 = MakeHub("conn-2", tracker, registry, out _);
await hub1.SubscribePlc("plc-1");
await hub2.SubscribePlc("plc-1");
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
// First viewer leaves — a second viewer remains, so capture stays armed.
await hub1.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
// Last viewer leaves — capture disarms.
await hub2.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
}
[Fact]
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
{
var registry = new TagCaptureRegistry(); // no captures registered
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
registry.TryGet("ghost", out _).ShouldBeFalse();
}
}
@@ -218,18 +218,60 @@ public sealed class StatusSnapshotBuilderTests
result.Service.ConfigReloadCount.ShouldBe(1);
}
// ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────
[Fact]
public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
{
var (host, builder) = await BuildAsync([]);
await using var _ = new AsyncHostDispose(host);
var debug = builder.BuildDebug("no-such-plc");
debug.CaptureArmed.ShouldBeFalse();
debug.Tags.ShouldBeEmpty();
}
// ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────
[Fact]
public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
{
int port = PickFreePort();
var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
await using var _ = new AsyncHostDispose(host);
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");
var debug = builder.BuildDebug("PLC-A");
debug.CaptureArmed.ShouldBeFalse(); // no detail page open
var tag = debug.Tags.ShouldHaveSingleItem();
tag.Address.ShouldBe(1072);
tag.Width.ShouldBe(16);
tag.HasValue.ShouldBeFalse();
tag.RawHex.ShouldBe("—");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
(string name, int port)[] plcs,
int startupWaitMs = 200,
int backendPort = 502)
int backendPort = 502,
int? bcd16Address = null)
{
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
};
if (bcd16Address is { } addr)
{
config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
}
for (int i = 0; i < plcs.Length; i++)
{
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
@@ -73,7 +73,8 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
return new ConfigReconciler(
monitor,
NullLoggerFactory.Instance,
counters ?? new ServiceCounters());
counters ?? new ServiceCounters(),
new Mbproxy.Proxy.TagCaptureRegistry());
}
// The reconciler and supervisors tracked for cleanup.
@@ -334,4 +334,36 @@ public sealed class ReloadValidatorTests
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("BackendHeartbeatIdleMs"));
}
// ── AdminPushIntervalMs ────────────────────────────────────────────────────
[Fact]
public void Validate_AdminPushIntervalMs_Zero_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = 0,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
[Fact]
public void Validate_AdminPushIntervalMs_Negative_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = -5,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
}
@@ -190,6 +190,28 @@ public sealed class MbproxyOptionsBindingTests
string.Join("; ", result.Failures ?? []));
}
// -------------------------------------------------------------------------
// Test 7 — AdminPushIntervalMs (SignalR dashboard push cadence)
// -------------------------------------------------------------------------
[Fact]
public void MbproxyOptionsBinding_AdminPushIntervalMs_DefaultsTo1000()
{
var options = BindOptions(new Dictionary<string, string?>());
options.AdminPushIntervalMs.ShouldBe(1000);
}
[Fact]
public void MbproxyOptionsBinding_AdminPushIntervalMs_BindsConfiguredValue()
{
var options = BindOptions(new Dictionary<string, string?>
{
["Mbproxy:AdminPushIntervalMs"] = "250",
});
options.AdminPushIntervalMs.ShouldBe(250);
}
/// <summary>
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
/// Works from both the Windows dev box and the Linux test box.
@@ -0,0 +1,181 @@
using System.Collections.Frozen;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for the <see cref="TagValueCapture"/> recording hooks in
/// <see cref="BcdPduPipeline"/>. Verifies that an armed capture records raw PLC-side
/// and decoded client-side values, and — as a regression guard — that a disarmed or
/// absent capture leaves the rewrite behaviour byte-identical.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdPduPipelineCaptureTests
{
private static readonly BcdPduPipeline Pipeline = new();
private static BcdTagMap BuildMap(params BcdTag[] tags)
{
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
return frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
}
private static PerPlcContext MakeContext(TagValueCapture? capture, params BcdTag[] tags)
=> new()
{
PlcName = "TestPLC",
TagMap = BuildMap(tags),
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Capture = capture,
};
private static InFlightRequest MakeInFlight(byte fc, ushort start, ushort qty)
=> new(1, fc, start, qty, Array.Empty<InterestedParty>(), DateTimeOffset.UtcNow);
private static byte[] Fc03Response(params ushort[] regs)
{
var pdu = new byte[2 + regs.Length * 2];
pdu[0] = 0x03;
pdu[1] = (byte)(regs.Length * 2);
for (int i = 0; i < regs.Length; i++)
{
pdu[2 + i * 2] = (byte)(regs[i] >> 8);
pdu[2 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
}
return pdu;
}
private static byte[] Fc06Request(ushort address, ushort value)
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
private static byte[] Fc16Request(ushort start, params ushort[] regs)
{
var pdu = new byte[6 + regs.Length * 2];
pdu[0] = 0x10;
pdu[1] = (byte)(start >> 8);
pdu[2] = (byte)(start & 0xFF);
pdu[3] = (byte)((ushort)regs.Length >> 8);
pdu[4] = (byte)(regs.Length & 0xFF);
pdu[5] = (byte)(regs.Length * 2);
for (int i = 0; i < regs.Length; i++)
{
pdu[6 + i * 2] = (byte)(regs[i] >> 8);
pdu[6 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
}
return pdu;
}
private static void ProcessFc03Response(PerPlcContext ctx, ushort start, ushort qty, byte[] response)
{
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(0x03, start, qty));
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, response.AsSpan(), responseCtx);
}
private static ushort ReadReg(byte[] pdu, int offsetWords)
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
// ── Read path (FC03/FC04 response) ───────────────────────────────────────
[Fact]
public void FC03_16Bit_ArmedCapture_RecordsRawAndDecoded()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
ProcessFc03Response(ctx, 100, 1, Fc03Response(0x1234));
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Address.ShouldBe((ushort)100);
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles on the PLC wire
slot.DecodedValue.ShouldBe(1234); // binary the client receives
slot.Direction.ShouldBe(CaptureDirection.Read);
slot.UpdatedAtUtc.ShouldNotBeNull();
}
[Fact]
public void FC03_32Bit_ArmedCapture_RecordsBothRawWords()
{
var capture = new TagValueCapture([BcdTag.Create(100, 32)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 32));
// CDAB: low word 0x5678, high word 0x1234 → decoded 1234*10000 + 5678.
ProcessFc03Response(ctx, 100, 2, Fc03Response(0x5678, 0x1234));
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Width.ShouldBe((byte)32);
slot.RawLow.ShouldBe((ushort)0x5678);
slot.RawHigh.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(12345678);
slot.Direction.ShouldBe(CaptureDirection.Read);
}
// ── Write path (FC06 / FC16 request) ─────────────────────────────────────
[Fact]
public void FC06_ArmedCapture_RecordsEncodedBcdAndClientValue()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
// Client writes binary 1234; proxy encodes to BCD 0x1234 for the PLC.
var req = Fc06Request(100, 1234);
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles sent to the PLC
slot.DecodedValue.ShouldBe(1234); // binary the client wrote
slot.Direction.ShouldBe(CaptureDirection.Write);
}
[Fact]
public void FC16_16Bit_ArmedCapture_RecordsWrite()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
var req = Fc16Request(100, 4321);
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.RawLow.ShouldBe((ushort)0x4321);
slot.DecodedValue.ShouldBe(4321);
slot.Direction.ShouldBe(CaptureDirection.Write);
}
// ── Regression guards: disarmed / absent capture ─────────────────────────
[Fact]
public void FC03_DisarmedCapture_StillRewrites_ButCapturesNothing()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
// Not armed.
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
var rsp = Fc03Response(0x1234);
ProcessFc03Response(ctx, 100, 1, rsp);
ReadReg(rsp, 0).ShouldBe((ushort)1234); // rewrite still happened
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void FC03_NullCapture_DoesNotThrow_AndStillRewrites()
{
var ctx = MakeContext(capture: null, BcdTag.Create(100, 16));
var rsp = Fc03Response(0x1234);
Should.NotThrow(() => ProcessFc03Response(ctx, 100, 1, rsp));
ReadReg(rsp, 0).ShouldBe((ushort)1234);
}
}
@@ -0,0 +1,112 @@
using System.Collections.Frozen;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for <see cref="TagCaptureRegistry"/> — the shared seam that arms and
/// disarms per-PLC <see cref="TagValueCapture"/> instances.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagCaptureRegistryTests
{
private static BcdTagMap Map(params (ushort addr, byte width)[] tags)
{
if (tags.Length == 0)
return BcdTagMap.Empty;
var frozen = tags
.Select(t => BcdTag.Create(t.addr, t.width))
.ToDictionary(t => t.Address)
.ToFrozenDictionary();
return new BcdTagMap(frozen);
}
[Fact]
public void GetOrCreate_ReturnsSameInstance_OnRepeatCall_WhenTagSetUnchanged()
{
var registry = new TagCaptureRegistry();
var first = registry.GetOrCreate("plc-1", Map((100, 16)));
var second = registry.GetOrCreate("plc-1", Map((100, 16)));
// AddOrUpdate's update path rebuilds; both must be live and consistent.
second.TagCount.ShouldBe(1);
registry.TryGet("plc-1", out var current).ShouldBeTrue();
current.ShouldBeSameAs(second);
}
[Fact]
public void GetOrCreate_Rebuild_PreservesArmedFlag()
{
var registry = new TagCaptureRegistry();
var capture = registry.GetOrCreate("plc-1", Map((100, 16)));
capture.Arm();
// Hot-reload reseat: same PLC, changed tag set.
var rebuilt = registry.GetOrCreate("plc-1", Map((100, 16), (200, 32)));
rebuilt.ShouldNotBeSameAs(capture);
rebuilt.IsArmed.ShouldBeTrue("a rebuilt capture must keep capturing for an open detail page");
rebuilt.TagCount.ShouldBe(2);
}
[Fact]
public void Arm_And_Disarm_ReachTheRightCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.GetOrCreate("plc-2", Map((100, 16)));
registry.Arm("plc-1");
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
c1.IsArmed.ShouldBeTrue();
c2.IsArmed.ShouldBeFalse();
registry.Disarm("plc-1");
c1.IsArmed.ShouldBeFalse();
}
[Fact]
public void DisarmAll_DisarmsEveryCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.GetOrCreate("plc-2", Map((100, 16)));
registry.Arm("plc-1");
registry.Arm("plc-2");
registry.DisarmAll();
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
c1.IsArmed.ShouldBeFalse();
c2.IsArmed.ShouldBeFalse();
}
[Fact]
public void UnknownPlc_Operations_AreSafeNoOps()
{
var registry = new TagCaptureRegistry();
Should.NotThrow(() => registry.Arm("ghost"));
Should.NotThrow(() => registry.Disarm("ghost"));
Should.NotThrow(() => registry.Remove("ghost"));
registry.TryGet("ghost", out _).ShouldBeFalse();
}
[Fact]
public void Remove_DropsTheCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.TryGet("plc-1", out _).ShouldBeTrue();
registry.Remove("plc-1");
registry.TryGet("plc-1", out _).ShouldBeFalse();
}
}
@@ -0,0 +1,156 @@
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for <see cref="TagValueCapture"/> — the on-demand per-tag value store
/// behind the connection-detail debug view.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagValueCaptureTests
{
private static TagValueCapture Make(params (ushort addr, byte width)[] tags)
=> new(tags.Select(t => BcdTag.Create(t.addr, t.width)));
[Fact]
public void Disarmed_Record_IsNoOp()
{
var capture = Make((100, 16));
// No Arm() call — capture starts disarmed.
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
capture.IsArmed.ShouldBeFalse();
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void Armed_Record_UpdatesMatchingSlot()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Address.ShouldBe((ushort)100);
slot.Width.ShouldBe((byte)16);
slot.RawLow.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(1234);
slot.Direction.ShouldBe(CaptureDirection.Read);
slot.UpdatedAtUtc.ShouldNotBeNull();
}
[Fact]
public void Armed_Record_UnknownAddress_IsIgnored()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(999, 0x1111, 0, 1111, CaptureDirection.Read);
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
}
[Fact]
public void Disarm_ClearsAllSlots()
{
var capture = Make((100, 16), (200, 16));
capture.Arm();
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
capture.Record(200, 0x0099, 0, 99, CaptureDirection.Read);
capture.Disarm();
capture.IsArmed.ShouldBeFalse();
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
}
[Fact]
public void ReArm_AfterDisarm_StartsEmpty()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
capture.Disarm();
capture.Arm();
// No new traffic since re-arm — slot must read as empty, not the pre-disarm value.
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void ThirtyTwoBitTag_RecordsBothRawWords()
{
var capture = Make((100, 32));
capture.Arm();
capture.Record(100, 0x5678, 0x1234, 12345678, CaptureDirection.Read);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Width.ShouldBe((byte)32);
slot.RawLow.ShouldBe((ushort)0x5678);
slot.RawHigh.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(12345678);
}
[Fact]
public void Snapshot_ReturnsOneRowPerTag_OrderedByAddress()
{
var capture = Make((300, 16), (100, 32), (200, 16));
capture.TagCount.ShouldBe(3);
var snap = capture.Snapshot();
snap.Select(s => s.Address).ShouldBe([(ushort)100, (ushort)200, (ushort)300]);
}
[Fact]
public void WriteDirection_IsPreserved()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x0500, 0, 500, CaptureDirection.Write);
capture.Snapshot().ShouldHaveSingleItem().Direction.ShouldBe(CaptureDirection.Write);
}
[Fact]
public async Task ConcurrentRecordAndSnapshot_NeverYieldsTornSlot()
{
// Invariant maintained by every Record: DecodedValue == RawLow + RawHigh.
// A torn read (fields from two different Record calls) would break it.
var capture = Make((100, 32));
capture.Arm();
var ct = TestContext.Current.CancellationToken;
bool tornObserved = false;
var writers = Enumerable.Range(0, 4).Select(seed => Task.Run(() =>
{
var rng = new Random(seed + 1);
for (int i = 0; i < 200_000; i++)
{
ushort lo = (ushort)rng.Next(0, 60000);
ushort hi = (ushort)rng.Next(0, 5000);
capture.Record(100, lo, hi, lo + hi, CaptureDirection.Read);
}
}, ct)).ToArray();
var reader = Task.Run(() =>
{
for (int i = 0; i < 200_000; i++)
{
foreach (var slot in capture.Snapshot())
{
if (slot.UpdatedAtUtc is null)
continue;
if (slot.DecodedValue != slot.RawLow + slot.RawHigh)
tornObserved = true;
}
}
}, ct);
await Task.WhenAll([.. writers, reader]);
tornObserved.ShouldBeFalse("Snapshot must never observe a torn (half-updated) slot");
}
}
@@ -0,0 +1,71 @@
// mbproxy smoke-test configuration used by the Phase 4/5 web-UI browser smoke
// tests (see plans/2026-05-15-webui-dashboard.md). NOT a deployment config.
//
// Topology:
// * line-a / line-b the dl205 simulator on 127.0.0.1:5020 (run-dl205-sim.ps1).
// line-a carries a 16-bit BCD tag, line-b a 32-bit BCD tag,
// so the connection-detail debug view has content in both
// widths. Both listeners bind and reach a live backend.
// * line-dead an unreachable backend (192.0.2.1, TEST-NET-1, RFC 5737).
// The listener binds fine but every backend connect fails,
// so the row surfaces connect failures / heartbeat failures
// and exercises the dashboard's "problems only" filter.
{
"Mbproxy": {
"BcdTags": {
"Global": []
},
"Plcs": [
{
"Name": "line-a",
"ListenPort": 6020,
"Host": "127.0.0.1",
"Port": 5020,
"BcdTags": {
"Add": [
{ "Address": 1072, "Width": 16 }
]
}
},
{
"Name": "line-b",
"ListenPort": 6021,
"Host": "127.0.0.1",
"Port": 5020,
"BcdTags": {
"Add": [
{ "Address": 1072, "Width": 32 }
]
}
},
{
"Name": "line-dead",
"ListenPort": 6022,
"Host": "192.0.2.1",
"Port": 502
}
],
"AdminPort": 8080,
"AdminPushIntervalMs": 1000,
"Connection": {
"BackendConnectTimeoutMs": 2000,
"BackendRequestTimeoutMs": 2000,
"GracefulShutdownTimeoutMs": 5000,
"Keepalive": {
"Enabled": true,
"TcpIdleTimeMs": 30000,
"TcpProbeIntervalMs": 5000,
"TcpProbeCount": 4,
"BackendHeartbeatIdleMs": 10000,
"BackendHeartbeatProbeAddress": 0
}
}
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": { "Default": "Information" },
"WriteTo": [
{ "Name": "Console" }
]
}
}