feat(monitor): dashboard tiles + TPS via allowlisted RCON
Phases 2 and 3 of plan/23-monitor.md. /api/summary adds: server online/MOTD/ version, players online with NMSR heads, registered-account count, disk free on the world volume, backup freshness, join/leave history, and TPS. Each tile has its own TTL cache and its own failure mode. A failed collector keeps serving its last good value and reports the reason in a sibling *_err field, so the page goes stale-with-a-warning rather than blank. The five collectors refresh concurrently: sequentially, one request during an outage would cost the sum of every timeout (~15s), and an outage is exactly when someone is looking at this page. Measured with a dead dependency: 0.24s. TPS needs RCON, so the monitor now holds RCON_PASSWORD. Read-only survives because the allowlist is a compile-time constant and no HTTP input reaches a command string — the UI cannot run commands because the server cannot be asked to. The Source RCON client is implemented in-repo rather than added as a dependency precisely to keep that property local and auditable. Two things the real server corrected: - `neoforge tps` prints "Overall: 20.000 TPS (0.357 ms/tick)", not the older Forge "Mean tick time / Mean TPS" wording the plan assumed. main_test.go pins the real captured output so a format change fails a test instead of silently zeroing the tile. - Rosters come from mc-status, which already holds the Drasl admin credentials and exposes a sanitized list, so this container does not get a second copy. Also fixes a phase-1 bug: the UI's level regex assumed itzg's console timestamp ([12:34:56]) but the file uses [01Aug2026 22:55:34.358], so every line was classified INFO and the ERROR/WARN chips did nothing. It now matches on shape. The container runs as root deliberately — /data is drwxr-x--- owned by uid 1000, so a non-root user cannot traverse it and the tailer would silently read nothing. The :ro mounts and absent port are what bound the damage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
53604a6127
commit
00984e1a70
7 files changed
+958
-20
No files matched your search
+63
-12
@@ -118,6 +118,31 @@ owns up/down and uptime *history* — do not rebuild that here.
|
||||
walk (expensive, low value, cache-or-nothing), container health (needs the
|
||||
docker socket).
|
||||
|
||||
### `/api/summary` — one response, independently-degrading parts
|
||||
|
||||
Every tile has its own TTL cache and its own failure mode (`collect.go`). A
|
||||
collector that fails keeps serving its last good value and reports the reason in
|
||||
a sibling `*_err` field, so the page shows *stale with a warning* rather than a
|
||||
blank tile or a spinner that never resolves. Verified: with mc-status killed,
|
||||
`server_err` is populated while the disk, backup and log tiles keep working.
|
||||
|
||||
The five collectors are refreshed **concurrently**. Sequentially, one request
|
||||
during an outage would cost the sum of every timeout (~15s with mc-status
|
||||
hanging and RCON blackholed) — and an outage is precisely when someone is
|
||||
staring at the page. Measured with a dead dependency: 0.24s.
|
||||
|
||||
TTLs: status 15s, roster 60s, disk 60s, backup 60s, TPS 30s. The UI polls
|
||||
`/api/summary` every 10s and `/api/log` every 2s — tiles move far more slowly
|
||||
than the log does.
|
||||
|
||||
### Running as root, on purpose
|
||||
|
||||
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
|
||||
cannot traverse it, so the tailer would read nothing — silently. The container
|
||||
therefore runs as root, and what actually bounds the damage is that every mount
|
||||
is `:ro` and no port is published. Do not "harden" this by adding a `USER`
|
||||
directive without re-checking those permissions first.
|
||||
|
||||
## RCON — the one credential, and why it doesn't break constraint 2
|
||||
|
||||
TPS is the number you actually want when someone says "the server is lagging",
|
||||
@@ -129,10 +154,29 @@ Constraint 2 survives because the UI never sends a command: the poller runs a
|
||||
code path from an HTTP request to an RCON string. Hardcode the allowlist so a
|
||||
future refactor can't quietly turn this into a web console.
|
||||
|
||||
- **The command is `/neoforge tps` on NeoForge 1.21.1**, not `/forge tps`.
|
||||
Verify against the running server before wiring it, and treat an
|
||||
unknown-command reply as "TPS unavailable" rather than letting it kill the
|
||||
poll loop.
|
||||
- **The command is `neoforge tps` on NeoForge 1.21.1**, not `forge tps`.
|
||||
Confirmed on the running server; `forge tps` answers *"Unknown or incomplete
|
||||
command"*. An unknown-command reply is treated as "TPS unavailable" rather
|
||||
than being allowed to kill the poll loop.
|
||||
- **The output format is NOT the old Forge "Mean tick time / Mean TPS" wording**
|
||||
this doc originally assumed. Actual, captured from prod:
|
||||
|
||||
```
|
||||
Overworld: 20.000 TPS (0.298 ms/tick)
|
||||
The Nether: 20.000 TPS (0.014 ms/tick)
|
||||
Overall: 20.000 TPS (0.357 ms/tick)
|
||||
```
|
||||
|
||||
One line per dimension plus an `Overall:` line, with `§`/ANSI colour codes
|
||||
mixed in that have to be stripped. `main_test.go` pins this against the real
|
||||
captured string — if a NeoForge update changes the wording, that test fails
|
||||
instead of the tile silently reading zero.
|
||||
- The RCON client is implemented in-repo (`rcon.go`, ~120 lines of the Source
|
||||
RCON protocol) rather than pulled in as a dependency, specifically so the
|
||||
allowlist is a compile-time constant with no code path from HTTP input to a
|
||||
command string. `main_test.go` asserts `op`, `stop`, `say`, `ban` and
|
||||
near-misses like `LIST` and a trailing-space `"neoforge tps "` are all
|
||||
rejected.
|
||||
- Alternative considered: the `spark` mod. Better data (real profiling), but a
|
||||
165th jar into a pack already over its stated ceiling, plus its own web
|
||||
endpoint to secure. Rejected for now; still the upgrade path if RCON TPS
|
||||
@@ -259,17 +303,24 @@ one-container restart.
|
||||
- [x] nginx vhost + `MONITOR_ENABLED` gate; render + reload.
|
||||
- [x] Deploy with `--hard` (world downtime, 0 players online). Verified below.
|
||||
|
||||
### Phase 2 — tiles
|
||||
### Phase 2 — tiles — **DONE**
|
||||
|
||||
- [ ] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
|
||||
- [ ] mc-status reuse: online/MOTD/icon, players online w/ NMSR heads,
|
||||
registered count.
|
||||
- [ ] Join/leave session history parsed from the buffer.
|
||||
- [x] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
|
||||
- [x] mc-status reuse: online/MOTD, players online w/ NMSR heads,
|
||||
registered count. (Icon skipped — the MOTD and version carry the same
|
||||
information in a row of small tiles, and the favicon is a base64 blob.)
|
||||
- [x] Join/leave session history parsed from the buffer.
|
||||
- [x] **Fixed a phase-1 bug found while building this:** the UI's level regex
|
||||
assumed itzg's console format (`[12:34:56]`), but the *file* uses
|
||||
`[01Aug2026 22:55:34.358]`. Every line was classified INFO, so the
|
||||
ERROR/WARN chips silently did nothing. Now matches on shape
|
||||
(`[…] […/LEVEL]`) instead of on the timestamp.
|
||||
|
||||
### Phase 3 — TPS
|
||||
### Phase 3 — TPS — **DONE**
|
||||
|
||||
- [ ] RCON client, compile-time allowlist, 30s poll.
|
||||
- [ ] Confirm `/neoforge tps` is the right command on the running server.
|
||||
- [x] RCON client, compile-time allowlist, 30s poll.
|
||||
- [x] Confirmed `neoforge tps` is the right command, and captured its real
|
||||
output format (see *RCON* above — it is not what this doc first assumed).
|
||||
|
||||
### Follow-ups
|
||||
|
||||
|
||||
Reference in new issue
Block a user