From 8c0996ef0106a3009d48f3e57b58daa876b0e53f Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 3 Jun 2026 22:31:48 -0500 Subject: [PATCH] add profiler --- Overlord-Server/public/assets/settings.js | 189 +++++++++++ Overlord-Server/public/settings.html | 88 +++++ .../src/server/routes/misc-routes.ts | 302 ++++++++++++++++++ 3 files changed, 579 insertions(+) diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 33d6bec..caebeb4 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -1855,6 +1855,8 @@ function formatUptime(totalSeconds) { return `${secs}s`; } +let latestServerCpuProfile = null; + async function loadHealthStats() { const loading = document.getElementById("health-loading"); const content = document.getElementById("health-content"); @@ -1927,6 +1929,189 @@ async function loadHealthStats() { } } +function formatMs(ms) { + const n = Number(ms) || 0; + if (n >= 1000) return `${(n / 1000).toFixed(1)}s`; + return `${Math.round(n)}ms`; +} + +function formatPercent(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "-"; + return `${n.toFixed(n >= 10 ? 1 : 2)}%`; +} + +function formatSignedBytes(value) { + const n = Number(value) || 0; + if (n === 0) return "0 B"; + return `${n > 0 ? "+" : "-"}${formatBytes(Math.abs(n))}`; +} + +function setProfileMessage(text, type = "ok") { + const el = document.getElementById("server-profile-message"); + if (!el) return; + el.textContent = text; + el.classList.remove( + "hidden", + "text-emerald-200", + "border-emerald-700", + "bg-emerald-900/30", + "text-cyan-200", + "border-cyan-700", + "bg-cyan-900/30", + "text-rose-200", + "border-rose-700", + "bg-rose-900/30", + ); + if (type === "error") { + el.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); + } else if (type === "busy") { + el.classList.add("text-cyan-200", "border-cyan-700", "bg-cyan-900/30"); + } else { + el.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); + } +} + +function renderProfileRows(tbodyId, rows, renderRow, emptyText) { + const tbody = document.getElementById(tbodyId); + if (!tbody) return; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + tbody.innerHTML = `${escapeHtml(emptyText)}`; + return; + } + tbody.innerHTML = list.map(renderRow).join(""); +} + +function renderServerProfileResults(data) { + const results = document.getElementById("server-profile-results"); + const downloadBtn = document.getElementById("server-profile-download-btn"); + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = value; + }; + + const cpu = data?.cpu || {}; + const summary = cpu.summary || {}; + const beforeMem = data?.memory?.before?.process || {}; + const afterMem = data?.memory?.after?.process || {}; + const rssDelta = (Number(afterMem.rss) || 0) - (Number(beforeMem.rss) || 0); + + latestServerCpuProfile = cpu.rawProfile || null; + if (downloadBtn) downloadBtn.classList.toggle("hidden", !latestServerCpuProfile); + + set("profile-duration", formatMs(data?.durationMs || 0)); + set("profile-cpu-percent", formatPercent(cpu.processPercent)); + set("profile-samples", summary.totalSamples ?? 0); + set("profile-rss-delta", formatSignedBytes(rssDelta)); + + const totalSamples = Number(summary.totalSamples) || 0; + renderProfileRows( + "profile-functions-body", + (summary.topFunctions || []).slice(0, 12), + (fn) => { + const pct = Number.isFinite(Number(fn.percent)) && Number(fn.percent) > 0 + ? Number(fn.percent) + : (totalSamples > 0 ? (Number(fn.samples || 0) / totalSamples) * 100 : 0); + return ` + +
${escapeHtml(fn.name || "(anonymous)")}
+
${escapeHtml(fn.location || "(runtime)")}
+ + ${formatPercent(pct)} + ${Number(fn.samples || 0)} + `; + }, + "No CPU samples were captured.", + ); + + const topObjectTypes = data?.memory?.after?.jscHeap?.topObjectTypes || []; + renderProfileRows( + "profile-objects-body", + topObjectTypes.slice(0, 12), + (item) => ` + ${escapeHtml(item.type || "-")} + ${Number(item.count || 0).toLocaleString()} + `, + "Heap object details are unavailable.", + ); + + const stacksEl = document.getElementById("profile-stacks-list"); + if (stacksEl) { + const stacks = (summary.topStacks || []).slice(0, 5); + stacksEl.innerHTML = stacks.length + ? stacks.map((entry) => { + const pct = Number.isFinite(Number(entry.percent)) && Number(entry.percent) > 0 + ? Number(entry.percent) + : (totalSamples > 0 ? (Number(entry.samples || 0) / totalSamples) * 100 : 0); + const stack = Array.isArray(entry.stack) ? entry.stack : []; + return `
+
+ ${formatPercent(pct)} + ${Number(entry.samples || 0)} samples +
+
${escapeHtml(stack.join(" <- ") || "(runtime)")}
+
`; + }).join("") + : '
No stack samples were captured.
'; + } + + if (results) results.classList.remove("hidden"); +} + +async function runServerProfile() { + const btn = document.getElementById("server-profile-btn"); + const durationEl = document.getElementById("server-profile-duration"); + const durationMs = Number(durationEl?.value || 5000); + if (btn) { + btn.disabled = true; + btn.innerHTML = 'Capturing...'; + } + setProfileMessage(`Capturing server CPU and memory for ${formatMs(durationMs)}. Keep using the server while this runs.`, "busy"); + + try { + const res = await fetch("/api/settings/profile", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ durationMs }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || res.statusText || "Profile failed"); + } + renderServerProfileResults(data); + const profilerError = data?.cpu?.profilerError; + setProfileMessage( + profilerError + ? `Memory snapshot captured. CPU profiler was unavailable: ${profilerError}` + : "Profile complete. Hot functions and memory object types are listed below.", + profilerError ? "error" : "ok", + ); + } catch (e) { + setProfileMessage(`Profile failed: ${e.message}`, "error"); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = 'Run Profile'; + } + } +} + +function downloadServerCpuProfile() { + if (!latestServerCpuProfile) return; + const blob = new Blob([JSON.stringify(latestServerCpuProfile, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + a.href = url; + a.download = `overlord-server-${stamp}.cpuprofile`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + async function runGC() { const gcBtn = document.getElementById("health-gc-btn"); const msgEl = document.getElementById("health-gc-message"); @@ -1952,8 +2137,12 @@ async function runGC() { function initHealthHandlers() { const refreshBtn = document.getElementById("health-refresh-btn"); const gcBtn = document.getElementById("health-gc-btn"); + const profileBtn = document.getElementById("server-profile-btn"); + const downloadProfileBtn = document.getElementById("server-profile-download-btn"); if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats); if (gcBtn) gcBtn.addEventListener("click", runGC); + if (profileBtn) profileBtn.addEventListener("click", runServerProfile); + if (downloadProfileBtn) downloadProfileBtn.addEventListener("click", downloadServerCpuProfile); } async function init() { diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 15d5b76..4230b34 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -1193,6 +1193,94 @@ + +
+
+
+

Server Profiler

+

Capture a short CPU sample and memory snapshot to find hot functions and heavy allocations.

+
+
+ + + +
+
+ + + + +
+ diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 52ad159..11e7ae0 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -15,6 +15,10 @@ import { stopProxy, } from "../socks5-proxy-manager"; +let activeServerProfile: Promise | null = null; +let jscRuntimePromise: Promise | null | undefined; +let inspectorRuntimePromise: Promise | null | undefined; + type MiscRouteDeps = { CORS_HEADERS: Record; SERVER_VERSION: string; @@ -27,6 +31,274 @@ type MiscRouteDeps = { tlsSource?: "certbot" | "configured" | "self-signed"; }; +function clampProfileDuration(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 5000; + return Math.max(1000, Math.min(30000, Math.floor(parsed))); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function readProcessCpuUsage(previous?: NodeJS.CpuUsage): NodeJS.CpuUsage | null { + const cpuUsage = (process as any).cpuUsage; + if (typeof cpuUsage !== "function") return null; + return previous ? cpuUsage(previous) : cpuUsage(); +} + +function readResourceUsage(): any | null { + const resourceUsage = (process as any).resourceUsage; + if (typeof resourceUsage !== "function") return null; + try { + return resourceUsage(); + } catch { + return null; + } +} + +async function loadJscRuntime(): Promise { + if (jscRuntimePromise === undefined) { + try { + const runtimeImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + jscRuntimePromise = runtimeImport("bun:jsc").catch(() => null); + } catch { + jscRuntimePromise = null; + } + } + return jscRuntimePromise; +} + +async function loadInspectorRuntime(): Promise { + if (inspectorRuntimePromise === undefined) { + try { + const runtimeImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + inspectorRuntimePromise = runtimeImport("node:inspector").catch(() => null); + } catch { + inspectorRuntimePromise = null; + } + } + return inspectorRuntimePromise; +} + +async function summarizeRuntimeMemory() { + const mem = process.memoryUsage(); + let jscHeap: any = null; + let jscMemory: any = null; + try { + const jsc = await loadJscRuntime(); + if (typeof jsc?.heapStats === "function") jscHeap = jsc.heapStats(); + if (typeof jsc?.memoryUsage === "function") jscMemory = jsc.memoryUsage(); + } catch { } + + const objectTypeCounts = jscHeap?.objectTypeCounts && typeof jscHeap.objectTypeCounts === "object" + ? Object.entries(jscHeap.objectTypeCounts) + .map(([type, count]) => ({ type, count: Number(count) || 0 })) + .filter((entry) => entry.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 20) + : []; + + const activeResourcesInfo = typeof (process as any).getActiveResourcesInfo === "function" + ? (process as any).getActiveResourcesInfo() + : []; + + const activeResources = Array.isArray(activeResourcesInfo) + ? Object.entries(activeResourcesInfo.reduce((acc: Record, name: string) => { + acc[name] = (acc[name] || 0) + 1; + return acc; + }, {})).map(([type, count]) => ({ type, count })) + : []; + + return { + process: { + rss: mem.rss, + heapTotal: mem.heapTotal, + heapUsed: mem.heapUsed, + external: mem.external, + arrayBuffers: (mem as any).arrayBuffers ?? 0, + }, + jscHeap: jscHeap ? { + heapSize: jscHeap.heapSize, + heapCapacity: jscHeap.heapCapacity, + extraMemorySize: jscHeap.extraMemorySize, + objectCount: jscHeap.objectCount, + protectedObjectCount: jscHeap.protectedObjectCount, + globalObjectCount: jscHeap.globalObjectCount, + topObjectTypes: objectTypeCounts, + } : null, + jscMemory, + resources: activeResources.sort((a: any, b: any) => b.count - a.count).slice(0, 20), + components: { + thumbnails: getThumbnailStats(), + clients: { + inMemory: getClientCount(), + online: getOnlineClients().length, + }, + database: { + fileSizeBytes: getDatabaseFileSizeBytes(), + }, + }, + }; +} + +function normalizeProfileUrl(url: string): string { + if (!url) return "(runtime)"; + const clean = url.replace(/^file:\/\//, "").replaceAll("\\", "/"); + const parts = clean.split("/"); + return parts.slice(-3).join("/"); +} + +function summarizeCpuProfile(profile: any, durationMs: number) { + const nodes = Array.isArray(profile?.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile?.samples) ? profile.samples : []; + const timeDeltas = Array.isArray(profile?.timeDeltas) ? profile.timeDeltas : []; + const nodeById = new Map(); + const parentById = new Map(); + + for (const node of nodes) { + if (typeof node?.id !== "number") continue; + nodeById.set(node.id, node); + if (Array.isArray(node.children)) { + for (const childId of node.children) { + if (typeof childId === "number") parentById.set(childId, node.id); + } + } + } + + const functionTotals = new Map(); + const moduleTotals = new Map(); + const stackTotals = new Map(); + let totalUs = 0; + + samples.forEach((nodeId: number, index: number) => { + const node = nodeById.get(nodeId); + if (!node) return; + const deltaUs = Number(timeDeltas[index]) || 0; + totalUs += deltaUs; + const frame = node.callFrame || {}; + const name = frame.functionName || "(anonymous)"; + const moduleName = normalizeProfileUrl(frame.url || ""); + const location = frame.url + ? `${moduleName}:${(Number(frame.lineNumber) || 0) + 1}` + : moduleName; + const key = `${name}\n${location}`; + const current = functionTotals.get(key) || { name, location, samples: 0, selfTimeUs: 0 }; + current.samples += 1; + current.selfTimeUs += deltaUs; + functionTotals.set(key, current); + + const moduleCurrent = moduleTotals.get(moduleName) || { module: moduleName, samples: 0, selfTimeUs: 0 }; + moduleCurrent.samples += 1; + moduleCurrent.selfTimeUs += deltaUs; + moduleTotals.set(moduleName, moduleCurrent); + + const stackFrames: string[] = []; + let cursor: number | undefined = nodeId; + while (typeof cursor === "number" && stackFrames.length < 8) { + const stackNode = nodeById.get(cursor); + if (!stackNode) break; + const stackFrame = stackNode.callFrame || {}; + const stackName = stackFrame.functionName || "(anonymous)"; + stackFrames.push(`${stackName} @ ${normalizeProfileUrl(stackFrame.url || "")}`); + cursor = parentById.get(cursor); + } + const stackKey = stackFrames.join(" <- "); + const stackCurrent = stackTotals.get(stackKey) || { stack: stackFrames, samples: 0, selfTimeUs: 0 }; + stackCurrent.samples += 1; + stackCurrent.selfTimeUs += deltaUs; + stackTotals.set(stackKey, stackCurrent); + }); + + if (!totalUs && samples.length > 0) totalUs = durationMs * 1000; + const decorate = (item: any) => ({ + ...item, + selfTimeMs: item.selfTimeUs / 1000, + percent: totalUs > 0 ? (item.selfTimeUs / totalUs) * 100 : 0, + }); + + return { + totalSamples: samples.length, + totalTimeMs: totalUs / 1000, + topFunctions: Array.from(functionTotals.values()).map(decorate).sort((a, b) => b.samples - a.samples).slice(0, 25), + topModules: Array.from(moduleTotals.values()).map(decorate).sort((a, b) => b.samples - a.samples).slice(0, 12), + topStacks: Array.from(stackTotals.values()).map(decorate).sort((a, b) => b.samples - a.samples).slice(0, 10), + }; +} + +async function runInspectorCpuProfile(durationMs: number) { + const inspector = await loadInspectorRuntime(); + const Session = (inspector as any).Session; + if (typeof Session !== "function") { + throw new Error("Runtime inspector profiler is unavailable"); + } + + const session = new Session(); + const post = (method: string, params?: Record) => new Promise((resolve, reject) => { + session.post(method, params || {}, (error: Error | null, result: any) => { + if (error) reject(error); + else resolve(result); + }); + }); + + session.connect(); + try { + await post("Profiler.enable"); + await post("Profiler.setSamplingInterval", { interval: 1000 }); + await post("Profiler.start"); + await sleep(durationMs); + const result = await post("Profiler.stop"); + await post("Profiler.disable").catch(() => undefined); + return result?.profile || null; + } finally { + session.disconnect(); + } +} + +async function collectServerProfile(durationMs: number) { + const startedAt = Date.now(); + const beforeCpu = readProcessCpuUsage(); + const beforeResource = readResourceUsage(); + const beforeMemory = await summarizeRuntimeMemory(); + let cpuProfile: any = null; + let profilerError: string | null = null; + + try { + cpuProfile = await runInspectorCpuProfile(durationMs); + } catch (error: any) { + profilerError = String(error?.message || error || "CPU profiler failed"); + await sleep(durationMs); + } + + const elapsedMs = Date.now() - startedAt; + const cpuDelta = beforeCpu ? readProcessCpuUsage(beforeCpu) : null; + const afterResource = readResourceUsage(); + const afterMemory = await summarizeRuntimeMemory(); + const cpuTotalUs = cpuDelta ? (cpuDelta.user + cpuDelta.system) : 0; + + return { + ok: true, + startedAt, + durationMs: elapsedMs, + requestedDurationMs: durationMs, + cpu: { + userUs: cpuDelta?.user ?? null, + systemUs: cpuDelta?.system ?? null, + totalUs: cpuTotalUs || null, + processPercent: elapsedMs > 0 && cpuTotalUs > 0 ? (cpuTotalUs / (elapsedMs * 1000)) * 100 : null, + resourceBefore: beforeResource, + resourceAfter: afterResource, + profilerError, + summary: cpuProfile ? summarizeCpuProfile(cpuProfile, elapsedMs) : null, + rawProfile: cpuProfile, + }, + memory: { + before: beforeMemory, + after: afterMemory, + }, + }; +} + export async function handleMiscRoutes( req: Request, url: URL, @@ -807,5 +1079,35 @@ export async function handleMiscRoutes( return Response.json({ ok: true, freedBytes: Math.max(0, before - after) }); } + // POST /api/settings/profile + if (req.method === "POST" && url.pathname === "/api/settings/profile") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + if (activeServerProfile) { + return Response.json({ error: "A server profile capture is already running." }, { status: 409 }); + } + + let body: any = {}; + try { body = await req.json(); } catch { body = {}; } + const durationMs = clampProfileDuration(body?.durationMs); + activeServerProfile = collectServerProfile(durationMs); + try { + const result = await activeServerProfile; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: `Captured server profile (${durationMs}ms)`, + success: true, + }); + return Response.json(result, { headers: deps.CORS_HEADERS }); + } finally { + activeServerProfile = null; + } + } + return null; }