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>
518 lines
18 KiB
HTML
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>
|