Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft server's latest.log into an in-memory ring buffer and serves it to an embedded single-page UI (autoscroll, substring/regex filter, level chips). Read-only by construction — /data is mounted :ro, there is no mutating route, and the container holds no credentials. It publishes NO port: caddy's basic_auth vhost is the only way in, which is the point, since the service has no auth of its own and the log carries player IPs, chat and admin commands. Two non-obvious choices, both to avoid prod-only failures: - Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers proxied responses by default, so an EventSource would stall on cochi (whose vhost is an untracked per-host file) and not reproduce locally. Delay was accepted at design time, so polling is strictly better. - The cursor is a sequence number, not a byte offset, and the tailer reopens on inode change or size shrink. itzg rotates latest.log on every server restart, i.e. every deploy — a naive tailer goes silent afterwards while still answering HTTP 200. Verified against rotate, truncate, delete/recreate, partial-line and ring-overflow cases. MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE. MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose interpolates $-sequences in .env values, so a bare hash is silently mangled ($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why. Confirmed empirically, and it is the opposite of filestash, which needs $6$. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
338 lines
10 KiB
HTML
338 lines
10 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); }
|
|
|
|
#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="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 };
|
|
|
|
// log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message
|
|
const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\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);
|
|
}
|
|
}
|
|
|
|
// --- 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();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|