A bell toggle in the header. Rides the same sessions[] the "Recent joins" tile
already renders — no second data source and no re-parsing of the log in JS.
UI-only: no server change, no new egress, no secret, and the container still
reaches nothing but mcnet.
The hard part is not notifying for history, which arrives two ways: the events
buffer is already populated when the page loads, and the monitor's cold-start
backfill re-reads 5000 log lines on every deploy, repopulating the ring with the
SAME events. So newness is a seen-set in localStorage keyed on the log's own
timestamp (time|player|action). That key is stable across both a page reload and
a container restart, where a server-side sequence number would reset to zero and
make every historical event look new.
One subtle ordering bug worth calling out, caught while writing the tests: the
first-response baseline flag must flip unconditionally, not only when fresh
events were found. A returning user has everything already in localStorage, so
their first response yields nothing fresh — flipping the flag lazily would burn
the baseline on their first REAL event instead and silently swallow it.
Notifications are opt-in and off by default; permission is requested from the
bell click (a user gesture), never on load. A denied permission shows a distinct
blocked state, because "I enabled it and nothing happened" is otherwise
indistinguishable from a bug. Bursts (>3 in one poll, e.g. everyone rejoining
after a restart) collapse to one "N joined, M left". Summary polling tightens
from 10s to 5s only while enabled, since the poll interval is the alert latency.
Also fixes a bug this would have introduced: the level-chip click handler bound
to every .chip, which now includes the bell. Scoped to .chip[data-level].
Verified by extracting the shipped block and running it against stubs: silent
baseline, new event notifies, repolling silent, restart-replay silent (both
same-instance and after reload), burst coalescing, the returning-user edge case,
toggle-off still tracks (no backlog when re-enabled), denied permission silent,
and the seen-set staying bounded at 500 newest keys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft
server's latest.log into an in-memory ring buffer and serves it to an embedded
single-page UI (autoscroll, substring/regex filter, level chips).
Read-only by construction — /data is mounted :ro, there is no mutating route,
and the container holds no credentials. It publishes NO port: caddy's
basic_auth vhost is the only way in, which is the point, since the service has
no auth of its own and the log carries player IPs, chat and admin commands.
Two non-obvious choices, both to avoid prod-only failures:
- Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers
proxied responses by default, so an EventSource would stall on cochi (whose
vhost is an untracked per-host file) and not reproduce locally. Delay was
accepted at design time, so polling is strictly better.
- The cursor is a sequence number, not a byte offset, and the tailer reopens on
inode change or size shrink. itzg rotates latest.log on every server restart,
i.e. every deploy — a naive tailer goes silent afterwards while still
answering HTTP 200. Verified against rotate, truncate, delete/recreate,
partial-line and ring-overflow cases.
MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE.
MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose
interpolates $-sequences in .env values, so a bare hash is silently mangled
($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why.
Confirmed empirically, and it is the opposite of filestash, which needs $6$.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>