From 3eb20054a0f2761664681075be1bd7358443c69d Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Sun, 2 Aug 2026 03:38:33 +0200 Subject: [PATCH] feat(monitor): opt-in browser notifications for player join/leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker/monitor/ui/index.html | 132 ++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 3 deletions(-) diff --git a/docker/monitor/ui/index.html b/docker/monitor/ui/index.html index a4a6f1e..6b4b8a0 100644 --- a/docker/monitor/ui/index.html +++ b/docker/monitor/ui/index.html @@ -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=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 --- */ #tiles { flex: none; @@ -176,6 +181,7 @@ input[type=search].bad-re { border-color: var(--red); } +
@@ -348,7 +354,10 @@ input[type=search].bad-re { border-color: var(--red); } // --- 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) => { 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); } + handleSessions(d.sessions); + // Recent joins/leaves, parsed out of the log we already hold. const box = document.querySelector("#t-sessions .sessions"); box.replaceChildren(); @@ -462,9 +473,122 @@ input[type=search].bad-re { border-color: var(--red); } const res = await fetch("/api/summary", { cache: "no-store" }); if (res.ok) renderSummary(await res.json()); } 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 --------------------------------------------------------------- let debounce; @@ -489,7 +613,8 @@ input[type=search].bad-re { border-color: var(--red); } }, 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", () => { const on = chip.getAttribute("aria-pressed") !== "true"; 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 }); liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); }); + paintBell(); poll(); pollSummary(); })();