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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hot Functions
+
+
+
+
+ | Function |
+ Self |
+ Samples |
+
+
+
+
+
+
+
+
+
Memory Object Types
+
+
+
+
+ | Type |
+ Count |
+
+
+
+
+
+
+
+
+
+
+
+
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;
}