doctype html html(lang="en") head meta(charset="utf-8") title admin link(rel="icon", href="/graphyellow.svg") style. body { background: #850000; color: #FEBA7D; font: 12px ui-monospace, monospace; margin: 1rem; } .tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } .tabs button { color: #FEBA7D; background: none; border: 1px solid transparent; font: inherit; cursor: pointer; padding: 4px 10px; border-radius: 3px; } .tabs button:hover { background: #9D1A12; border-color: #C7643F; } .tabs button.active { font-weight: bold; color: #850000; background: #FEBA7D; border-color: #FEBA7D; cursor: default; } .toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; } .toolbar button { color: #FEBA7D; background: none; border: none; font: inherit; cursor: pointer; padding: 0 2px; } .toolbar button:hover { color: #FEBA7D; text-decoration: underline; } .toolbar button.active { font-weight: bold; color: #FEBA7D; cursor: default; } .toolbar input { background: #6C0000; color: #FEBA7D; font: inherit; padding: 2px 6px; border: 1px solid #C7643F; border-radius: 3px; width: 16rem; } .toolbar input::placeholder { color: #E49768; } table { border-collapse: collapse; width: 100%; } th, td { padding: 4px 8px; border-bottom: 0.5px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; } td { color: #5C0000; } th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; } tr:hover { background: #9D1A12; } .num { text-align: right; } .name, .mod { color: #FEBA7D; } .src { color: #5C0000; } #status { margin-left: auto; color: #FEBA7D; } .disclosure { display: inline-block; width: 1em; cursor: pointer; color: #FEBA7D; user-select: none; } .disclosure.leaf { cursor: default; visibility: hidden; } .pid-cell { white-space: pre; } section[hidden] { display: none; } script(src="https://frm.so/_/code/quill.js") body nav.tabs button#tab-processes.active processes button#tab-modules modules button#tab-logs logs button#tab-vm-memory vm memory span#status connecting… section#panel-processes .toolbar button#p-mine.active mine button#p-all all span | button#p-expand expand all button#p-collapse collapse all table thead tr th pid th name th initial call th.num memory (KB) th.num tree memory (KB) th.num msgs th status tbody#p-rows section#panel-modules(hidden) .toolbar button#m-mine.active mine button#m-all all input#m-filter(placeholder="filter…", autocomplete="off") button#m-refresh refresh table thead tr th module th app th source tbody#m-rows section#panel-logs(hidden) .toolbar button#l-refresh refresh table thead tr th time (Chicago) th source ip th host th method th path th.num status th.num duration ms tbody#l-rows section#panel-vm-memory(hidden) .toolbar button#v-refresh refresh table thead tr th category th.num bytes th.num KB th.num MB tbody#v-rows script. // ── shared infrastructure ────────────────────────────────────── const status = document.getElementById("status"); const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/admin/ws"; const ws = new WebSocket(url); // Per-message-type dispatch so each panel registers its own handler. const handlers = {}; ws.addEventListener("message", (e) => { const msg = JSON.parse(e.data); const h = handlers[msg.type]; if (h) h(msg); }); function send(obj) { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } function addCell(tr, text, cls) { const td = document.createElement("td"); if (cls) td.className = cls; td.textContent = text; tr.appendChild(td); } // ── tabs ─────────────────────────────────────────────────────── const tabs = { processes: { btn: document.getElementById("tab-processes"), panel: document.getElementById("panel-processes") }, modules: { btn: document.getElementById("tab-modules"), panel: document.getElementById("panel-modules") }, logs: { btn: document.getElementById("tab-logs"), panel: document.getElementById("panel-logs") }, vmMemory: { btn: document.getElementById("tab-vm-memory"), panel: document.getElementById("panel-vm-memory") }, }; const tabRoutes = { processes: "processes", modules: "modules", logs: "logs", vmMemory: "vm-memory", }; let active = "processes"; function tabFromLocation() { const segment = location.pathname.split("/").filter(Boolean)[1]; return Object.keys(tabRoutes).find(name => tabRoutes[name] === segment) || "processes"; } function setAdminPath(name, mode) { const nextPath = "/admin/" + tabRoutes[name]; if (location.pathname === nextPath) return; const nextUrl = nextPath + location.search + location.hash; if (mode === "replace") history.replaceState({tab: name}, "", nextUrl); else history.pushState({tab: name}, "", nextUrl); } function activate(name, mode) { active = name; setAdminPath(name, mode); for (const [k, t] of Object.entries(tabs)) { t.btn.classList.toggle("active", k === name); t.panel.hidden = k !== name; } if (name === "modules" && !mLoaded) pollModules(); if (name === "logs" && !lLoaded) pollLogs(); if (name === "vmMemory" && !vLoaded) pollVmMemory(); } tabs.processes.btn.addEventListener("click", () => activate("processes")); tabs.modules.btn.addEventListener("click", () => activate("modules")); tabs.logs.btn.addEventListener("click", () => activate("logs")); tabs.vmMemory.btn.addEventListener("click", () => activate("vmMemory")); window.addEventListener("popstate", () => activate(tabFromLocation(), "replace")); // ── processes panel ──────────────────────────────────────────── const pBody = document.getElementById("p-rows"); const pAll = document.getElementById("p-all"); const pMine = document.getElementById("p-mine"); const pExpand = document.getElementById("p-expand"); const pCollapse = document.getElementById("p-collapse"); let pFilterMine = $("#p-mine").classList.contains("active"); let pLastRows = []; const pCollapsed = new Set(); const pAutoCollapsed = new Set(); const pSeenLarge = new Set(); function pSetFilter(mine) { pFilterMine = mine; pAll.classList.toggle("active", !mine); pMine.classList.toggle("active", mine); pRender(); } pAll.addEventListener("click", () => pSetFilter(false)); pMine.addEventListener("click", () => pSetFilter(true)); pExpand.addEventListener("click", () => { pCollapsed.clear(); pAutoCollapsed.clear(); for (const pid of pLargeGroupPids()) pSeenLarge.add(pid); pRender(); }); pCollapse.addEventListener("click", () => { const haveChildren = new Set(pLastRows.map(r => r.parent).filter(Boolean)); const topPid = pPrimaryRootProcessPid(); pCollapsed.clear(); pAutoCollapsed.clear(); for (const pid of haveChildren) { if (pid !== topPid) pCollapsed.add(pid); } pRender(); }); function pBuildTree(rows) { const byPid = new Map(); for (const r of rows) byPid.set(r.pid, Object.assign({}, r, {children: []})); const roots = []; for (const node of byPid.values()) { const parent = node.parent && byPid.get(node.parent); if (parent) parent.children.push(node); else roots.push(node); } return roots; } function pRender() { const rows = pFilterMine ? pLastRows.filter(r => r.mine) : pLastRows; const tree = pBuildTree(rows); const mineN = pLastRows.filter(r => r.mine).length; pAll.textContent = "all (" + pLastRows.length + ")"; pMine.textContent = "mine (" + mineN + ")"; const frag = document.createDocumentFragment(); for (const node of tree) pRenderNode(node, 0, frag); pBody.replaceChildren(frag); } function pRenderNode(node, depth, frag) { const tr = document.createElement("tr"); if (node.mine) tr.className = "mine"; const pidTd = document.createElement("td"); pidTd.className = "pid-cell"; pidTd.style.paddingLeft = (8 + depth * 16) + "px"; const disc = document.createElement("span"); disc.className = "disclosure" + (node.children.length ? "" : " leaf"); disc.textContent = node.children.length === 0 ? "·" : pCollapsed.has(node.pid) ? "▸" : "▾"; if (node.children.length) { disc.addEventListener("click", () => { pSeenLarge.add(node.pid); pAutoCollapsed.delete(node.pid); if (pCollapsed.has(node.pid)) pCollapsed.delete(node.pid); else pCollapsed.add(node.pid); pRender(); }); } pidTd.appendChild(disc); pidTd.appendChild(document.createTextNode(" " + node.pid)); tr.appendChild(pidTd); addCell(tr, node.name || "-", node.name ? "name" : ""); addCell(tr, node.initial_call); addCell(tr, node.memory_kb, "num"); addCell(tr, node.tree_memory_kb || node.memory_kb, "num"); addCell(tr, node.msgs, "num"); addCell(tr, node.status); frag.appendChild(tr); if (!pCollapsed.has(node.pid)) { for (const child of node.children) pRenderNode(child, depth + 1, frag); } } function pollProcesses() { send({type: "list_processes"}); } handlers["processes"] = (msg) => { pLastRows = msg.rows; pAutoCollapseLargeGroups(); pRender(); }; function pLargeGroupPids() { const large = new Set(); const visit = (node) => { if (node.children.length > 10) large.add(node.pid); for (const child of node.children) visit(child); }; for (const node of pBuildTree(pLastRows)) visit(node); return large; } function pPrimaryRootProcessPid() { const supervisor = pLastRows.find(r => r.name === "Forum.Supervisor"); if (supervisor) return supervisor.pid; const roots = pBuildTree(pLastRows); return roots.length ? roots[0].pid : null; } function pAutoCollapseLargeGroups() { const large = pLargeGroupPids(); const topPid = pPrimaryRootProcessPid(); if (topPid) { large.delete(topPid); pCollapsed.delete(topPid); pAutoCollapsed.delete(topPid); pSeenLarge.add(topPid); } for (const pid of pAutoCollapsed) { if (!large.has(pid)) { pCollapsed.delete(pid); pAutoCollapsed.delete(pid); } } for (const pid of large) { if (!pSeenLarge.has(pid)) { pCollapsed.add(pid); pAutoCollapsed.add(pid); pSeenLarge.add(pid); } } } // ── modules panel ────────────────────────────────────────────── const mBody = document.getElementById("m-rows"); const mAll = document.getElementById("m-all"); const mMine = document.getElementById("m-mine"); const mFilter = document.getElementById("m-filter"); const mRefresh = document.getElementById("m-refresh"); let mFilterMine = $("#m-mine").classList.contains("active"); let mNeedle = ""; let mLastRows = []; let mLoaded = false; function mSetFilter(mine) { mFilterMine = mine; mAll.classList.toggle("active", !mine); mMine.classList.toggle("active", mine); mRender(); } mAll.addEventListener("click", () => mSetFilter(false)); mMine.addEventListener("click", () => mSetFilter(true)); mFilter.addEventListener("input", () => { mNeedle = mFilter.value.toLowerCase(); mRender(); }); mRefresh.addEventListener("click", () => pollModules()); function mRender() { let rows = mLastRows; if (mFilterMine) rows = rows.filter(r => r.mine); if (mNeedle) { rows = rows.filter(r => r.module.toLowerCase().includes(mNeedle) || (r.source && r.source.toLowerCase().includes(mNeedle)) || (r.app && r.app.toLowerCase().includes(mNeedle)) ); } const mineN = mLastRows.filter(r => r.mine).length; mAll.textContent = "all (" + mLastRows.length + ")"; mMine.textContent = "mine (" + mineN + ")"; const frag = document.createDocumentFragment(); for (const r of rows) { const tr = document.createElement("tr"); if (r.mine) tr.className = "mine"; addCell(tr, r.module, "mod"); addCell(tr, r.app || "-"); addCell(tr, r.source || "(no source)", "src"); frag.appendChild(tr); } mBody.replaceChildren(frag); } function pollModules() { send({type: "list_modules"}); } handlers["modules"] = (msg) => { mLastRows = msg.rows; mLoaded = true; mRender(); }; // ── logs panel ───────────────────────────────────────────────── const lBody = document.getElementById("l-rows"); const lRefresh = document.getElementById("l-refresh"); let lLoaded = false; const chicagoTime = new Intl.DateTimeFormat("en-US", { timeZone: "America/Chicago", year: "numeric", month: "short", day: "2-digit", hour: "numeric", minute: "2-digit", second: "2-digit", timeZoneName: "short" }); function pollLogs() { send({type: "list_logs"}); } function formatLogTime(value) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return chicagoTime.format(date); } lRefresh.addEventListener("click", () => pollLogs()); handlers["logs"] = (msg) => { lLoaded = true; const frag = document.createDocumentFragment(); for (const r of msg.rows) { const tr = document.createElement("tr"); addCell(tr, formatLogTime(r.time)); addCell(tr, r.source_ip || ""); addCell(tr, r.host || ""); addCell(tr, r.method || ""); addCell(tr, (r.path || "") + (r.query_string ? "?" + r.query_string : "")); addCell(tr, r.status || "", "num"); addCell(tr, r.duration_ms || "", "num"); frag.appendChild(tr); } lBody.replaceChildren(frag); }; // ── VM memory panel ──────────────────────────────────────────── const vBody = document.getElementById("v-rows"); const vRefresh = document.getElementById("v-refresh"); let vLoaded = false; function pollVmMemory() { send({type: "list_vm_memory"}); } vRefresh.addEventListener("click", () => pollVmMemory()); handlers["vm_memory"] = (msg) => { vLoaded = true; const frag = document.createDocumentFragment(); for (const r of msg.rows) { const tr = document.createElement("tr"); addCell(tr, r.category || ""); addCell(tr, r.bytes || "", "num"); addCell(tr, r.kb || "", "num"); addCell(tr, r.mb || "", "num"); frag.appendChild(tr); } vBody.replaceChildren(frag); }; // ── boot ─────────────────────────────────────────────────────── activate(tabFromLocation(), "replace"); ws.addEventListener("open", () => { status.textContent = "connected — processes auto-refresh 5s"; pollProcesses(); if (active === "modules") pollModules(); if (active === "logs") pollLogs(); if (active === "vmMemory") pollVmMemory(); // Processes keep polling whether or not the tab is visible — keeps // the view fresh when the user switches back. Modules load on // first activation and via the refresh button. setInterval(pollProcesses, 5000); }); ws.addEventListener("close", () => { status.textContent = "disconnected"; }); ws.addEventListener("error", () => { status.textContent = "error"; });