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>
This commit is contained in:
1 parent
53604a6127
commit
00984e1a70
7 files changed
+958
-20
No files matched your search
@@ -83,6 +83,53 @@ 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); }
|
||||
|
||||
/* --- 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;
|
||||
@@ -131,6 +178,16 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
</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>
|
||||
|
||||
@@ -159,8 +216,11 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
let regex = null;
|
||||
const levelOn = { ERROR: true, WARN: true, INFO: true };
|
||||
|
||||
// log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message
|
||||
const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
||||
// 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);
|
||||
@@ -286,6 +346,125 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
@@ -331,6 +510,7 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
|
||||
|
||||
poll();
|
||||
pollSummary();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user