diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 784f807..39dfbc8 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -891,6 +891,7 @@ function initSettingsSidebar() { // (nav-hidden toggle, viewport resize, dropdown opened) doesn't leave the // section partly hidden under the nav. updateSettingsScrollOffset(); + _clickLockUntil = Date.now() + 1200; setActive(link); target.scrollIntoView({ behavior: "smooth", block: "start" }); history.replaceState(null, "", `#${id}`); @@ -898,7 +899,9 @@ function initSettingsSidebar() { // Active-section highlight via IntersectionObserver + page-bottom override. const visible = new Set(); + let _clickLockUntil = 0; function updateActive() { + if (Date.now() < _clickLockUntil) return; // Special case: if the user has scrolled as far as the page allows, the // last visible section becomes active (otherwise the topmost-visible // heuristic would leave shorter trailing sections — like Custom CSS or @@ -1706,6 +1709,129 @@ function initThumbnailHandlers() { if (form) form.addEventListener("submit", saveThumbnailSettings); } +// ── Server Health ───────────────────────────────────────────────────────── + +function formatBytes(bytes) { + if (!bytes || bytes <= 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return `${(bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0)} ${sizes[i]}`; +} + +function formatUptime(totalSeconds) { + const s = Math.floor(totalSeconds); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const mins = Math.floor((s % 3600) / 60); + const secs = s % 60; + if (days > 0) return `${days}d ${hours}h ${mins}m`; + if (hours > 0) return `${hours}h ${mins}m ${secs}s`; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; +} + +async function loadHealthStats() { + const loading = document.getElementById("health-loading"); + const content = document.getElementById("health-content"); + if (loading) { loading.innerHTML = 'Loading...'; loading.classList.remove("hidden"); } + if (content) content.classList.add("hidden"); + + try { + const res = await fetch("/api/settings/health", { credentials: "include" }); + if (!res.ok) { + if (loading) loading.textContent = "Failed to load health stats."; + return; + } + const data = await res.json(); + + const mem = data.memory || {}; + const rss = mem.rss || 0; + const heapUsed = mem.heapUsed || 0; + const ext = (mem.external || 0) + (mem.arrayBuffers || 0); + + const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + + set("health-uptime", formatUptime(data.uptime || 0)); + set("health-rss", formatBytes(rss)); + set("health-heap-used", formatBytes(heapUsed)); + set("health-external", formatBytes(ext)); + const heapPct = rss > 0 ? Math.min(100, (heapUsed / rss) * 100) : 0; + const extPct = rss > 0 ? Math.min(100 - heapPct, (ext / rss) * 100) : 0; + + const heapBar = document.getElementById("health-heap-bar"); + const extBar = document.getElementById("health-ext-bar"); + const heapBarLabel = document.getElementById("health-heap-bar-label"); + const barLabel = document.getElementById("health-bar-label"); + + if (heapBar) { + heapBar.style.width = `${heapPct.toFixed(1)}%`; + heapBar.title = `Heap: ${formatBytes(heapUsed)} (${heapPct.toFixed(1)}% of RSS)`; + } + if (extBar) { + extBar.style.width = `${extPct.toFixed(1)}%`; + extBar.title = `External/Buffers: ${formatBytes(ext)} (${extPct.toFixed(1)}% of RSS)`; + } + if (heapBarLabel) { + heapBarLabel.textContent = `${heapPct.toFixed(0)}%`; + heapBarLabel.classList.toggle("hidden", heapPct < 8); + } + if (barLabel) { + barLabel.textContent = rss > 0 + ? `${formatBytes(heapUsed)} heap + ${formatBytes(ext)} ext / ${formatBytes(rss)} RSS` + : "-"; + } + + const t = data.components?.thumbnails || {}; + set("h-thumb-count", `${t.cachedCount ?? 0} / ${t.cacheMax ?? 0}`); + set("h-thumb-bytes", formatBytes(t.cachedBytes || 0)); + set("h-thumb-frames", t.pendingFrames ?? 0); + set("h-thumb-gen", `${t.genActive ?? 0} active, ${t.genQueued ?? 0} queued`); + set("h-thumb-max", t.cacheMax ?? 0); + + const c = data.components?.clients || {}; + set("h-clients-mem", c.inMemory ?? 0); + set("h-clients-online", c.online ?? 0); + + const db = data.components?.database || {}; + set("h-db-size", formatBytes(db.fileSizeBytes || 0)); + + if (loading) loading.classList.add("hidden"); + if (content) content.classList.remove("hidden"); + } catch (e) { + if (loading) loading.textContent = `Error: ${e.message}`; + } +} + +async function runGC() { + const gcBtn = document.getElementById("health-gc-btn"); + const msgEl = document.getElementById("health-gc-message"); + if (gcBtn) gcBtn.disabled = true; + try { + const res = await fetch("/api/settings/gc", { method: "POST", credentials: "include" }); + const data = await res.json().catch(() => ({})); + if (msgEl) { + msgEl.textContent = res.ok + ? `GC complete — freed ~${formatBytes(data.freedBytes || 0)}` + : `GC failed: ${data.error || res.statusText}`; + msgEl.classList.remove("hidden"); + setTimeout(() => msgEl.classList.add("hidden"), 6000); + } + if (res.ok) await loadHealthStats(); + } catch (e) { + if (msgEl) { msgEl.textContent = `Error: ${e.message}`; msgEl.classList.remove("hidden"); } + } finally { + if (gcBtn) gcBtn.disabled = false; + } +} + +function initHealthHandlers() { + const refreshBtn = document.getElementById("health-refresh-btn"); + const gcBtn = document.getElementById("health-gc-btn"); + if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats); + if (gcBtn) gcBtn.addEventListener("click", runGC); +} + async function init() { try { await loadCurrentUser(); @@ -1729,9 +1855,11 @@ async function init() { await loadRegistrationSettings(); await loadBuildRateLimitSettings(); await loadThumbnailSettings(); + await loadHealthStats(); initRegistrationHandlers(); initBuildRateLimitHandlers(); initThumbnailHandlers(); + initHealthHandlers(); } passwordForm.addEventListener("submit", updatePassword); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index a671ed6..0633722 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -98,6 +98,7 @@ Build Rate Limit Custom CSS Export / Import + Server Health @@ -989,6 +990,123 @@ + + + diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 751696c..35e1a81 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -2494,3 +2494,11 @@ export function recordAutoDeployRun(deployId: string, clientId: string) { Date.now(), ); } + +export function getDatabaseFileSizeBytes(): number { + try { + return Bun.file(dbPath).size; + } catch { + return 0; + } +} diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index b636d8d..c657f7b 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -2,7 +2,9 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; import { logger } from "../../logger"; import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config"; -import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; +import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db"; +import { getThumbnailStats } from "../../thumbnails"; +import { getClientCount, getOnlineClients } from "../../clientManager"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users"; @@ -750,5 +752,48 @@ export async function handleMiscRoutes( } } + // GET /api/settings/health + if (req.method === "GET" && url.pathname === "/api/settings/health") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const mem = process.memoryUsage(); + return Response.json({ + memory: { + rss: mem.rss, + heapTotal: mem.heapTotal, + heapUsed: mem.heapUsed, + external: mem.external, + arrayBuffers: (mem as any).arrayBuffers ?? 0, + }, + uptime: Math.floor(process.uptime()), + components: { + thumbnails: getThumbnailStats(), + clients: { + inMemory: getClientCount(), + online: getOnlineClients().length, + }, + database: { + fileSizeBytes: getDatabaseFileSizeBytes(), + }, + }, + }); + } + + // POST /api/settings/gc + if (req.method === "POST" && url.pathname === "/api/settings/gc") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const before = process.memoryUsage().heapUsed; + if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") { + (Bun as any).gc(true); + } + const after = process.memoryUsage().heapUsed; + return Response.json({ ok: true, freedBytes: Math.max(0, before - after) }); + } + return null; } diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts index 5518320..910c82a 100644 --- a/Overlord-Server/src/thumbnails.ts +++ b/Overlord-Server/src/thumbnails.ts @@ -216,6 +216,28 @@ export function clearThumbnailRequest(id: string) { thumbnailRequests.delete(id); } +export function getThumbnailStats(): { + cachedCount: number; + cachedBytes: number; + pendingFrames: number; + genActive: number; + genQueued: number; + genStateTracked: number; + cacheMax: number; +} { + let cachedBytes = 0; + for (const rec of thumbnails.values()) cachedBytes += rec.bytes.byteLength; + return { + cachedCount: thumbnails.size, + cachedBytes, + pendingFrames: latestFrames.size, + genActive: _activeThumbnailGen, + genQueued: _thumbnailGenQueue.length, + genStateTracked: thumbnailGenState.size, + cacheMax: THUMBNAIL_CACHE_MAX, + }; +} + export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean { const ts = thumbnailRequests.get(id); if (!ts) return false;