let clientsChart = null; let commandsChart = null; let bandwidthChart = null; let httpRequestsChart = null; let httpChart = null; let memoryChart = null; let eventLoopChart = null; let sessionsChart = null; let osChart = null; let countryMap = null; let countriesLayer = null; let countryCounts = {}; let maxCountryCount = 0; let latestByCountry = {}; const GEOJSON_URL = "/vendor/geo-countries/countries.geojson"; const MAX_CHART_POINTS = 240; const METRICS_POLL_INTERVAL_MS = 5000; if (typeof Chart !== "undefined") { Chart.defaults.color = "#cbd5e1"; Chart.defaults.borderColor = "rgba(100, 116, 139, 0.25)"; Chart.defaults.font.family = "Inter, system-ui, sans-serif"; } function chartTooltip() { return { backgroundColor: "#0f172a", titleColor: "#e2e8f0", bodyColor: "#cbd5e1", borderColor: "#334155", borderWidth: 1, }; } function lineChartOptions(extra = {}) { const extraPlugins = extra.plugins || {}; return { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8" }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, x: { ticks: { color: "#94a3b8", maxTicksLimit: 6, autoSkip: true, maxRotation: 0, }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, ...(extra.scales || {}), }, ...extra, plugins: { legend: extraPlugins.legend ?? { display: true, labels: { boxWidth: 10, boxHeight: 10 } }, tooltip: chartTooltip(), ...extraPlugins, }, }; } function makeLineChart(canvas, datasets, options = {}) { if (!canvas) return null; return new Chart(canvas, { type: "line", data: { labels: [], datasets }, options: lineChartOptions(options), }); } function initCharts() { const clientsCtx = document.getElementById("clients-chart"); const commandsCtx = document.getElementById("commands-chart"); const bandwidthCtx = document.getElementById("bandwidth-chart"); const httpRequestsCtx = document.getElementById("http-requests-chart"); const httpCtx = document.getElementById("http-chart"); const memoryCtx = document.getElementById("memory-chart"); const eventLoopCtx = document.getElementById("event-loop-chart"); const sessionsCtx = document.getElementById("sessions-chart"); const osCtx = document.getElementById("os-chart"); clientsChart = new Chart(clientsCtx, { type: "line", data: { labels: [], datasets: [ { label: "Clients Online", data: [], borderColor: "rgb(96, 165, 250)", backgroundColor: "rgba(96, 165, 250, 0.1)", fill: true, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: "#0f172a", titleColor: "#e2e8f0", bodyColor: "#cbd5e1", borderColor: "#334155", borderWidth: 1, }, }, scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8", stepSize: 1 }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, x: { ticks: { color: "#94a3b8", maxTicksLimit: 6, autoSkip: true, maxRotation: 0, }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, }); commandsChart = new Chart(commandsCtx, { type: "line", data: { labels: [], datasets: [ { label: "Commands/Min", data: [], borderColor: "rgb(192, 132, 252)", backgroundColor: "rgba(192, 132, 252, 0.1)", fill: true, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: "#0f172a", titleColor: "#e2e8f0", bodyColor: "#cbd5e1", borderColor: "#334155", borderWidth: 1, }, }, scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8" }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, x: { ticks: { color: "#94a3b8", maxTicksLimit: 6, autoSkip: true, maxRotation: 0, }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, }); bandwidthChart = makeLineChart( bandwidthCtx, [ { label: "Sent/s", data: [], borderColor: "rgb(251, 146, 60)", backgroundColor: "rgba(251, 146, 60, 0.12)", fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, }, { label: "Received/s", data: [], borderColor: "rgb(56, 189, 248)", backgroundColor: "rgba(56, 189, 248, 0.1)", fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, }, ], { scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8", callback: (value) => formatBytes(Number(value)), }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, ); httpRequestsChart = makeLineChart( httpRequestsCtx, [ { label: "requests/min", data: [], borderColor: "rgb(52, 211, 153)", backgroundColor: "rgba(52, 211, 153, 0.12)", fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, }, { label: "errors/min", data: [], borderColor: "rgb(248, 113, 113)", backgroundColor: "rgba(248, 113, 113, 0.14)", fill: false, tension: 0.25, pointRadius: 0, borderDash: [5, 4], borderWidth: 2, }, ], { scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8", precision: 0 }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, ); httpChart = makeLineChart( httpCtx, [ { label: "p99 ms", data: [], borderColor: "rgb(248, 113, 113)", backgroundColor: "rgba(248, 113, 113, 0.08)", fill: false, tension: 0.35, pointRadius: 0, borderDash: [4, 4], borderWidth: 2, }, { label: "p95 ms", data: [], borderColor: "rgb(244, 63, 94)", backgroundColor: "rgba(244, 63, 94, 0.1)", fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, }, { label: "avg ms", data: [], borderColor: "rgb(251, 191, 36)", backgroundColor: "rgba(251, 191, 36, 0.08)", fill: false, tension: 0.35, pointRadius: 0, borderWidth: 2, }, ], { scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, ); memoryChart = makeLineChart( memoryCtx, [ { label: "Heap", data: [], borderColor: "rgb(34, 211, 238)", backgroundColor: "rgba(34, 211, 238, 0.1)", fill: true, tension: 0.35, pointRadius: 0, yAxisID: "y", borderWidth: 2, }, { label: "RSS", data: [], borderColor: "rgb(129, 140, 248)", backgroundColor: "rgba(129, 140, 248, 0.08)", fill: true, tension: 0.35, pointRadius: 0, yAxisID: "y", borderWidth: 2, }, { label: "System %", data: [], borderColor: "rgb(52, 211, 153)", backgroundColor: "rgba(52, 211, 153, 0.08)", fill: false, tension: 0.35, pointRadius: 0, yAxisID: "y1", borderWidth: 2, }, ], { scales: { y: { beginAtZero: true, position: "left", ticks: { color: "#94a3b8", callback: (value) => formatBytes(Number(value)) }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, y1: { beginAtZero: true, max: 100, position: "right", ticks: { color: "#86efac", callback: (value) => `${Math.round(Number(value))}%` }, grid: { drawOnChartArea: false }, }, }, }, ); eventLoopChart = makeLineChart( eventLoopCtx, [ { label: "p95 lag", data: [], borderColor: "rgb(251, 191, 36)", backgroundColor: "rgba(251, 191, 36, 0.12)", fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, }, { label: "avg lag", data: [], borderColor: "rgb(96, 165, 250)", backgroundColor: "rgba(96, 165, 250, 0.08)", fill: false, tension: 0.35, pointRadius: 0, borderWidth: 2, }, ], { scales: { y: { beginAtZero: true, ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, }, }, ); sessionsChart = sessionsCtx ? new Chart(sessionsCtx, { type: "doughnut", data: { labels: ["Console", "Remote Desktop", "Files", "Processes"], datasets: [{ data: [0, 0, 0, 0], backgroundColor: ["#34d399", "#c084fc", "#60a5fa", "#fb923c"], borderColor: "#0f172a", borderWidth: 2, }], }, options: { responsive: true, maintainAspectRatio: false, animation: false, cutout: "64%", plugins: { legend: { position: "right", labels: { boxWidth: 10, boxHeight: 10 } }, tooltip: chartTooltip(), }, }, }) : null; osChart = osCtx ? new Chart(osCtx, { type: "bar", data: { labels: [], datasets: [{ label: "Clients", data: [], backgroundColor: "rgba(56, 189, 248, 0.5)", borderColor: "rgb(56, 189, 248)", borderWidth: 1, }], }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: chartTooltip(), }, scales: { x: { beginAtZero: true, ticks: { color: "#94a3b8", precision: 0 }, grid: { color: "rgba(100, 116, 139, 0.1)" }, }, y: { ticks: { color: "#94a3b8" }, grid: { display: false }, }, }, }, }) : null; } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; if (bytes < 1) return `${bytes.toFixed(2)} B`; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } function formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ${hours % 24}h`; if (hours > 0) return `${hours}h ${minutes % 60}m`; if (minutes > 0) return `${minutes}m ${seconds % 60}s`; return `${seconds}s`; } function formatMs(value) { if (!Number.isFinite(value)) return "0 ms"; return `${Math.round(value)} ms`; } function formatTime(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", }); } function animateCounter(element, newValue, duration = 800) { const oldValue = parseInt(element.textContent.replace(/,/g, "")) || 0; if (oldValue === newValue) return; const startTime = performance.now(); const diff = newValue - oldValue; function update(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easeOutQuad = progress * (2 - progress); const current = Math.round(oldValue + diff * easeOutQuad); element.textContent = current.toLocaleString(); element.classList.add("counter-animate"); if (progress < 1) { requestAnimationFrame(update); } else { setTimeout(() => element.classList.remove("counter-animate"), 500); } } requestAnimationFrame(update); } function updateMetrics(data, debug) { animateCounter( document.getElementById("clients-online"), data.clients.online, ); animateCounter(document.getElementById("clients-total"), data.clients.total); const totalSessions = data.sessions.console + data.sessions.remoteDesktop + data.sessions.fileBrowser + data.sessions.process; animateCounter(document.getElementById("active-sessions"), totalSessions); animateCounter( document.getElementById("commands-hour"), data.commands.lastHour, ); animateCounter( document.getElementById("commands-minute"), data.commands.lastMinute, ); animateCounter( document.getElementById("commands-total"), data.commands.total, ); const totalRate = data.bandwidth.sentPerSecond + data.bandwidth.receivedPerSecond; document.getElementById("bandwidth-rate").textContent = formatBytes(totalRate) + "/s"; document.getElementById("bandwidth-sent").textContent = formatBytes( data.bandwidth.sent, ); document.getElementById("bandwidth-received").textContent = formatBytes( data.bandwidth.received, ); document.getElementById("server-uptime").textContent = formatDuration( data.server.uptime, ); document.getElementById("server-memory").textContent = formatBytes( data.server.memoryUsage.heapUsed, ); const serverRssEl = document.getElementById("server-rss"); if (serverRssEl) { serverRssEl.textContent = formatBytes(data.server.memoryUsage.rss); } const serverSystemMemEl = document.getElementById("server-system-memory"); if (serverSystemMemEl && data.server.systemMemory) { const used = data.server.systemMemory.used || 0; const total = data.server.systemMemory.total || 0; const percent = data.server.systemMemory.usedPercent || 0; serverSystemMemEl.textContent = `${formatBytes(used)} / ${formatBytes(total)} (${Math.round(percent)}%)`; } const serverCpuEl = document.getElementById("server-cpu-load"); if (serverCpuEl && data.server.cpu) { const [l1, l5, l15] = data.server.cpu.loadAvg || [0, 0, 0]; const cores = data.server.cpu.cores || 0; serverCpuEl.textContent = `${Number(l1).toFixed(2)} / ${Number(l5).toFixed(2)} / ${Number(l15).toFixed(2)}${cores ? ` (${cores} cores)` : ""}`; } animateCounter( document.getElementById("total-connections"), data.connections.totalConnections, ); const httpRequestsEl = document.getElementById("http-requests-minute"); if (httpRequestsEl) { animateCounter(httpRequestsEl, data.http.lastMinute || 0); } const httpErrorsEl = document.getElementById("http-errors-minute"); if (httpErrorsEl) { animateCounter(httpErrorsEl, data.http.lastMinuteErrors || 0); } const httpLatencyAvgEl = document.getElementById("http-latency-avg"); if (httpLatencyAvgEl) { httpLatencyAvgEl.textContent = formatMs(data.http.latencyAvg || 0); } const httpLatencyP95El = document.getElementById("http-latency-p95"); if (httpLatencyP95El) { httpLatencyP95El.textContent = formatMs(data.http.latencyP95 || 0); } const httpLatencyP99El = document.getElementById("http-latency-p99"); if (httpLatencyP99El) { httpLatencyP99El.textContent = formatMs(data.http.latencyP99 || 0); } const eventLoopAvgEl = document.getElementById("event-loop-avg"); if (eventLoopAvgEl) { eventLoopAvgEl.textContent = formatMs(data.eventLoop.avg || 0); } const eventLoopP95El = document.getElementById("event-loop-p95"); if (eventLoopP95El) { eventLoopP95El.textContent = formatMs(data.eventLoop.p95 || 0); } const eventLoopMaxEl = document.getElementById("event-loop-max"); if (eventLoopMaxEl) { eventLoopMaxEl.textContent = formatMs(data.eventLoop.max || 0); } if (data.ping.count > 0) { document.getElementById("ping-avg").textContent = Math.round(data.ping.avg) + " ms"; document.getElementById("ping-min").textContent = Math.round(data.ping.min) + " ms"; document.getElementById("ping-max").textContent = Math.round(data.ping.max) + " ms"; animateCounter(document.getElementById("ping-count"), data.ping.count); } else { document.getElementById("ping-avg").textContent = "-"; document.getElementById("ping-min").textContent = "-"; document.getElementById("ping-max").textContent = "-"; document.getElementById("ping-count").textContent = "0"; } const osList = document.getElementById("clients-by-os"); if (Object.keys(data.clients.byOS).length > 0) { osList.innerHTML = Object.entries(data.clients.byOS) .sort((a, b) => b[1] - a[1]) .map( ([os, count]) => `