491 lines
18 KiB
Plaintext
491 lines
18 KiB
Plaintext
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"; });
|