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:
1 parent
bac2372c95
commit
3eb20054a0
1 file changed
+129
-3
@@ -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); }
|
||||
<button class="chip" data-level="WARN" aria-pressed="true">WARN</button>
|
||||
<button class="chip" data-level="INFO" aria-pressed="true">INFO</button>
|
||||
</span>
|
||||
<button class="chip bell" id="bell" data-state="off" aria-pressed="false" title="join/leave notifications">🔕</button>
|
||||
</header>
|
||||
|
||||
<div id="tiles">
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in new issue
Block a user