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:
@@ -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[]`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user