docs(monitor): document join/leave browser notifications

Records why it is browser-only rather than chat push (a push channel needs a CA
bundle in the scratch image, a token, and outbound egress this container does
not have), why newness is a localStorage seen-set keyed on the log timestamp
rather than a server-side sequence number (the counter resets on restart, the
timestamp does not), and the unconditional-baseline subtlety.

Also states plainly what the node harness does NOT cover: the real permission
prompt and actual notification rendering are stubbed, so those are untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
oierandClaude Opus 5 committed 2026-08-02 03:39:47 +02:00
1 parent 3eb20054a0
commit d105fc9acf
3 files changed
+77 -1

No files matched your search

+2 -1
View File
@@ -251,7 +251,8 @@ Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
**outside mc-backup** and unquota'd (accepted — eyeballed). **outside mc-backup** and unquota'd (accepted — eyeballed).
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream + - [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups, dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
joins/leaves). Live on cochi, all three phases done. See `plan/23-monitor.md`. joins/leaves) + opt-in browser notifications on join/leave (bell toggle,
client-side only). Live on cochi, all four phases done. See `plan/23-monitor.md`.
Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`. Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`.
- [ ] **Verify NeoForge version** against current stable before first deploy. - [ ] **Verify NeoForge version** against current stable before first deploy.
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is - [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
+4
View File
@@ -169,6 +169,10 @@ ERROR/WARN/INFO chips, above a row of tiles: server status, TPS, players online
(with NMSR heads), registered accounts, disk free, backup freshness and recent (with NMSR heads), registered accounts, disk free, backup freshness and recent
joins/leaves. joins/leaves.
The bell in the header turns on **browser notifications for player join/leave**.
Opt-in, off by default, remembered per browser; it fires only while the page is
open (a background tab counts, a closed one doesn't).
**Admin only, and read-only.** The log carries player IPs, chat and admin **Admin only, and read-only.** The log carries player IPs, chat and admin
commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy
`basic_auth`. There are no controls in the UI — every route is a `GET`. `basic_auth`. There are no controls in the UI — every route is a `GET`.
+71
View File
@@ -135,6 +135,58 @@ 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 `/api/summary` every 10s and `/api/log` every 2s — tiles move far more slowly
than the log does. than the log does.
## Join/leave browser notifications (opt-in)
A bell toggle in the header raises a desktop notification when a player joins or
leaves. It rides the **same `sessions[]`** the Recent joins tile renders — no
second data source, no re-parsing of the log in JS. Entirely client-side:
no server change, no new egress, no secret. The container still reaches nothing
outside `mcnet`.
Deliberately **not** a push channel. Chat push (Telegram/Discord/ntfy) would
reach a phone with nothing open, but it needs a CA bundle in the `scratch` image
(this container currently makes no TLS calls at all), a token in `.env`, and
outbound internet egress it does not have today. Browser notifications cost none
of that, at the price of only firing while the page is open — a background tab
is fine, a closed tab or sleeping laptop is not.
### Newness is a seen-set keyed on the log timestamp
The whole difficulty is *not* notifying for history, and history arrives two
ways: the events ring is already populated when the page loads, and **the
cold-start backfill re-reads 5000 log lines on every deploy**, repopulating the
ring with the same events.
So the client keeps a `time|player|action` seen-set in `localStorage` (capped at
the 500 newest keys). That key is stable across both a page reload and a
container restart. A server-side sequence number would **not** be — it resets to
zero on restart, so every historical event would look new and every deploy would
fire a notification burst.
### The baseline flag must flip unconditionally
The first response after a page load is history by definition, so it seeds the
seen-set and notifies nothing. The subtle part, caught by the tests rather than
by reading the code: that flag has to flip **whether or not any fresh events were
found**. A returning user already has every key 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.
### Other behaviour
- **Opt-in, off by default.** Permission is requested from the bell click (a
user gesture), never on page load.
- **A denied permission shows a distinct blocked state**, because "I enabled it
and nothing happened" is otherwise indistinguishable from a bug.
- **Bursts coalesce**: more than 3 events in one poll (everyone rejoining after a
restart) becomes one "N joined, M left".
- **While enabled, `/api/summary` polls every 5s instead of 10s** — with this
feature on, the poll interval *is* the alert latency.
- Events are marked seen even while the toggle is off, so enabling it does not
replay a backlog.
- Each notification carries a `tag` derived from its event key, so a duplicate
cannot stack.
### Running as root, on purpose ### Running as root, on purpose
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user `/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
@@ -322,6 +374,25 @@ one-container restart.
- [x] Confirmed `neoforge tps` is the right command, and captured its real - [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). output format (see *RCON* above — it is not what this doc first assumed).
### Phase 4 — join/leave browser notifications — **DONE, live 2026-08-02**
- [x] Bell toggle, opt-in, persisted in `localStorage`; permission requested from
the click gesture; blocked state surfaced.
- [x] Seen-set newness detection + unconditional first-response baseline.
- [x] Burst coalescing; 5s poll while enabled.
- [x] Fixed a bug this introduced: the level-chip handler bound to every
`.chip`, which now includes the bell. Scoped to `.chip[data-level]`.
Verified by extracting the shipped block from `index.html` and running it in node
against stubbed `localStorage`/`Notification`: silent baseline, new event
notifies, re-polling silent, restart-replay silent (same instance *and* after a
reload), burst coalescing counts, the returning-user edge case, toggle-off still
tracking, denied permission silent, seen-set bounded at the 500 newest keys.
**Not covered by that harness:** the actual browser permission prompt and whether
notifications render — those need a real browser and were not tested. Everything
downstream of `Notification.permission === "granted"` is stubbed.
### Follow-ups ### Follow-ups
- [ ] Uptime Kuma HTTP monitor against `http://monitor:8080/healthz` (internal, - [ ] Uptime Kuma HTTP monitor against `http://monitor:8080/healthz` (internal,