@@ -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();
})();