Files
ulicraft-server-v1/docker/monitor/ui/index.html
T
oierandClaude Opus 5 00984e1a70 feat(monitor): dashboard tiles + TPS via allowlisted RCON
Phases 2 and 3 of plan/23-monitor.md. /api/summary adds: server online/MOTD/
version, players online with NMSR heads, registered-account count, disk free on
the world volume, backup freshness, join/leave history, and TPS.

Each tile has its own TTL cache and its own failure mode. A failed collector
keeps serving its last good value and reports the reason in a sibling *_err
field, so the page goes stale-with-a-warning rather than blank. The five
collectors refresh concurrently: sequentially, one request during an outage
would cost the sum of every timeout (~15s), and an outage is exactly when
someone is looking at this page. Measured with a dead dependency: 0.24s.

TPS needs RCON, so the monitor now holds RCON_PASSWORD. Read-only survives
because the allowlist is a compile-time constant and no HTTP input reaches a
command string — the UI cannot run commands because the server cannot be asked
to. The Source RCON client is implemented in-repo rather than added as a
dependency precisely to keep that property local and auditable.

Two things the real server corrected:

- `neoforge tps` prints "Overall: 20.000 TPS (0.357 ms/tick)", not the older
  Forge "Mean tick time / Mean TPS" wording the plan assumed. main_test.go
  pins the real captured output so a format change fails a test instead of
  silently zeroing the tile.

- Rosters come from mc-status, which already holds the Drasl admin credentials
  and exposes a sanitized list, so this container does not get a second copy.

Also fixes a phase-1 bug: the UI's level regex assumed itzg's console timestamp
([12:34:56]) but the file uses [01Aug2026 22:55:34.358], so every line was
classified INFO and the ERROR/WARN chips did nothing. It now matches on shape.

The container runs as root deliberately — /data is drwxr-x--- owned by uid 1000,
so a non-root user cannot traverse it and the tailer would silently read
nothing. The :ro mounts and absent port are what bound the damage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:16:11 +02:00

518 lines
18 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); }
/* --- 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>
</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 ------------------------------------------------------
const SUMMARY_MS = 10000; // tiles move far slower than the log does
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);
}
// 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, SUMMARY_MS); }
}
// --- 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);
});
for (const chip of document.querySelectorAll(".chip")) {
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(); });
poll();
pollSummary();
})();
</script>
</body>
</html>