feat(monitor): opt-in browser notifications for player join/leave

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>
This commit is contained in:
oierandClaude Opus 5 committed 2026-08-02 03:38:33 +02:00
1 parent bac2372c95
commit 3eb20054a0
1 file changed
+129 -3
+129 -3
View File
@@ -83,6 +83,11 @@ input[type=search].bad-re { border-color: var(--red); }
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); } .chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); } .chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
.chip.bell { font-size: 13px; line-height: 1.15; padding: 4px 7px; margin-left: 4px; }
.chip.bell[data-state="on"] { color: var(--accent); border-color: var(--accent); }
.chip.bell[data-state="blocked"],
.chip.bell[data-state="unsupported"] { color: var(--red); cursor: not-allowed; }
/* --- dashboard tiles --- */ /* --- dashboard tiles --- */
#tiles { #tiles {
flex: none; flex: none;
@@ -176,6 +181,7 @@ input[type=search].bad-re { border-color: var(--red); }
<button class="chip" data-level="WARN" aria-pressed="true">WARN</button> <button class="chip" data-level="WARN" aria-pressed="true">WARN</button>
<button class="chip" data-level="INFO" aria-pressed="true">INFO</button> <button class="chip" data-level="INFO" aria-pressed="true">INFO</button>
</span> </span>
<button class="chip bell" id="bell" data-state="off" aria-pressed="false" title="join/leave notifications">🔕</button>
</header> </header>
<div id="tiles"> <div id="tiles">
@@ -348,7 +354,10 @@ input[type=search].bad-re { border-color: var(--red); }
// --- dashboard tiles ------------------------------------------------------ // --- dashboard tiles ------------------------------------------------------
const SUMMARY_MS = 10000; // tiles move far slower than the log does // Tiles move far slower than the log does. Poll harder only while join/leave
// notifications are on, since then the poll interval IS the alert latency.
const SUMMARY_IDLE_MS = 10000;
const SUMMARY_NOTIFY_MS = 5000;
const fmtBytes = (n) => { const fmtBytes = (n) => {
if (!n) return "0 B"; if (!n) return "0 B";
@@ -437,6 +446,8 @@ input[type=search].bad-re { border-color: var(--red); }
tile("t-backup", "none", "bad", "", "", d.backup_err); tile("t-backup", "none", "bad", "", "", d.backup_err);
} }
handleSessions(d.sessions);
// Recent joins/leaves, parsed out of the log we already hold. // Recent joins/leaves, parsed out of the log we already hold.
const box = document.querySelector("#t-sessions .sessions"); const box = document.querySelector("#t-sessions .sessions");
box.replaceChildren(); box.replaceChildren();
@@ -462,9 +473,122 @@ input[type=search].bad-re { border-color: var(--red); }
const res = await fetch("/api/summary", { cache: "no-store" }); const res = await fetch("/api/summary", { cache: "no-store" });
if (res.ok) renderSummary(await res.json()); if (res.ok) renderSummary(await res.json());
} catch { /* tiles keep their last values; the log poll owns the status dot */ } } catch { /* tiles keep their last values; the log poll owns the status dot */ }
finally { setTimeout(pollSummary, SUMMARY_MS); } finally { setTimeout(pollSummary, notifyOn ? SUMMARY_NOTIFY_MS : SUMMARY_IDLE_MS); }
} }
// --- join/leave notifications ---------------------------------------------
//
// Rides the same sessions[] the "Recent joins" tile renders — no second
// source, no re-parsing of the log in JS.
//
// The whole difficulty is not notifying for history, and there are two ways
// history shows up: the buffer already holds events when the page loads, and
// the monitor's cold-start backfill re-reads 5000 log lines on every deploy,
// repopulating the events ring with the SAME events. So newness is decided by
// a persisted seen-set keyed on the log's own timestamp — stable across both a
// page reload and a container restart, which a server-side counter would not
// be (it resets to zero and every event looks new again).
const NOTIFY_KEY = "ulicraft.monitor.notify";
const SEEN_KEY = "ulicraft.monitor.seen";
const SEEN_MAX = 500;
const BURST_MIN = 3; // more than this in one poll collapses to one notification
let notifyOn = localStorage.getItem(NOTIFY_KEY) === "1";
let seen = new Set(loadSeen());
let baselined = false;
function loadSeen() {
try { return JSON.parse(localStorage.getItem(SEEN_KEY)) || []; } catch { return []; }
}
function saveSeen() {
const arr = [...seen].slice(-SEEN_MAX); // Set preserves insertion order
seen = new Set(arr);
try { localStorage.setItem(SEEN_KEY, JSON.stringify(arr)); } catch { /* quota */ }
}
const evKey = (s) => `${s.time}|${s.player}|${s.action}`;
function handleSessions(sessions) {
if (!Array.isArray(sessions)) return;
// Server returns newest-first; walk backwards so `seen` stays chronological
// and the burst summary reads in the right order.
const fresh = [];
for (let i = sessions.length - 1; i >= 0; i--) {
const k = evKey(sessions[i]);
if (seen.has(k)) continue;
seen.add(k);
fresh.push(sessions[i]);
}
if (fresh.length) saveSeen();
// The first response after a page load is history by definition. Flip the
// flag unconditionally — doing it only when fresh.length > 0 would mean a
// returning user (everything already seen) burns their baseline on the
// FIRST REAL event instead, silently swallowing it.
const isBaseline = !baselined;
baselined = true;
if (isBaseline || !fresh.length) return;
if (!notifyOn || !("Notification" in window) || Notification.permission !== "granted") return;
if (fresh.length > BURST_MIN) {
// A server restart makes everyone rejoin at once; one line beats six.
const joined = fresh.filter(s => s.action === "joined").length;
const left = fresh.length - joined;
const parts = [];
if (joined) parts.push(`${joined} joined`);
if (left) parts.push(`${left} left`);
notify("Player activity", parts.join(", "), "burst:" + evKey(fresh[fresh.length - 1]));
} else {
for (const s of fresh) notify(`${s.player} ${s.action}`, s.time, evKey(s));
}
}
function notify(title, body, tag) {
try {
const n = new Notification(title, { body, tag }); // tag: identical events can't stack
n.onclick = () => { window.focus(); n.close(); };
} catch { /* some browsers throw outside a service worker; nothing to do */ }
}
const bellEl = document.getElementById("bell");
function paintBell() {
const supported = "Notification" in window;
const blocked = supported && Notification.permission === "denied";
const state = !supported ? "unsupported" : blocked ? "blocked" : notifyOn ? "on" : "off";
bellEl.dataset.state = state;
bellEl.setAttribute("aria-pressed", String(state === "on"));
bellEl.textContent = state === "on" ? "🔔" : "🔕";
bellEl.title = {
unsupported: "this browser has no Notification API",
// Say this out loud: "enabled it and nothing happened" is otherwise
// indistinguishable from the feature being broken.
blocked: "blocked in browser settings — allow notifications for this site",
on: "join/leave notifications ON",
off: "join/leave notifications off — click to enable",
}[state];
}
bellEl.addEventListener("click", async () => {
if (!("Notification" in window)) return;
if (notifyOn) {
notifyOn = false;
} else {
// requestPermission must be called from a user gesture — i.e. this click,
// never on page load.
if (Notification.permission === "default") {
try { await Notification.requestPermission(); } catch { /* ignore */ }
}
if (Notification.permission !== "granted") { paintBell(); return; }
notifyOn = true;
}
localStorage.setItem(NOTIFY_KEY, notifyOn ? "1" : "0");
paintBell();
});
// --- filter --------------------------------------------------------------- // --- filter ---------------------------------------------------------------
let debounce; let debounce;
@@ -489,7 +613,8 @@ input[type=search].bad-re { border-color: var(--red); }
}, 120); }, 120);
}); });
for (const chip of document.querySelectorAll(".chip")) { // [data-level] only — the bell is also a .chip but is not a level filter.
for (const chip of document.querySelectorAll(".chip[data-level]")) {
chip.addEventListener("click", () => { chip.addEventListener("click", () => {
const on = chip.getAttribute("aria-pressed") !== "true"; const on = chip.getAttribute("aria-pressed") !== "true";
chip.setAttribute("aria-pressed", String(on)); chip.setAttribute("aria-pressed", String(on));
@@ -509,6 +634,7 @@ input[type=search].bad-re { border-color: var(--red); }
logEl.addEventListener("scroll", updateLiveButton, { passive: true }); logEl.addEventListener("scroll", updateLiveButton, { passive: true });
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); }); liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
paintBell();
poll(); poll();
pollSummary(); pollSummary();
})(); })();