diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js new file mode 100644 index 0000000..b1a54cc --- /dev/null +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -0,0 +1,387 @@ +let initialized = false; +let metricsTimer = null; +let onlineChart = null; +let osChart = null; +const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed"; + +const palette = { + text: "#cbd5e1", + muted: "#94a3b8", + border: "rgba(100, 116, 139, 0.18)", + panel: "rgba(15, 23, 42, 0.96)", + cyan: "#22d3ee", + emerald: "#34d399", + sky: "#38bdf8", + indigo: "#818cf8", + amber: "#fbbf24", + rose: "#fb7185", + violet: "#a78bfa", + slate: "#64748b", +}; + +const osColors = [ + palette.sky, + palette.emerald, + palette.violet, + palette.amber, + palette.rose, + palette.cyan, + palette.indigo, + palette.slate, +]; + +function $(id) { + return document.getElementById(id); +} + +function setText(id, value) { + const el = $(id); + if (el) el.textContent = value; +} + +function formatNumber(value) { + const n = Number(value) || 0; + return n.toLocaleString(); +} + +function formatBytes(bytes) { + const n = Number(bytes) || 0; + if (n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const idx = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return `${(n / Math.pow(1024, idx)).toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; +} + +function formatTime(timestamp) { + return new Date(timestamp || Date.now()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatWeekLabel(timestamp) { + const date = new Date(timestamp || Date.now()); + return date.toLocaleDateString([], { + weekday: "short", + hour: "2-digit", + }); +} + +function aggregateOnlineHistory(history, snapshot) { + const now = Date.now(); + const weekAgo = now - 7 * 24 * 60 * 60 * 1000; + const source = Array.isArray(history) + ? history.filter((row) => Number(row?.timestamp) >= weekAgo) + : []; + const rows = source.length + ? source + : [{ timestamp: now, clientsOnline: snapshot?.clients?.online || 0 }]; + const maxPoints = 84; + if (rows.length <= maxPoints) return rows; + + const bucketMs = Math.max( + 60 * 60 * 1000, + Math.ceil((now - weekAgo) / maxPoints), + ); + const buckets = new Map(); + for (const row of rows) { + const ts = Number(row.timestamp) || now; + const key = Math.floor(ts / bucketMs) * bucketMs; + const bucket = buckets.get(key) || { timestamp: key, total: 0, count: 0, peak: 0 }; + const online = Number(row.clientsOnline) || 0; + bucket.total += online; + bucket.count += 1; + bucket.peak = Math.max(bucket.peak, online); + buckets.set(key, bucket); + } + + return Array.from(buckets.values()) + .sort((a, b) => a.timestamp - b.timestamp) + .map((bucket) => ({ + timestamp: bucket.timestamp, + clientsOnline: Math.round(bucket.total / Math.max(1, bucket.count)), + clientsPeak: bucket.peak, + })); +} + +function setDotTone(id, tone) { + const el = $(id); + if (!el) return; + el.classList.toggle("is-live", tone === "live"); + el.classList.toggle("is-warn", tone === "warn"); + el.classList.toggle("is-bad", tone === "bad"); + el.classList.toggle("is-muted", tone === "muted"); +} + +function topEntries(record, limit = 6) { + return Object.entries(record || {}) + .map(([key, value]) => [key || "Unknown", Number(value) || 0]) + .filter(([, value]) => value > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit); +} + +function compactOsLabel(os) { + const text = String(os || "Unknown"); + const lower = text.toLowerCase(); + if (lower.includes("windows 11")) return "Win 11"; + if (lower.includes("windows 10")) return "Win 10"; + if (lower.includes("windows")) return "Windows"; + if (lower.includes("darwin") || lower.includes("mac")) return "macOS"; + if (lower.includes("ubuntu")) return "Ubuntu"; + if (lower.includes("debian")) return "Debian"; + if (lower.includes("kali")) return "Kali"; + if (lower.includes("fedora")) return "Fedora"; + if (lower.includes("linux")) return "Linux"; + return text.length > 16 ? `${text.slice(0, 15)}...` : text; +} + +function configureChartDefaults() { + if (typeof Chart === "undefined") return false; + Chart.defaults.color = palette.text; + Chart.defaults.borderColor = palette.border; + Chart.defaults.font.family = "Inter, Segoe UI, system-ui, sans-serif"; + return true; +} + +function createGradient(ctx, color) { + const gradient = ctx.createLinearGradient(0, 0, 0, 150); + gradient.addColorStop(0, `${color}55`); + gradient.addColorStop(0.72, `${color}12`); + gradient.addColorStop(1, `${color}00`); + return gradient; +} + +function makeCharts() { + if (!configureChartDefaults()) return false; + const onlineCanvas = $("dash-online-chart"); + const osCanvas = $("dash-os-chart"); + if (!onlineCanvas || !osCanvas) return false; + + const onlineCtx = onlineCanvas.getContext("2d"); + onlineChart = new Chart(onlineCtx, { + type: "line", + data: { + labels: [], + datasets: [{ + label: "Online", + data: [], + borderColor: palette.cyan, + backgroundColor: createGradient(onlineCtx, palette.cyan), + borderWidth: 2, + fill: true, + tension: 0.38, + pointRadius: 0, + pointHoverRadius: 4, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 260 }, + interaction: { intersect: false, mode: "index" }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: palette.panel, + borderColor: "rgba(56, 189, 248, 0.32)", + borderWidth: 1, + titleColor: "#e2e8f0", + bodyColor: palette.text, + displayColors: false, + callbacks: { + label: (ctx) => `Avg online: ${ctx.parsed.y}`, + }, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { color: palette.muted, maxRotation: 0, maxTicksLimit: 5 }, + }, + y: { + beginAtZero: true, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + ticks: { color: palette.muted, precision: 0, maxTicksLimit: 4 }, + }, + }, + }, + }); + + osChart = new Chart(osCanvas, { + type: "doughnut", + data: { + labels: [], + datasets: [{ + data: [], + backgroundColor: osColors, + borderColor: "rgba(2, 6, 23, 0.9)", + borderWidth: 3, + hoverOffset: 5, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 260 }, + cutout: "66%", + plugins: { + legend: { + position: "right", + labels: { + boxWidth: 9, + boxHeight: 9, + color: palette.text, + padding: 10, + font: { size: 11 }, + }, + }, + tooltip: { + backgroundColor: palette.panel, + borderColor: "rgba(129, 140, 248, 0.32)", + borderWidth: 1, + titleColor: "#e2e8f0", + bodyColor: palette.text, + }, + }, + }, + }); + return true; +} + +function updateOnlineChart(history, snapshot) { + if (!onlineChart) return; + const rows = aggregateOnlineHistory(history, snapshot); + + onlineChart.data.labels = rows.map((row) => formatWeekLabel(row.timestamp)); + onlineChart.data.datasets[0].data = rows.map((row) => Number(row.clientsOnline) || 0); + onlineChart.update("none"); +} + +function updateOsChart(byOS) { + if (!osChart) return; + const entries = topEntries(byOS, 7); + if (!entries.length) { + osChart.data.labels = ["No clients"]; + osChart.data.datasets[0].data = [1]; + osChart.data.datasets[0].backgroundColor = ["rgba(100, 116, 139, 0.35)"]; + setText("dash-os-leader", "None"); + osChart.update("none"); + return; + } + + osChart.data.labels = entries.map(([label]) => compactOsLabel(label)); + osChart.data.datasets[0].data = entries.map(([, count]) => count); + osChart.data.datasets[0].backgroundColor = entries.map((_, index) => osColors[index % osColors.length]); + setText("dash-os-leader", `${compactOsLabel(entries[0][0])} ${entries[0][1]}`); + osChart.update("none"); +} + +function updateSummary(snapshot) { + const clients = snapshot?.clients || {}; + const online = Number(clients.online) || 0; + const total = Number(clients.total) || 0; + const ratio = total > 0 ? Math.round((online / total) * 100) : 0; + const sessions = snapshot?.sessions || {}; + const activeSessions = + (Number(sessions.console) || 0) + + (Number(sessions.remoteDesktop) || 0) + + (Number(sessions.fileBrowser) || 0) + + (Number(sessions.process) || 0); + + setText("dash-online-count", formatNumber(online)); + setText("dash-total-count", `${formatNumber(total)} total`); + setText("dash-online-ratio", `${ratio}%`); + setText("dash-session-count", formatNumber(activeSessions)); + setText("dash-command-minute", `${formatNumber(snapshot?.commands?.lastMinute)} cmd/min`); + setText("dash-http-errors", `${formatNumber(snapshot?.http?.lastMinuteErrors)} errors`); + + const mem = Number(snapshot?.server?.systemMemory?.usedPercent) || 0; + setText("dash-memory-status", `${Math.round(mem)}%`); + setDotTone("dash-memory-dot", mem >= 90 ? "bad" : mem >= 75 ? "warn" : "live"); + + const avgPing = snapshot?.ping?.avg; + if (Number.isFinite(avgPing)) { + const rounded = Math.round(avgPing); + setText("dash-ping-status", `${rounded} ms`); + setDotTone("dash-ping-dot", rounded >= 150 ? "bad" : rounded >= 80 ? "warn" : "live"); + } else { + setText("dash-ping-status", "-"); + setDotTone("dash-ping-dot", "muted"); + } + + setText("dash-trend-status", `${formatBytes(snapshot?.bandwidth?.sentPerSecond || 0)}/s out`); + setText("dash-last-refresh", formatTime(Date.now())); +} + +export function updateDashboardStatsFromClients(data) { + if (!data) return; + const online = Number(data.online) || 0; + const total = Number(data.total) || 0; + const ratio = total > 0 ? Math.round((online / total) * 100) : 0; + setText("dash-online-count", formatNumber(online)); + setText("dash-total-count", `${formatNumber(total)} total`); + setText("dash-online-ratio", `${ratio}%`); +} + +async function fetchDashboardMetrics() { + try { + const res = await fetch("/api/metrics", { credentials: "include" }); + if (!res.ok) throw new Error(`metrics ${res.status}`); + const data = await res.json(); + const snapshot = data?.snapshot || {}; + updateSummary(snapshot); + updateOnlineChart(data?.history || [], snapshot); + updateOsChart(snapshot?.clients?.byOS || {}); + setText("dash-api-status", "Live"); + } catch (err) { + console.warn("dashboard stats failed", err); + setText("dash-api-status", "Error"); + setText("dash-last-refresh", "Error"); + } +} + +export function initDashboardStats() { + if (initialized) return; + initialized = true; + if (!makeCharts()) { + setTimeout(makeCharts, 150); + } + initStatsToggle(); + fetchDashboardMetrics(); + metricsTimer = setInterval(fetchDashboardMetrics, 5000); +} + +function initStatsToggle() { + const shell = $("dashboard-stats"); + const button = $("dashboard-stats-toggle"); + if (!shell || !button) return; + + const setCollapsed = (collapsed) => { + shell.classList.toggle("is-collapsed", collapsed); + button.setAttribute("aria-expanded", collapsed ? "false" : "true"); + button.title = collapsed ? "Show status overview" : "Hide status overview"; + button.innerHTML = collapsed + ? '' + : ''; + localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false"); + }; + + setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true"); + button.addEventListener("click", () => { + const nextCollapsed = !shell.classList.contains("is-collapsed"); + setCollapsed(nextCollapsed); + if (!nextCollapsed) { + requestAnimationFrame(() => { + onlineChart?.resize(); + osChart?.resize(); + }); + } + }); +} + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + if (metricsTimer) clearInterval(metricsTimer); + }); +} diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index c9c6291..156a889 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -4377,6 +4377,277 @@ body.sb-ready #top-nav { z-index: 20 !important; } +.dashboard-stats-shell { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dashboard-stats-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.dashboard-stats-title { + display: inline-flex; + align-items: center; + gap: 8px; + color: #94a3b8; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.dashboard-stats-title i { + color: #38bdf8; +} + +.dashboard-stats-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid rgba(51, 65, 85, 0.68); + border-radius: 8px; + background: rgba(30, 41, 59, 0.7); + color: #94a3b8; + cursor: pointer; + transition: background 140ms ease, color 140ms ease, border-color 140ms ease; +} + +.dashboard-stats-toggle:hover { + background: rgba(51, 65, 85, 0.78); + color: #e2e8f0; + border-color: rgba(71, 85, 105, 0.8); +} + +.dashboard-stats-content { + display: grid; + grid-template-columns: minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(260px, 1.45fr) minmax(260px, 1.35fr) minmax(220px, 1fr); + gap: 12px; + align-items: stretch; +} + +.dashboard-stats-shell.is-collapsed .dashboard-stats-content { + display: none; +} + +.dashboard-stat-card, +.dashboard-chart-card, +.dashboard-health-card { + position: relative; + min-width: 0; + border: 1px solid rgba(51, 65, 85, 0.68); + border-radius: 10px; + background: rgba(15, 23, 42, 0.72); + box-shadow: none; + overflow: hidden; +} + +.dashboard-stat-card::before, +.dashboard-chart-card::before, +.dashboard-health-card::before { + display: none; +} + +.dashboard-stat-card { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 142px; + padding: 14px; +} + +.dashboard-stat-card--online { + border-color: rgba(34, 211, 238, 0.22); +} + +.dashboard-stat-card--sessions { + border-color: rgba(129, 140, 248, 0.24); +} + +.dashboard-stat-topline, +.dashboard-chart-kicker { + display: inline-flex; + align-items: center; + gap: 7px; + color: #94a3b8; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.dashboard-stat-topline i { + color: #818cf8; +} + +.dashboard-stat-value { + margin-top: 10px; + color: #f8fafc; + font-size: 2.4rem; + line-height: 1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.dashboard-stat-subline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 12px; + color: #9db1cc; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.dashboard-chart-card, +.dashboard-health-card { + min-height: 142px; + padding: 12px 14px 10px; +} + +.dashboard-chart-head { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} + +.dashboard-chart-head strong { + display: block; + margin-top: 2px; + color: #e2e8f0; + font-size: 0.94rem; + font-weight: 700; +} + +.dashboard-chart-pill { + flex-shrink: 0; + max-width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 999px; + background: rgba(8, 47, 73, 0.32); + color: #bae6fd; + padding: 3px 8px; + font-size: 0.7rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dashboard-chart-wrap { + position: relative; + z-index: 1; + height: 88px; + min-width: 0; +} + +.dashboard-chart-wrap--donut { + height: 92px; +} + +.dashboard-health-list { + position: relative; + z-index: 1; + display: grid; + gap: 7px; +} + +.dashboard-health-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + min-height: 26px; + border: 1px solid rgba(100, 116, 139, 0.16); + border-radius: 8px; + background: rgba(15, 23, 42, 0.46); + padding: 5px 8px; + color: #9db1cc; + font-size: 0.78rem; +} + +.dashboard-health-row strong { + color: #e2e8f0; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.dashboard-live-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #64748b; + box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.12); +} + +.dashboard-live-dot.is-live { + background: #34d399; + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12), 0 0 16px rgba(52, 211, 153, 0.34); +} + +.dashboard-live-dot.is-warn { + background: #fbbf24; + box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.12), 0 0 16px rgba(251, 191, 36, 0.3); +} + +.dashboard-live-dot.is-bad { + background: #fb7185; + box-shadow: 0 0 0 4px rgba(251, 113, 133, 0.12), 0 0 16px rgba(251, 113, 133, 0.3); +} + +.dashboard-live-dot.is-muted { + background: #64748b; + box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.1); +} + +@media (max-width: 1380px) { + .dashboard-stats-content { + grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, minmax(260px, 1.35fr)); + } + + .dashboard-health-card { + grid-column: span 2; + } +} + +@media (max-width: 940px) { + .dashboard-stats-content { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-chart-card, + .dashboard-health-card { + grid-column: span 2; + } +} + +@media (max-width: 620px) { + .dashboard-stats-content { + grid-template-columns: 1fr; + } + + .dashboard-chart-card, + .dashboard-health-card { + grid-column: auto; + } + + .dashboard-stat-value { + font-size: 2rem; + } +} + .dashboard-menu-panel, .nav-dd-menu { z-index: 40 !important; diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 5fafd7d..2722ca2 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -13,6 +13,7 @@ import { markManualDisconnect, } from "./data.js"; import { ThumbnailLoader } from "./thumbnail-loader.js"; +import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js"; const grid = document.getElementById("grid"); const totalPill = document.getElementById("total-pill"); @@ -844,7 +845,11 @@ function initializeRenderer() { getDisplayFields: () => displayFields, }); rendererSetLayout = rSetLayout; - registerRenderer(renderMerge); + registerRenderer((data, options) => { + renderMerge(data, options); + updateDashboardStatsFromClients(data); + }); + initDashboardStats(); setupDashboardThumbnailLoader(); refreshGroupFilter(); loadWithOptions(); diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index 6eeca0a..b025db9 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -16,6 +16,7 @@ + @@ -38,6 +39,91 @@ >0 online +
+
+
+ + Status overview +
+ +
+
+
+
+ + Online +
+
0
+
+ 0 total + 0% +
+
+
+
+ + Sessions +
+
0
+
+ 0 cmd/min + 0 errors +
+
+
+
+
+ Past Week + Clients Online +
+ Live +
+
+ +
+
+
+
+
+ Fleet Mix + Operating Systems +
+ None +
+
+ +
+
+
+
+
+ Status + Server Pulse +
+ Waiting +
+
+
+ + API + Live +
+
+ + Memory + 0% +
+
+ + Ping Avg + - +
+
+
+
+