This commit is contained in:
Sam
2026-06-10 11:51:56 -05:00
commit 66ba338b81
57 changed files with 5509 additions and 0 deletions

496
priv/ui/admin.html Normal file
View File

@@ -0,0 +1,496 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>admin</title>
<link rel="icon" href="/admin/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: 1px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; }
td { color: #FEBA7D; }
th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; }
tr:hover { background: #9D1A12; }
.num { text-align: right; }
.name, .mod { color: #ffe1c6; }
#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; }
</style>
<script src="https://frm.so/_/code/quill.js"></script>
</head>
<body>
<nav class="tabs">
<button id="tab-processes" class="active">processes</button>
<button id="tab-modules">modules</button>
<button id="tab-logs">logs</button>
<button id="tab-vm-memory">vm memory</button>
<span id="status">connecting…</span>
</nav>
<section id="panel-processes">
<div class="toolbar">
<button id="p-mine" class="active">mine</button>
<button id="p-all">all</button>
<span>|</span>
<button id="p-expand">expand all</button>
<button id="p-collapse">collapse all</button>
</div>
<table>
<thead><tr>
<th>pid</th><th>name</th><th>initial call</th>
<th class="num">memory (KB)</th><th class="num">tree memory (KB)</th>
<th class="num">msgs</th><th>status</th>
</tr></thead>
<tbody id="p-rows"></tbody>
</table>
</section>
<section id="panel-modules" hidden>
<div class="toolbar">
<button id="m-mine" class="active">mine</button>
<button id="m-all">all</button>
<input id="m-filter" placeholder="filter…" autocomplete="off">
<button id="m-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>module</th><th>app</th><th>source</th>
</tr></thead>
<tbody id="m-rows"></tbody>
</table>
</section>
<section id="panel-logs" hidden>
<div class="toolbar">
<button id="l-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>time (Chicago)</th><th>source ip</th><th>host</th><th>method</th><th>path</th>
<th class="num">status</th><th class="num">duration ms</th>
</tr></thead>
<tbody id="l-rows"></tbody>
</table>
</section>
<section id="panel-vm-memory" hidden>
<div class="toolbar">
<button id="v-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>category</th><th class="num">bytes</th>
<th class="num">KB</th><th class="num">MB</th>
</tr></thead>
<tbody id="v-rows"></tbody>
</table>
</section>
<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 chicagoTimeParts = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Chicago",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
function pollLogs() { send({type: "list_logs"}); }
function formatLogTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const parts = Object.fromEntries(
chicagoTimeParts.formatToParts(date).map(part => [part.type, part.value])
);
return `${parts.month}.${parts.day} ${parts.hour}:${parts.minute}:${parts.second}${parts.dayPeriod.toLowerCase()}`;
}
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"; });
</script>
</body>
</html>

490
priv/ui/admin.pug Normal file
View File

@@ -0,0 +1,490 @@
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"; });

93
priv/ui/desktop.html Normal file
View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>forum</title>
<style>
body { font: 14px ui-monospace, monospace; margin: 2rem; background: #f7f7f4; color: #171717; }
#status { color: #888; font-size: 12px; }
#doc { display: grid; gap: 24px; margin-top: 1rem; }
.form-table h2 { margin: 0 0 8px; font-size: 16px; }
.table-wrap { overflow-x: auto; border: 1px solid #d7d7cf; background: #fff; }
table { width: 100%; border-collapse: collapse; min-width: 920px; }
th, td { border-bottom: 1px solid #e6e6df; padding: 8px 10px; text-align: left; vertical-align: top; }
th { position: sticky; top: 0; background: #efefea; color: #555; font-size: 12px; }
tbody tr:hover { background: #fafaf7; }
td { overflow-wrap: anywhere; white-space: pre-wrap; }
code { font-weight: 700; }
</style>
</head>
<body>
<div id="status">connecting…</div>
<main id="doc"><!-- FORMS_HTML --></main>
<script>
const status = document.getElementById("status");
const doc = document.getElementById("doc");
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
let ws = null;
let heartbeat = null;
let reconnectTimer = null;
let reconnectDelay = 500;
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
function stopHeartbeat() {
if (heartbeat) clearInterval(heartbeat);
heartbeat = null;
}
function scheduleReconnect() {
if (reconnectTimer) return;
status.textContent = "disconnected; reconnecting…";
stopHeartbeat();
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
status.textContent = "connecting…";
ws = new WebSocket(url);
ws.addEventListener("open", () => {
reconnectDelay = 500;
status.textContent = "connected";
send({type: "get_doc"});
stopHeartbeat();
heartbeat = setInterval(() => send({type: "ping"}), 25000);
});
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "doc") {
doc.innerHTML = msg.html;
status.textContent = "rendered backend document at " + new Date().toLocaleTimeString();
}
});
ws.addEventListener("close", scheduleReconnect);
ws.addEventListener("error", () => {
status.textContent = "connection error; reconnecting…";
ws.close();
});
}
window.addEventListener("beforeunload", stopHeartbeat);
connect();
</script>
</body>
</html>

11
priv/ui/graphyellow.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="30" height="28" viewBox="0 0 30 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14.5" cy="14.5" r="6" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="26.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="8.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="3.5" cy="19.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="26.5" cy="24.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<rect x="19.3531" y="10.04" width="6.20598" height="0.5" transform="rotate(-42 19.3531 10.04)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="11.5018" y="8.76443" width="2.48193" height="0.5" transform="rotate(-118 11.5018 8.76443)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="8.74289" y="17.7719" width="2.12617" height="0.5" transform="rotate(149 8.74289 17.7719)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="19.692" y="18.3533" width="5.65271" height="0.5" transform="rotate(43 19.692 18.3533)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,18 @@
# Pug Features Demo
This folder is a small Pug demo showing:
- interpolation with `#{name}`
- conditionals with `if` and `case`
- loops with `each`
- includes with `_summary.pug` and `_mixins.pug`
- layout inheritance with `extends ./layout.pug`
- reusable mixins with `+featureCard(feature)` and `+badge(...)`
Compile it with:
```bash
npx pug-cli priv/ui/pug-demo/index.pug --pretty --out priv/ui/pug-demo
```
That writes `priv/ui/pug-demo/index.html`.

View File

@@ -0,0 +1,8 @@
mixin badge(label, tone)
span.badge(class=`badge--${tone}`)= label
mixin featureCard(feature)
li.feature-card
h3 #{feature.name}
p.muted= feature.description
+badge(feature.statusLabel, feature.status)

View File

@@ -0,0 +1,12 @@
section.panel
h2 Included Summary
p
| This section comes from
code _summary.pug
| . It can still read variables declared in
code index.pug
| , including
strong #{name}
| and the feature count:
strong #{features.length}
| .

149
priv/ui/pug-demo/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pug Features Demo</title>
<style>
:root {
color-scheme: light;
--ink: #202124;
--muted: #5f6368;
--line: #dadce0;
--paper: #ffffff;
--soft: #f7f8fa;
--accent: #0b57d0;
--accent-soft: #e8f0fe;
--good: #137333;
--warn: #b06000;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--soft);
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(960px, calc(100% - 32px));
margin: 32px auto;
}
header {
margin-bottom: 24px;
}
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; margin-bottom: 12px; }
h3 { font-size: 16px; margin-bottom: 6px; }
.muted { color: var(--muted); }
.panel, .feature-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel {
padding: 18px;
margin-bottom: 16px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.feature-card {
padding: 14px;
}
.feature-card p { margin-bottom: 10px; }
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 650;
}
.badge--ready {
background: #e6f4ea;
color: var(--good);
}
.badge--practice {
background: #fef7e0;
color: var(--warn);
}
code {
background: #eef0f3;
border-radius: 4px;
padding: 1px 5px;
}
</style>
</head>
<body>
<main>
<header>
<h1>Pug features for Ada Lovelace</h1>
<p class="muted">This page is rendered from <code>index.pug</code> and extends <code>layout.pug</code>.</p>
</header>
<section class="panel">
<h2>Interpolation</h2>
<p>Hello, Ada Lovelace. Your current plan is PRO.</p>
<p>The literal syntax is <code>#{name}</code>, which inserts escaped text into a line.</p>
</section>
<section class="panel">
<h2>Conditionals</h2>
<p>Welcome back, Ada Lovelace.</p>
<p>You are using the pro plan, so all examples are visible.</p>
</section>
<section class="panel">
<h2>Loops and Mixins</h2>
<ul class="feature-grid">
<li class="feature-card">
<h3>Interpolation</h3>
<p class="muted">Drop values into text with #{name} and escaped output.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Conditionals</h3>
<p class="muted">Render different branches with if, else if, else, and case.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Loops</h3>
<p class="muted">Repeat markup with each item in collection syntax.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Includes and extends</h3>
<p class="muted">Compose pages from layouts and smaller partial files.</p><span class="badge badge--practice">practice</span>
</li>
<li class="feature-card">
<h3>Mixins</h3>
<p class="muted">Create reusable snippets that accept arguments.</p><span class="badge badge--ready">ready</span>
</li>
</ul>
</section>
<section class="panel">
<h2>Included Summary</h2>
<p>This section comes from <code>_summary.pug</code>. It can still read variables declared in <code>index.pug</code>, including <strong>Ada Lovelace</strong> and the feature count: <strong>5</strong>.</p>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,80 @@
extends ./layout.pug
include ./_mixins.pug
block content
-
const name = "Ada Lovelace";
const signedIn = true;
const plan = "pro";
const features = [
{
name: "Interpolation",
description: "Drop values into text with #{name} and escaped output.",
status: "ready",
statusLabel: "ready"
},
{
name: "Conditionals",
description: "Render different branches with if, else if, else, and case.",
status: "ready",
statusLabel: "ready"
},
{
name: "Loops",
description: "Repeat markup with each item in collection syntax.",
status: "ready",
statusLabel: "ready"
},
{
name: "Includes and extends",
description: "Compose pages from layouts and smaller partial files.",
status: "practice",
statusLabel: "practice"
},
{
name: "Mixins",
description: "Create reusable snippets that accept arguments.",
status: "ready",
statusLabel: "ready"
}
];
header
h1 Pug features for #{name}
p.muted
| This page is rendered from
code index.pug
| and extends
code layout.pug
| .
section.panel
h2 Interpolation
p Hello, #{name}. Your current plan is #{plan.toUpperCase()}.
p
| The literal syntax is
code #{'#{name}'}
| , which inserts escaped text into a line.
section.panel
h2 Conditionals
if signedIn
p Welcome back, #{name}.
else
p Please sign in to see the demo.
case plan
when "free"
p You are using the free plan.
when "pro"
p You are using the pro plan, so all examples are visible.
default
p Your plan is #{plan}.
section.panel
h2 Loops and Mixins
ul.feature-grid
each feature in features
+featureCard(feature)
include ./_summary.pug

102
priv/ui/pug-demo/layout.pug Normal file
View File

@@ -0,0 +1,102 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title Pug Features Demo
style.
:root {
color-scheme: light;
--ink: #202124;
--muted: #5f6368;
--line: #dadce0;
--paper: #ffffff;
--soft: #f7f8fa;
--accent: #0b57d0;
--accent-soft: #e8f0fe;
--good: #137333;
--warn: #b06000;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--soft);
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(960px, calc(100% - 32px));
margin: 32px auto;
}
header {
margin-bottom: 24px;
}
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; margin-bottom: 12px; }
h3 { font-size: 16px; margin-bottom: 6px; }
.muted { color: var(--muted); }
.panel, .feature-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel {
padding: 18px;
margin-bottom: 16px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.feature-card {
padding: 14px;
}
.feature-card p { margin-bottom: 10px; }
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 650;
}
.badge--ready {
background: #e6f4ea;
color: var(--good);
}
.badge--practice {
background: #fef7e0;
color: var(--warn);
}
code {
background: #eef0f3;
border-radius: 4px;
padding: 1px 5px;
}
body
main
block content