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>
644 lines
23 KiB
HTML
644 lines
23 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>Ulicraft — monitor</title>
|
|
<style>
|
|
/* Palette lifted from the landing's default (grass) theme so this doesn't look
|
|
like a different product. See landing/src/styles/main.css. */
|
|
:root {
|
|
--bg: oklch(0.165 0.014 150);
|
|
--bg-2: oklch(0.205 0.016 150);
|
|
--surface: oklch(0.235 0.017 150);
|
|
--line: oklch(0.33 0.016 150);
|
|
--accent: oklch(0.72 0.16 145);
|
|
--accent-ink:oklch(0.17 0.05 150);
|
|
--gold: oklch(0.82 0.135 85);
|
|
--red: oklch(0.68 0.19 25);
|
|
--text: oklch(0.95 0.008 110);
|
|
--muted: oklch(0.72 0.012 145);
|
|
--dim: oklch(0.55 0.012 145);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
header {
|
|
flex: none;
|
|
background: var(--bg-2);
|
|
border-bottom: 1px solid var(--line);
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 8px 12px;
|
|
}
|
|
h1 { font-size: 14px; margin: 0; font-weight: 600; letter-spacing: .02em; }
|
|
h1 small { color: var(--dim); font-weight: 400; }
|
|
|
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); flex: none; }
|
|
.dot.ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
|
.dot.bad { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
|
|
|
.stat { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
.stat b { color: var(--text); font-weight: 600; }
|
|
.err { color: var(--red); font-size: 12px; }
|
|
.spacer { flex: 1 1 auto; }
|
|
|
|
input[type=search] {
|
|
flex: 1 1 220px;
|
|
min-width: 160px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
color: var(--text);
|
|
padding: 5px 8px;
|
|
font: inherit;
|
|
font-size: 13px;
|
|
}
|
|
input[type=search]:focus { outline: none; border-color: var(--accent); }
|
|
input[type=search].bad-re { border-color: var(--red); }
|
|
|
|
.chips { display: flex; gap: 4px; }
|
|
.chip {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
color: var(--dim);
|
|
padding: 4px 8px;
|
|
font: inherit;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.chip[aria-pressed="true"] { color: var(--text); border-color: var(--accent); }
|
|
.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;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
background: var(--bg-2);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.tile {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
min-width: 0;
|
|
}
|
|
.tile h2 {
|
|
margin: 0 0 4px;
|
|
font-size: 10.5px;
|
|
font-weight: 600;
|
|
letter-spacing: .08em;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
}
|
|
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
|
|
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
|
.tile .sub.warnish { color: var(--gold); }
|
|
.tile .sub.badish { color: var(--red); }
|
|
.good { color: var(--accent); }
|
|
.warn { color: var(--gold); }
|
|
.bad { color: var(--red); }
|
|
|
|
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
|
|
.bar > i { display: block; height: 100%; background: var(--accent); }
|
|
.bar > i.warn { background: var(--gold); }
|
|
.bar > i.bad { background: var(--red); }
|
|
|
|
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
|
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
|
|
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
|
|
|
|
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
|
|
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.sessions .j { color: var(--accent); }
|
|
.sessions .l { color: var(--dim); }
|
|
|
|
#log {
|
|
flex: 1 1 auto;
|
|
overflow: auto;
|
|
padding: 8px 12px 40px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
font-size: 12.5px;
|
|
line-height: 1.45;
|
|
}
|
|
.ln { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
.ln.error { color: var(--red); }
|
|
.ln.warn { color: var(--gold); }
|
|
.ln.meta { color: var(--dim); font-style: italic; padding: 4px 0; }
|
|
|
|
#live {
|
|
position: fixed;
|
|
right: 18px;
|
|
bottom: 18px;
|
|
background: var(--accent);
|
|
color: var(--accent-ink);
|
|
border: none;
|
|
border-radius: 999px;
|
|
padding: 8px 14px;
|
|
font: inherit;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
box-shadow: 0 2px 10px rgb(0 0 0 / .4);
|
|
}
|
|
#live[hidden] { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<span class="dot" id="dot"></span>
|
|
<h1>Ulicraft <small>latest.log</small></h1>
|
|
<span class="stat" id="age">—</span>
|
|
<span class="stat" id="counts">—</span>
|
|
<span class="err" id="error"></span>
|
|
<span class="spacer"></span>
|
|
<input type="search" id="filter" placeholder="filter… (/regex/ for regex, press / to focus)" spellcheck="false">
|
|
<span class="chips">
|
|
<button class="chip" data-level="ERROR" aria-pressed="true">ERROR</button>
|
|
<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">
|
|
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
|
|
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
|
|
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
|
|
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
|
|
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
|
|
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
|
|
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
|
|
</div>
|
|
|
|
<div id="log" role="log" aria-live="off"></div>
|
|
<button id="live" hidden>jump to live ↓</button>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
|
|
const MAX = 5000; // must not exceed the server's ring capacity
|
|
const POLL_MS = 2000; // delay is acceptable by design; see plan/23-monitor.md
|
|
const STICK_PX = 40; // "close enough to the bottom" tolerance
|
|
|
|
const logEl = document.getElementById("log");
|
|
const dotEl = document.getElementById("dot");
|
|
const ageEl = document.getElementById("age");
|
|
const countsEl = document.getElementById("counts");
|
|
const errEl = document.getElementById("error");
|
|
const filterEl = document.getElementById("filter");
|
|
const liveEl = document.getElementById("live");
|
|
|
|
// records: {text, level, rendered}. `rendered` tracks whether a DOM node
|
|
// exists for this record, so trimming can remove exactly the right number of
|
|
// leading nodes without a full re-render on every poll.
|
|
let records = [];
|
|
let seq = null; // null => first fetch, ask for the whole buffer
|
|
let needle = "";
|
|
let regex = null;
|
|
const levelOn = { ERROR: true, WARN: true, INFO: true };
|
|
|
|
// Two bracket groups, then the level: the timestamp format differs between
|
|
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
|
|
// so match the *shape* rather than the timestamp:
|
|
// [<anything>] [<thread>/LEVEL] [<logger>]: message
|
|
const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
|
|
|
function levelOf(text) {
|
|
const m = LEVEL_RE.exec(text);
|
|
if (!m) return "INFO"; // stack traces, mod spam, etc.
|
|
const lv = m[1];
|
|
if (lv === "ERROR" || lv === "FATAL" || lv === "SEVERE") return "ERROR";
|
|
if (lv === "WARN" || lv === "WARNING") return "WARN";
|
|
return "INFO";
|
|
}
|
|
|
|
function passes(rec) {
|
|
if (rec.level === "META") return true; // dropped-lines markers always show
|
|
if (!levelOn[rec.level]) return false;
|
|
if (regex) return regex.test(rec.text);
|
|
if (needle) return rec.text.toLowerCase().includes(needle);
|
|
return true;
|
|
}
|
|
|
|
function nodeFor(rec) {
|
|
const el = document.createElement("div");
|
|
el.className = "ln " + rec.level.toLowerCase();
|
|
el.textContent = rec.text; // textContent, never innerHTML
|
|
return el;
|
|
}
|
|
|
|
const atBottom = () =>
|
|
logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < STICK_PX;
|
|
|
|
const toBottom = () => { logEl.scrollTop = logEl.scrollHeight; };
|
|
|
|
function addRecords(recs) {
|
|
const stick = atBottom();
|
|
const frag = document.createDocumentFragment();
|
|
for (const rec of recs) {
|
|
records.push(rec);
|
|
if (passes(rec)) { frag.appendChild(nodeFor(rec)); rec.rendered = true; }
|
|
}
|
|
logEl.appendChild(frag);
|
|
trim();
|
|
if (stick) toBottom();
|
|
updateCounts();
|
|
updateLiveButton();
|
|
}
|
|
|
|
function trim() {
|
|
const excess = records.length - MAX;
|
|
if (excess <= 0) return;
|
|
let nodes = 0;
|
|
for (let i = 0; i < excess; i++) if (records[i].rendered) nodes++;
|
|
records.splice(0, excess);
|
|
for (let i = 0; i < nodes && logEl.firstChild; i++) logEl.removeChild(logEl.firstChild);
|
|
}
|
|
|
|
// Full rebuild — only on a filter change, never on a poll.
|
|
function rerender() {
|
|
const frag = document.createDocumentFragment();
|
|
for (const rec of records) {
|
|
rec.rendered = passes(rec);
|
|
if (rec.rendered) frag.appendChild(nodeFor(rec));
|
|
}
|
|
logEl.replaceChildren(frag);
|
|
toBottom();
|
|
updateCounts();
|
|
updateLiveButton();
|
|
}
|
|
|
|
function reset() {
|
|
records = [];
|
|
logEl.replaceChildren();
|
|
}
|
|
|
|
function updateCounts() {
|
|
const shown = logEl.childElementCount;
|
|
countsEl.textContent = shown === records.length
|
|
? `${records.length} lines`
|
|
: `${shown} / ${records.length} lines`;
|
|
}
|
|
|
|
function updateLiveButton() { liveEl.hidden = atBottom(); }
|
|
|
|
function updateTail(tail) {
|
|
if (!tail) return;
|
|
if (!tail.ok) {
|
|
dotEl.className = "dot bad";
|
|
errEl.textContent = tail.error || "log unavailable";
|
|
ageEl.textContent = "—";
|
|
return;
|
|
}
|
|
errEl.textContent = "";
|
|
dotEl.className = "dot ok";
|
|
// Browser vs container clock skew can make this slightly off; clamped at 0.
|
|
const age = Math.max(0, Math.floor(Date.now() / 1000) - tail.mtime);
|
|
const rot = tail.rotations ? ` · ${tail.rotations} rotation${tail.rotations > 1 ? "s" : ""}` : "";
|
|
ageEl.innerHTML = `last write <b>${age}s</b> ago${rot}`;
|
|
}
|
|
|
|
async function poll() {
|
|
try {
|
|
const url = seq === null ? "/api/log" : "/api/log?since=" + seq;
|
|
const res = await fetch(url, { cache: "no-store" });
|
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
const data = await res.json();
|
|
|
|
if (data.reset) {
|
|
reset();
|
|
if (data.dropped > 0) {
|
|
addRecords([{ text: `— fell behind, ${data.dropped} line(s) skipped —`, level: "META", rendered: false }]);
|
|
}
|
|
}
|
|
seq = data.seq;
|
|
|
|
if (data.lines.length) {
|
|
addRecords(data.lines.map(text => ({ text, level: levelOf(text), rendered: false })));
|
|
} else {
|
|
updateCounts();
|
|
}
|
|
updateTail(data.tail);
|
|
} catch (e) {
|
|
dotEl.className = "dot bad";
|
|
errEl.textContent = String(e.message || e);
|
|
} finally {
|
|
setTimeout(poll, POLL_MS);
|
|
}
|
|
}
|
|
|
|
// --- dashboard tiles ------------------------------------------------------
|
|
|
|
// 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";
|
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
|
|
};
|
|
|
|
const fmtAge = (s) => {
|
|
if (s == null) return "—";
|
|
if (s < 60) return s + "s ago";
|
|
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
|
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
|
return Math.floor(s / 86400) + "d ago";
|
|
};
|
|
|
|
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
|
|
// over the value: a stale number with no warning would be a lie.
|
|
function tile(id, big, bigClass, sub, subClass, err) {
|
|
const el = document.getElementById(id);
|
|
el.querySelector(".big").textContent = big;
|
|
el.querySelector(".big").className = "big " + (bigClass || "");
|
|
const s = el.querySelector(".sub");
|
|
if (s) {
|
|
s.textContent = err ? err : (sub || "");
|
|
s.className = "sub " + (err ? "badish" : (subClass || ""));
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function renderSummary(d) {
|
|
// Server
|
|
tile("t-server",
|
|
d.server.online ? "online" : "offline",
|
|
d.server.online ? "good" : "bad",
|
|
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
|
|
"", d.server_err);
|
|
|
|
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
|
|
if (d.tps) {
|
|
const t = d.tps.tps;
|
|
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
|
|
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
|
|
} else {
|
|
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
|
|
}
|
|
|
|
// Online players + heads (rendered by NMSR on its own public origin)
|
|
const pl = d.server.players || [];
|
|
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
|
|
const heads = document.querySelector("#t-players .heads");
|
|
heads.replaceChildren();
|
|
for (const p of pl) {
|
|
const wrap = document.createElement("span");
|
|
wrap.className = "head";
|
|
if (d.avatar_url && p.uuid) {
|
|
const img = document.createElement("img");
|
|
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
|
|
img.alt = "";
|
|
img.loading = "lazy";
|
|
wrap.appendChild(img);
|
|
}
|
|
const nm = document.createElement("span");
|
|
nm.textContent = p.name || "?";
|
|
wrap.appendChild(nm);
|
|
heads.appendChild(wrap);
|
|
}
|
|
|
|
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
|
|
|
|
// Disk — the failure that actually takes the server down.
|
|
const used = d.disk.used_pct || 0;
|
|
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
|
|
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
|
|
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
|
|
const bar = document.querySelector("#t-disk .bar > i");
|
|
bar.style.width = Math.min(100, used) + "%";
|
|
bar.className = cls === "good" ? "" : cls;
|
|
|
|
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
|
|
if (d.backup && d.backup.count) {
|
|
const age = d.backup.age_seconds;
|
|
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
|
|
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
|
|
} else {
|
|
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();
|
|
if (!d.sessions || !d.sessions.length) {
|
|
const e = document.createElement("div");
|
|
e.textContent = "no joins in buffer";
|
|
box.appendChild(e);
|
|
} else {
|
|
for (const s of d.sessions.slice(0, 12)) {
|
|
const row = document.createElement("div");
|
|
const who = document.createElement("span");
|
|
who.className = s.action === "joined" ? "j" : "l";
|
|
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
|
|
row.appendChild(who);
|
|
row.appendChild(document.createTextNode(" " + (s.time || "")));
|
|
box.appendChild(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pollSummary() {
|
|
try {
|
|
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, 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;
|
|
filterEl.addEventListener("input", () => {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(() => {
|
|
const raw = filterEl.value.trim();
|
|
const re = raw.length > 1 && raw.startsWith("/") ? raw.lastIndexOf("/") : -1;
|
|
regex = null;
|
|
needle = "";
|
|
filterEl.classList.remove("bad-re");
|
|
if (re > 0) {
|
|
try {
|
|
regex = new RegExp(raw.slice(1, re), raw.slice(re + 1) || "i");
|
|
} catch {
|
|
filterEl.classList.add("bad-re"); // keep showing everything, don't lie
|
|
}
|
|
} else {
|
|
needle = raw.toLowerCase();
|
|
}
|
|
rerender();
|
|
}, 120);
|
|
});
|
|
|
|
// [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));
|
|
levelOn[chip.dataset.level] = on;
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "/" && document.activeElement !== filterEl) {
|
|
e.preventDefault();
|
|
filterEl.focus();
|
|
filterEl.select();
|
|
}
|
|
});
|
|
|
|
logEl.addEventListener("scroll", updateLiveButton, { passive: true });
|
|
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
|
|
|
|
paintBell();
|
|
poll();
|
|
pollSummary();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|