add profiler

This commit is contained in:
KingKDot
2026-06-03 22:31:48 -05:00
parent 4f82a3618a
commit 8c0996ef01
3 changed files with 579 additions and 0 deletions
+189
View File
@@ -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 = `<tr><td colspan="3" class="px-3 py-3 text-slate-500 text-center">${escapeHtml(emptyText)}</td></tr>`;
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 `<tr class="bg-slate-900/70">
<td class="px-3 py-2 min-w-[15rem]">
<div class="font-mono text-slate-100 truncate" title="${escapeHtml(fn.name || "")}">${escapeHtml(fn.name || "(anonymous)")}</div>
<div class="font-mono text-[11px] text-slate-500 truncate" title="${escapeHtml(fn.location || "")}">${escapeHtml(fn.location || "(runtime)")}</div>
</td>
<td class="px-3 py-2 text-right font-mono text-cyan-300 whitespace-nowrap">${formatPercent(pct)}</td>
<td class="px-3 py-2 text-right font-mono text-slate-300">${Number(fn.samples || 0)}</td>
</tr>`;
},
"No CPU samples were captured.",
);
const topObjectTypes = data?.memory?.after?.jscHeap?.topObjectTypes || [];
renderProfileRows(
"profile-objects-body",
topObjectTypes.slice(0, 12),
(item) => `<tr class="bg-slate-900/70">
<td class="px-3 py-2 font-mono text-slate-100 truncate" title="${escapeHtml(item.type || "")}">${escapeHtml(item.type || "-")}</td>
<td class="px-3 py-2 text-right font-mono text-slate-300">${Number(item.count || 0).toLocaleString()}</td>
</tr>`,
"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 `<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-2">
<div class="flex items-center justify-between gap-3 text-xs">
<span class="font-mono text-cyan-300">${formatPercent(pct)}</span>
<span class="font-mono text-slate-500">${Number(entry.samples || 0)} samples</span>
</div>
<div class="mt-1 font-mono text-[11px] text-slate-300 leading-relaxed break-all">${escapeHtml(stack.join(" <- ") || "(runtime)")}</div>
</div>`;
}).join("")
: '<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-3 text-xs text-slate-500 text-center">No stack samples were captured.</div>';
}
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 = '<i class="fa-solid fa-spinner fa-spin"></i>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 = '<i class="fa-solid fa-gauge-high"></i>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() {
+88
View File
@@ -1193,6 +1193,94 @@
</div>
</div>
<!-- Server profiler -->
<div class="rounded-lg bg-slate-900 border border-slate-800 p-4 space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Server Profiler</p>
<p class="text-sm text-slate-400">Capture a short CPU sample and memory snapshot to find hot functions and heavy allocations.</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<select id="server-profile-duration"
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-sm text-slate-200 focus:outline-none focus:ring-2 focus:ring-cyan-500">
<option value="3000">3 seconds</option>
<option value="5000" selected>5 seconds</option>
<option value="10000">10 seconds</option>
<option value="30000">30 seconds</option>
</select>
<button id="server-profile-btn" type="button"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 border border-cyan-600 text-sm transition-colors text-white">
<i class="fa-solid fa-gauge-high"></i>Run Profile
</button>
<button id="server-profile-download-btn" type="button"
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors text-slate-300">
<i class="fa-solid fa-download"></i>CPU Profile
</button>
</div>
</div>
<p id="server-profile-message" class="hidden text-xs rounded-lg px-3 py-2 border"></p>
<div id="server-profile-results" class="hidden space-y-4">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-2">
<div class="text-slate-400 text-xs mb-0.5">Captured</div>
<div id="profile-duration" class="font-medium font-mono">-</div>
</div>
<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-2">
<div class="text-slate-400 text-xs mb-0.5">Process CPU</div>
<div id="profile-cpu-percent" class="font-medium font-mono">-</div>
</div>
<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-2">
<div class="text-slate-400 text-xs mb-0.5">Samples</div>
<div id="profile-samples" class="font-medium font-mono">-</div>
</div>
<div class="rounded-lg bg-slate-950 border border-slate-800 px-3 py-2">
<div class="text-slate-400 text-xs mb-0.5">RSS Delta</div>
<div id="profile-rss-delta" class="font-medium font-mono">-</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Hot Functions</p>
<div class="overflow-x-auto rounded-lg border border-slate-800">
<table class="min-w-full text-xs">
<thead class="bg-slate-950 text-slate-400">
<tr>
<th class="text-left px-3 py-2 font-medium">Function</th>
<th class="text-right px-3 py-2 font-medium">Self</th>
<th class="text-right px-3 py-2 font-medium">Samples</th>
</tr>
</thead>
<tbody id="profile-functions-body" class="divide-y divide-slate-800"></tbody>
</table>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Memory Object Types</p>
<div class="overflow-x-auto rounded-lg border border-slate-800">
<table class="min-w-full text-xs">
<thead class="bg-slate-950 text-slate-400">
<tr>
<th class="text-left px-3 py-2 font-medium">Type</th>
<th class="text-right px-3 py-2 font-medium">Count</th>
</tr>
</thead>
<tbody id="profile-objects-body" class="divide-y divide-slate-800"></tbody>
</table>
</div>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Hottest Stacks</p>
<div id="profile-stacks-list" class="space-y-2"></div>
</div>
</div>
</div>
<p id="health-gc-message" class="hidden text-xs rounded-lg px-3 py-2 border border-emerald-700 bg-emerald-900/20 text-emerald-300"></p>
</div>
</section>