From 68134a69c21d851eee8246354d02193d1b79ca44 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 3 Jun 2026 22:36:34 -0500 Subject: [PATCH 1/8] 2.3.5 --- Overlord-Client/cmd/agent/config/config.go | 2 +- Overlord-Server/package.json | 2 +- Overlord-Server/src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index a629f57..4adf08e 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -17,7 +17,7 @@ import ( "time" ) -var AgentVersion = "2.3.4" +var AgentVersion = "2.3.5" var DefaultPersistence = "false" var DefaultServerURL = "wss://127.0.0.1:5173" diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index f70a6c1..cb0d92b 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -1,6 +1,6 @@ { "name": "overlord-server", - "version": "2.3.4", + "version": "2.3.5", "private": true, "type": "module", "scripts": { diff --git a/Overlord-Server/src/version.ts b/Overlord-Server/src/version.ts index c68ccaa..052e9bd 100644 --- a/Overlord-Server/src/version.ts +++ b/Overlord-Server/src/version.ts @@ -1 +1 @@ -export const SERVER_VERSION = "2.3.4"; +export const SERVER_VERSION = "2.3.5"; From 25b90005ea826a2314d88769ede73a846d2650dd Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 3 Jun 2026 23:07:50 -0500 Subject: [PATCH 2/8] add gridstack --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + .../public/assets/dashboard-stats.js | 260 +++++++++++++++++- Overlord-Server/public/assets/main.css | 126 ++++++--- Overlord-Server/public/index.html | 152 +++++----- Overlord-Server/scripts/vendor.ts | 13 + 6 files changed, 445 insertions(+), 110 deletions(-) diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 86f2fba..602d889 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -10,6 +10,7 @@ "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", @@ -316,6 +317,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gridstack": ["gridstack@12.6.0", "", {}, "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index cb0d92b..8ce510b 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -21,6 +21,7 @@ "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js index 61ff587..37439b0 100644 --- a/Overlord-Server/public/assets/dashboard-stats.js +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -5,12 +5,22 @@ let osChart = null; const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed"; const STATS_HEIGHT_KEY = "overlord_dashboard_stats_height"; const STATS_ORDER_KEY = "overlord_dashboard_stats_order"; +const STATS_LAYOUT_KEY = "overlord_dashboard_stats_gridstack_layout"; +const STATS_HIDDEN_KEY = "overlord_dashboard_stats_hidden"; const PULSE_ORDER_KEY = "overlord_dashboard_pulse_order"; const DEFAULT_STATS_HEIGHT = 142; const MIN_STATS_HEIGHT = 112; const MAX_STATS_HEIGHT = 280; const DEFAULT_CARD_ORDER = ["online", "sessions", "trend", "fleet", "pulse"]; const DEFAULT_PULSE_ORDER = ["api", "memory", "ping"]; +const DEFAULT_GRID_LAYOUT = { + online: { x: 0, y: 0, w: 2, h: 2 }, + sessions: { x: 2, y: 0, w: 2, h: 2 }, + trend: { x: 4, y: 0, w: 3, h: 2 }, + fleet: { x: 7, y: 0, w: 3, h: 2 }, + pulse: { x: 10, y: 0, w: 2, h: 2 }, +}; +let statsGrid = null; const palette = { text: "#cbd5e1", @@ -291,6 +301,239 @@ function applyHeightMode(height) { } } +function getStatsCellHeight() { + return Math.max(48, Math.round(getSavedHeight() / 2)); +} + +function resizeCharts() { + requestAnimationFrame(() => { + onlineChart?.resize(); + osChart?.resize(); + }); +} + +function syncStatsWidgetHeights() { + if (!statsGrid?.engine?.nodes) return; + const cellHeight = Number(statsGrid.opts?.cellHeight) || getStatsCellHeight(); + for (const node of statsGrid.engine.nodes) { + const content = node.el?.querySelector?.(".grid-stack-item-content"); + if (!content) continue; + const estimated = Math.max(64, (Number(node.h) || 2) * cellHeight - 12); + content.style.setProperty("--dashboard-stats-height", `${estimated}px`); + } +} + +function readSavedGridLayout() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_LAYOUT_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item) => DEFAULT_CARD_ORDER.includes(item?.id)) + .map((item) => ({ + id: item.id, + x: Math.max(0, Number(item.x) || 0), + y: Math.max(0, Number(item.y) || 0), + w: Math.max(1, Number(item.w) || DEFAULT_GRID_LAYOUT[item.id]?.w || 2), + h: Math.max(1, Number(item.h) || DEFAULT_GRID_LAYOUT[item.id]?.h || 2), + })); + } catch { + return []; + } +} + +function readHiddenStatsCards() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_HIDDEN_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id) => DEFAULT_CARD_ORDER.includes(id)); + } catch { + return []; + } +} + +function saveHiddenStatsCards(hidden) { + const cleaned = Array.from(hidden).filter((id) => DEFAULT_CARD_ORDER.includes(id)); + localStorage.setItem(STATS_HIDDEN_KEY, JSON.stringify(cleaned)); +} + +function isStatsGridManaged(el) { + return !!statsGrid?.engine?.nodes?.some((node) => node.el === el); +} + +function saveStatsGridLayout() { + if (!statsGrid?.engine?.nodes) return; + const layoutMap = new Map( + DEFAULT_CARD_ORDER.map((id) => [id, { id, ...DEFAULT_GRID_LAYOUT[id] }]), + ); + + for (const item of readSavedGridLayout()) { + layoutMap.set(item.id, item); + } + + for (const node of statsGrid.engine.nodes) { + const id = node.el?.dataset?.dashboardCard || node.id; + if (!DEFAULT_CARD_ORDER.includes(id)) continue; + layoutMap.set(id, { id, x: node.x, y: node.y, w: node.w, h: node.h }); + } + + const layout = DEFAULT_CARD_ORDER.map((id) => layoutMap.get(id)); + localStorage.setItem(STATS_LAYOUT_KEY, JSON.stringify(layout)); +} + +function applyGridLayout(layout) { + if (!statsGrid) return; + statsGrid.batchUpdate(); + for (const item of layout) { + const el = document.querySelector(`[data-dashboard-card="${item.id}"]`); + if (el) statsGrid.update(el, { x: item.x, y: item.y, w: item.w, h: item.h }); + } + statsGrid.batchUpdate(false); + syncStatsWidgetHeights(); + resizeCharts(); +} + +function setStatsCardHidden(id, hidden, persist = true) { + if (!DEFAULT_CARD_ORDER.includes(id)) return; + const el = document.querySelector(`[data-dashboard-card="${id}"]`); + if (!el) return; + + if (hidden) { + if (statsGrid && isStatsGridManaged(el)) statsGrid.removeWidget(el, false); + el.classList.add("dashboard-card-hidden"); + } else { + el.classList.remove("dashboard-card-hidden"); + if (statsGrid && !isStatsGridManaged(el)) { + statsGrid.makeWidget(el); + const layout = readSavedGridLayout().find((item) => item.id === id); + statsGrid.update(el, { ...(layout || DEFAULT_GRID_LAYOUT[id]) }); + } + } + + if (persist) { + const nextHidden = new Set(readHiddenStatsCards()); + if (hidden) nextHidden.add(id); + else nextHidden.delete(id); + saveHiddenStatsCards(nextHidden); + saveStatsGridLayout(); + } + + syncStatsWidgetHeights(); + resizeCharts(); +} + +function applyHiddenStatsCards() { + const hidden = new Set(readHiddenStatsCards()); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, hidden.has(id), false); + } +} + +function syncStatsCardMenu() { + const panel = $("dashboard-card-menu-panel"); + if (!panel) return; + const hidden = new Set(readHiddenStatsCards()); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + const id = input.dataset.dashboardCardToggle; + input.checked = !hidden.has(id); + }); +} + +function initStatsCardMenu() { + const menu = $("dashboard-card-menu"); + const panel = $("dashboard-card-menu-panel"); + if (!panel || panel.dataset.bound === "true") return; + + panel.dataset.bound = "true"; + syncStatsCardMenu(); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + input.addEventListener("change", () => { + setStatsCardHidden(input.dataset.dashboardCardToggle, !input.checked); + }); + }); + + menu?.addEventListener("toggle", () => { + if (menu.open) syncStatsCardMenu(); + }); +} + +function applySavedGridLayout() { + const saved = readSavedGridLayout(); + if (saved.length) { + applyGridLayout(saved); + return; + } + + // Preserve older drag-only ordering by converting it into default-size widgets. + const order = readSavedOrder(); + if (!order.length || order.join("|") === DEFAULT_CARD_ORDER.join("|")) return; + const migrated = order.map((id, index) => ({ + id, + ...(DEFAULT_GRID_LAYOUT[id] || { w: 2, h: 2 }), + x: index === 0 ? 0 : undefined, + y: index === 0 ? 0 : undefined, + })); + let x = 0; + for (const item of migrated) { + const width = item.w || 2; + if (x + width > 12) x = 0; + item.x = x; + item.y = 0; + x += width; + } + applyGridLayout(migrated); +} + +function resetStatsGridLayout() { + localStorage.removeItem(STATS_LAYOUT_KEY); + localStorage.removeItem(STATS_HIDDEN_KEY); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, false, false); + } + syncStatsCardMenu(); + if (!statsGrid) { + const content = $("dashboard-stats-content"); + if (content) applySavedOrder(content); + return; + } + applyGridLayout(DEFAULT_CARD_ORDER.map((id) => ({ id, ...DEFAULT_GRID_LAYOUT[id] }))); +} + +function initStatsGrid(content) { + if (!content || content.dataset.gridstackBound === "true") return; + const api = window.GridStack?.GridStack || window.GridStack; + if (typeof api?.init !== "function") { + applySavedOrder(content); + applyHiddenStatsCards(); + return; + } + + content.dataset.gridstackBound = "true"; + statsGrid = api.init({ + column: 12, + cellHeight: getStatsCellHeight(), + margin: 6, + animate: true, + float: false, + handle: ".grid-stack-item-content", + draggable: { + handle: ".grid-stack-item-content", + appendTo: "body", + scroll: true, + }, + resizable: { handles: "se" }, + }, content); + + applySavedGridLayout(); + applyHiddenStatsCards(); + statsGrid.on("change dragstop resizestop", () => { + syncStatsWidgetHeights(); + saveStatsGridLayout(); + resizeCharts(); + }); + syncStatsWidgetHeights(); + resizeCharts(); +} + function updateOnlineChart(history, snapshot) { if (!onlineChart) return; const rows = aggregateOnlineHistory(history, snapshot); @@ -403,18 +646,15 @@ function initStatsToggle() { const resizer = $("dashboard-stats-resizer"); if (!shell || !button) return; - const resizeCharts = () => { - requestAnimationFrame(() => { - onlineChart?.resize(); - osChart?.resize(); - }); - }; - const setHeight = (value, persist = true) => { const height = normalizeHeight(value); shell.style.setProperty("--dashboard-stats-height", `${height}px`); resizer?.setAttribute("aria-valuenow", String(height)); if (persist) localStorage.setItem(STATS_HEIGHT_KEY, String(height)); + if (statsGrid) { + statsGrid.cellHeight(getStatsCellHeight()); + syncStatsWidgetHeights(); + } applyHeightMode(height); resizeCharts(); }; @@ -429,7 +669,8 @@ function initStatsToggle() { localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false"); }; - initCardReorder(); + initStatsGrid($("dashboard-stats-content")); + initStatsCardMenu(); initPulseReorder(); setHeight(getSavedHeight(), false); setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true"); @@ -483,10 +724,9 @@ function initStatsToggle() { resetButton?.addEventListener("click", () => { setHeight(DEFAULT_STATS_HEIGHT); setCollapsed(false); + resetStatsGridLayout(); localStorage.removeItem(STATS_ORDER_KEY); localStorage.removeItem(PULSE_ORDER_KEY); - const content = $("dashboard-stats-content"); - if (content) applySavedOrder(content); const pulseList = document.querySelector(".dashboard-health-list"); if (pulseList) applyPulseOrder(pulseList); }); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 18bd091..a53c724 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -4466,11 +4466,56 @@ body.sb-ready #top-nav { border-color: rgba(71, 85, 105, 0.8); } -.dashboard-stats-content { +.dashboard-card-menu { + position: relative; +} + +.dashboard-card-menu > summary { + list-style: none; +} + +.dashboard-card-menu > summary::-webkit-details-marker { + display: none; +} + +.dashboard-card-menu-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 45; 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; + gap: 6px; + width: 196px; + padding: 10px; + border: 1px solid rgba(51, 65, 85, 0.82); + border-radius: 8px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 18px 38px rgba(2, 6, 23, 0.42); +} + +.dashboard-card-menu-panel label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 7px; + border-radius: 6px; + color: #cbd5e1; + font-size: var(--text-xs); + font-weight: 700; + cursor: pointer; +} + +.dashboard-card-menu-panel label:hover { + background: rgba(51, 65, 85, 0.62); +} + +.dashboard-card-menu-panel input { + accent-color: #38bdf8; +} + +.dashboard-stats-content { + min-height: calc(var(--dashboard-stats-height) + 12px); } .dashboard-stats-shell.is-collapsed .dashboard-stats-content, @@ -4513,6 +4558,7 @@ body.sb-ready #top-nav { .dashboard-health-card { position: relative; min-width: 0; + height: 100%; border: 1px solid rgba(51, 65, 85, 0.68); border-radius: 10px; background: rgba(15, 23, 42, 0.72); @@ -4540,11 +4586,44 @@ body.sb-ready #top-nav { border-color: rgba(56, 189, 248, 0.62); } +.dashboard-stats-content .grid-stack-item-content { + overflow: hidden !important; +} + +.dashboard-stats-content .grid-stack-item.dashboard-card-hidden { + display: none; +} + +.dashboard-stats-content .grid-stack-item.ui-draggable-dragging .grid-stack-item-content, +.dashboard-stats-content .grid-stack-item.ui-resizable-resizing .grid-stack-item-content { + border-color: rgba(56, 189, 248, 0.62); + box-shadow: 0 18px 42px rgba(2, 6, 23, 0.42); +} + +.dashboard-stats-content .grid-stack-placeholder > .placeholder-content, +.dashboard-stats-content .grid-stack-placeholder > .grid-stack-item-content { + border: 1px dashed rgba(56, 189, 248, 0.58); + border-radius: 10px; + background: rgba(8, 47, 73, 0.22); +} + +.dashboard-stats-content .ui-resizable-se { + right: 8px !important; + bottom: 8px !important; + width: 14px; + height: 14px; + opacity: 0; + transition: opacity 140ms ease; +} + +.dashboard-stats-content .grid-stack-item:hover .ui-resizable-se { + opacity: 0.72; +} + .dashboard-stat-card { display: flex; flex-direction: column; justify-content: space-between; - height: var(--dashboard-stats-height); padding: 14px; } @@ -4603,7 +4682,6 @@ body.sb-ready #top-nav { .dashboard-chart-card, .dashboard-health-card { - height: var(--dashboard-stats-height); padding: 12px 14px 10px; } @@ -4653,6 +4731,12 @@ body.sb-ready #top-nav { padding-bottom: 8px; } +.dashboard-chart-wrap canvas { + display: block; + max-width: 100%; + max-height: 100%; +} + .dashboard-health-list { position: relative; z-index: 1; @@ -4741,37 +4825,7 @@ body.sb-ready #top-nav { 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: clamp(1.65rem, calc(var(--dashboard-stats-height) / 3.4), 3rem); } diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index cf9071b..33901cc 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -12,11 +12,13 @@ + + @@ -34,6 +36,18 @@ Server version: loading...
+
+ + + +
+ + + + + +
+
@@ -42,79 +56,89 @@
-
-
-
- - Online -
-
0
-
- 0 total - 0% -
-
-
-
- - Sessions -
-
0
-
- 0 cmd/min - 0 errors +
+
+
+
+ + Online +
+
0
+
+ 0 total + 0% +
+
-
-
-
-
- Past Week - Clients Online +
+
+
+ + Sessions +
+
0
+
+ 0 cmd/min + 0 errors +
- Live
-
- -
-
-
-
-
- Fleet Mix - Operating Systems +
+
+
+
+ Past Week + Clients Online +
+ Live +
+
+ +
- None -
-
-
-
-
-
-
- Status - Server Pulse +
+
+
+
+ Fleet Mix + Operating Systems +
+ None +
+
+ +
- Waiting
-
-
- - API - Live -
-
- - Memory - 0% -
-
- - Ping Avg - - +
+
+
+
+ Status + Server Pulse +
+ Waiting +
+
+
+ + API + Live +
+
+ + Memory + 0% +
+
+ + Ping Avg + - +
+
-
Date: Thu, 4 Jun 2026 00:04:49 -0500 Subject: [PATCH 3/8] fixed dashboard cards, add fuse js, fix load times, fix csp in scripts, fix remote desktop not working first try. --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + .../public/assets/command-palette.js | 1 + .../public/assets/dashboard-stats.js | 14 + Overlord-Server/public/assets/data.js | 24 ++ Overlord-Server/public/assets/main.css | 32 ++ Overlord-Server/public/assets/nav.js | 95 +++-- .../public/assets/remotedesktop.js | 82 +++- Overlord-Server/public/assets/soft-nav.js | 357 ++++++++++++++++++ Overlord-Server/src/client-order.test.ts | 33 +- Overlord-Server/src/db/repositories.ts | 347 ++++++++++++----- Overlord-Server/src/db/schema.ts | 76 ++++ .../src/server/http-security.test.ts | 1 + Overlord-Server/src/server/http-security.ts | 2 +- .../src/server/ws-console-rd-hvnc.ts | 24 +- 15 files changed, 974 insertions(+), 118 deletions(-) create mode 100644 Overlord-Server/public/assets/soft-nav.js diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 602d889..7fae326 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -9,6 +9,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", "jose": "^6.1.3", @@ -311,6 +312,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fuse.js": ["fuse.js@7.4.1", "", {}, "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw=="], + "geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 8ce510b..6f85831 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -20,6 +20,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", "jose": "^6.1.3", diff --git a/Overlord-Server/public/assets/command-palette.js b/Overlord-Server/public/assets/command-palette.js index f3d0781..cffeeaa 100644 --- a/Overlord-Server/public/assets/command-palette.js +++ b/Overlord-Server/public/assets/command-palette.js @@ -268,6 +268,7 @@ function close() { function navigate(href, newTab = false) { pushRecent(href); if (newTab) window.open(href, "_blank", "noopener"); + else if (window.overlordSoftNavigate) window.overlordSoftNavigate(href); else window.location.href = href; close(); } diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js index 37439b0..f3ced54 100644 --- a/Overlord-Server/public/assets/dashboard-stats.js +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -22,6 +22,18 @@ const DEFAULT_GRID_LAYOUT = { }; let statsGrid = null; +function teardownDashboardStats() { + if (metricsTimer) clearInterval(metricsTimer); + metricsTimer = null; + onlineChart?.destroy?.(); + osChart?.destroy?.(); + onlineChart = null; + osChart = null; + statsGrid?.destroy?.(false); + statsGrid = null; + initialized = false; +} + const palette = { text: "#cbd5e1", muted: "#94a3b8", @@ -629,6 +641,8 @@ async function fetchDashboardMetrics() { export function initDashboardStats() { if (initialized) return; initialized = true; + window.removeEventListener("pagehide", teardownDashboardStats); + window.addEventListener("pagehide", teardownDashboardStats); if (!makeCharts()) { setTimeout(() => { if (makeCharts()) applyHeightMode(getSavedHeight()); diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index 9d8ecb7..7efedd9 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -14,6 +14,29 @@ let dashboardWsConnected = false; let wsReconnectTimer = null; const manuallyDisconnecting = new Set(); +function stopDashboardRealtime() { + clearInterval(pollTimer); + pollTimer = null; + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + if (dashboardWs) { + try { + dashboardWs.onopen = null; + dashboardWs.onmessage = null; + dashboardWs.onerror = null; + dashboardWs.onclose = null; + dashboardWs.close(1000, "page hidden"); + } catch {} + } + dashboardWs = null; + dashboardWsConnected = false; +} + +function bindDashboardPagehideCleanup() { + window.removeEventListener("pagehide", stopDashboardRealtime); + window.addEventListener("pagehide", stopDashboardRealtime); +} + function moveClientCardImmediately(msg) { const clientId = typeof msg?.clientId === "string" ? msg.clientId : ""; if (!clientId) return; @@ -243,6 +266,7 @@ function adjustPollingForWs() { } export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) { + bindDashboardPagehideCleanup(); connectDashboardWs(); const effectiveInterval = dashboardWsConnected ? FALLBACK_POLL_MS diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index a53c724..fc433c3 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1828,6 +1828,35 @@ mark.search-current { z-index: 10; width: 100%; min-height: 60px; + background: rgba(2, 6, 23, 0.92); + border-bottom: 1px solid rgba(30, 41, 59, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +#top-nav:empty::before { + content: "Overlord"; + display: flex; + align-items: center; + height: 60px; + padding: 0 20px; + color: #e2e8f0; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +html.nav-pre-sidebar #top-nav:empty::before { + align-items: flex-start; + height: auto; + padding: 18px 14px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before { + content: "O"; + justify-content: center; + padding-inline: 0; } #top-nav nav a { @@ -4515,7 +4544,10 @@ body.sb-ready #top-nav { } .dashboard-stats-content { + height: calc(var(--dashboard-stats-height) + 12px); min-height: calc(var(--dashboard-stats-height) + 12px); + contain: layout paint; + overflow: hidden; } .dashboard-stats-shell.is-collapsed .dashboard-stats-content, diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 9fceded..d6e704e 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -12,13 +12,19 @@ import { createAdaptiveNavController } from "./nav/layout.js"; import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js"; import { loadPluginNavItems } from "./nav/plugins-loader.js"; import { init as initCommandPalette } from "./command-palette.js"; +import { + installPageResourceTracker, + runWithoutPageTracking, + setupSoftNavigation, + startPageTracking, +} from "./soft-nav.js"; const host = document.getElementById("top-nav"); if (host) { const refs = mountNav(host); initCommandPalette(); import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => { - showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host); + runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host)); }); const { applyAdaptiveNavLayout, navHide } = createAdaptiveNavController(host, refs); @@ -44,13 +50,18 @@ if (host) { "/purgatory": "enrollment-link", }; - loadPluginNavItems(activeMap).then(() => { - const activeId = activeMap[path]; + const applyActivePath = (nextPath = window.location.pathname) => { + host.querySelectorAll(".nav-active").forEach((el) => el.classList.remove("nav-active")); + host.querySelectorAll(".sb-group-btn[aria-expanded='true']").forEach((btn) => btn.setAttribute("aria-expanded", "false")); + host.querySelectorAll(".sb-group-children.sb-group-open").forEach((children) => children.classList.remove("sb-group-open")); + host.querySelectorAll(".sb-chevron.sb-chevron-open").forEach((chevron) => chevron.classList.remove("sb-chevron-open")); + refs.accountSettingsBtn?.classList.remove("ring-1", "ring-sky-500/60", "bg-slate-700"); + + const activeId = activeMap[nextPath]; if (activeId) { const el = document.getElementById(activeId); if (el) { el.classList.add("nav-active"); - // Also expand the parent sidebar group if applicable const group = el.closest(".sb-group"); if (group) { const btn = group.querySelector(".sb-group-btn"); @@ -62,6 +73,14 @@ if (host) { } } } + + if (nextPath === "/settings" && refs.accountSettingsBtn) { + refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); + } + }; + + loadPluginNavItems(activeMap).then(() => { + applyActivePath(path); }); if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) { @@ -91,14 +110,14 @@ if (host) { if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) { refs.accountSettingsBtn.dataset.boundSettings = "true"; refs.accountSettingsBtn.addEventListener("click", () => { - window.location.href = "/settings"; + if (window.overlordSoftNavigate) { + window.overlordSoftNavigate("/settings"); + } else { + window.location.href = "/settings"; + } }); } - if (path === "/settings" && refs.accountSettingsBtn) { - refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); - } - const updateToggle = () => { const enabled = getNotificationsEnabled(); if (refs.notifyToggle) { @@ -139,6 +158,32 @@ if (host) { } }); + const prefetchedPages = new Set(); + const prefetchPage = (href) => { + try { + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin) return; + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + const key = `${url.pathname}${url.search}`; + if (prefetchedPages.has(key)) return; + prefetchedPages.add(key); + const link = document.createElement("link"); + link.rel = "prefetch"; + link.as = "document"; + link.href = key; + document.head.appendChild(link); + } catch {} + }; + + host.addEventListener("pointerenter", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }, true); + host.addEventListener("focusin", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }); + async function loadCurrentUser() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); @@ -178,19 +223,25 @@ if (host) { } import("./chat-widget.js").then((chatWidget) => { - chatWidget.init(); + runWithoutPageTracking(() => { + chatWidget.init(); - if (chatWidget.isHidden() && refs.navUtility) { - const restoreBtn = document.createElement("button"); - restoreBtn.id = "chat-restore-btn"; - restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; - restoreBtn.title = "Show team chat"; - restoreBtn.innerHTML = 'Chat'; - restoreBtn.addEventListener("click", () => { - chatWidget.show(); - restoreBtn.remove(); - }); - refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); - } + if (chatWidget.isHidden() && refs.navUtility) { + const restoreBtn = document.createElement("button"); + restoreBtn.id = "chat-restore-btn"; + restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; + restoreBtn.title = "Show team chat"; + restoreBtn.innerHTML = 'Chat'; + restoreBtn.addEventListener("click", () => { + chatWidget.show(); + restoreBtn.remove(); + }); + refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); + } + }); }); + + installPageResourceTracker(); + setupSoftNavigation(host, { onPathChange: applyActivePath }); + startPageTracking(); } diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index ce6c337..8871285 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -111,6 +111,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let clientOs = ""; let clientIsAdmin = false; let firewallWarningAcked = false; + let firstFrameLogged = false; + + function rdDebug(label, data = {}) { + try { + console.debug(`rd: ${label} ${JSON.stringify(data)}`); + } catch { + console.debug(`rd: ${label}`, data); + } + } function resetH264RuntimeState() { h264TimestampUs = 0; @@ -730,6 +739,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function handleStatus(msg) { if (!msg || msg.type !== "status" || !msg.status) return; + rdDebug("status", { + status: msg.status, + reason: msg.reason || "", + desiredStreaming, + streamState, + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + sessionId: msg.sessionId || "", + }); if (msg.status === "offline") { scheduleOffline(msg.reason); return; @@ -880,10 +897,26 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; return; } if (ws.readyState !== WebSocket.OPEN) { + rdDebug("send skipped websocket not open", { + type, + readyState: ws.readyState, + streamState, + desiredStreaming, + }); return; } const msg = { type, ...payload }; - console.debug("rd: send", msg); + rdDebug("send", { + msg, + readyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + transport: getWebrtcMode(), + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + }); ws.send(encodeMsgpack(msg)); } @@ -1021,6 +1054,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const res = await fetch("/api/clients"); const data = await res.json(); const client = data.items.find((c) => c.id === activeClientId); + rdDebug("client info", { + found: !!client, + monitors: client?.monitors, + monitorInfo: client?.monitorInfo, + os: client?.os || "", + isAdmin: !!client?.isAdmin, + }); if (client) { clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`; clientOs = (client.os || "").toLowerCase(); @@ -1115,6 +1155,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; startBtn.addEventListener("click", function () { const mode = getWebrtcMode(); + rdDebug("start click", { + mode, + wsReadyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + prefersH264, + duplication: !!duplicationCtrl?.checked, + clientOs, + clientIsAdmin, + }); if (needsWebrtcFirewallWarning(mode)) { if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) { return; @@ -1132,6 +1185,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; pushResolution(); desiredStreaming = true; lastFrameAt = 0; + firstFrameLogged = false; resetH264SessionState(); setStreamState("starting", "Starting stream"); if (mode === "relayed") { @@ -1267,6 +1321,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function markFrameReceived() { lastFrameAt = performance.now(); clearOfflineTimer(); + if (!firstFrameLogged) { + firstFrameLogged = true; + rdDebug("first frame received", { + streamState, + desiredStreaming, + canvas: { width: canvas.width, height: canvas.height }, + transport: getWebrtcMode(), + }); + } if (streamState !== "streaming" && desiredStreaming) { setStreamState("streaming", "Streaming"); } @@ -1700,6 +1763,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); ws.addEventListener("open", function () { + rdDebug("ws open", { + url: ws.url, + readyState: ws.readyState, + clientId, + }); if (qualitySlider) { pushQuality(qualitySlider.value); } @@ -1718,7 +1786,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); }); - ws.addEventListener("close", function () { + ws.addEventListener("close", function (event) { + rdDebug("ws close", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + streamState, + desiredStreaming, + }); desiredStreaming = false; disconnectAudio(); destroyVideoDecoder(); @@ -1729,7 +1804,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; setStreamState("disconnected", "Disconnected"); }); - ws.addEventListener("error", function () { + ws.addEventListener("error", function (event) { + console.warn("rd: ws error", event); destroyVideoDecoder(); stopAllWebrtc(); setStreamState("error", "WebSocket error"); diff --git a/Overlord-Server/public/assets/soft-nav.js b/Overlord-Server/public/assets/soft-nav.js new file mode 100644 index 0000000..758b31c --- /dev/null +++ b/Overlord-Server/public/assets/soft-nav.js @@ -0,0 +1,357 @@ +let installedTracker = false; +let pageTrackingEnabled = false; +let navSeq = 0; + +const tracked = { + events: new Set(), + intervals: new Set(), + sockets: new Set(), + workers: new Set(), +}; + +const original = {}; +const loadedClassicScripts = new Set(); +const persistentIds = new Set([ + "top-nav", + "sb-mobile-bar", + "sb-backdrop", + "nav-reveal-btn", + "chat-bubble", + "chat-panel", + "cert-trust-banner", +]); + +function normalizeUrl(value, base = window.location.href) { + try { + return new URL(value, base).href; + } catch { + return value || ""; + } +} + +function scriptPath(value) { + try { + return new URL(value, window.location.href).pathname; + } catch { + return ""; + } +} + +function shouldSkipScript(script) { + const path = scriptPath(script.getAttribute("src") || ""); + return path === "/assets/nav.js" || path === "/assets/nav-prelude.js"; +} + +function isPersistentNode(node) { + return node instanceof Element && persistentIds.has(node.id); +} + +function cleanupTrackedResources() { + pageTrackingEnabled = false; + window.dispatchEvent(new Event("pagehide")); + + for (const id of tracked.intervals) original.clearInterval(id); + for (const entry of tracked.events) { + original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options); + } + for (const socket of tracked.sockets) { + try { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "soft navigation"); + } catch {} + } + for (const worker of tracked.workers) { + try { + worker.terminate(); + } catch {} + } + + tracked.events.clear(); + tracked.intervals.clear(); + tracked.sockets.clear(); + tracked.workers.clear(); + + document.querySelectorAll("script[data-soft-nav-script]").forEach((script) => script.remove()); + document.getElementById("cmdp-root")?.remove(); + pageTrackingEnabled = true; +} + +export function runWithoutPageTracking(fn) { + const previous = pageTrackingEnabled; + pageTrackingEnabled = false; + try { + const result = fn(); + if (result && typeof result.then === "function") { + return result.finally(() => { + pageTrackingEnabled = previous; + }); + } + pageTrackingEnabled = previous; + return result; + } catch (err) { + pageTrackingEnabled = previous; + throw err; + } +} + +export function startPageTracking() { + pageTrackingEnabled = true; +} + +export function installPageResourceTracker() { + if (installedTracker) return; + installedTracker = true; + + document.querySelectorAll("script[src]").forEach((script) => { + const src = script.getAttribute("src"); + if (src && script.type !== "module") loadedClassicScripts.add(normalizeUrl(src)); + }); + + original.addEventListener = EventTarget.prototype.addEventListener; + original.removeEventListener = EventTarget.prototype.removeEventListener; + original.setInterval = window.setInterval; + original.clearInterval = window.clearInterval; + original.WebSocket = window.WebSocket; + original.Worker = window.Worker; + + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (pageTrackingEnabled && listener) { + tracked.events.add({ target: this, type, listener, options }); + } + return original.addEventListener.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function (type, listener, options) { + for (const entry of tracked.events) { + if (entry.target === this && entry.type === type && entry.listener === listener) { + tracked.events.delete(entry); + } + } + return original.removeEventListener.call(this, type, listener, options); + }; + + window.setInterval = function (...args) { + const id = original.setInterval.apply(window, args); + if (pageTrackingEnabled) tracked.intervals.add(id); + return id; + }; + + window.clearInterval = function (id) { + tracked.intervals.delete(id); + return original.clearInterval.call(window, id); + }; + + if (typeof original.WebSocket === "function") { + window.WebSocket = function (...args) { + const socket = new original.WebSocket(...args); + if (pageTrackingEnabled) tracked.sockets.add(socket); + return socket; + }; + window.WebSocket.prototype = original.WebSocket.prototype; + Object.defineProperty(window.WebSocket, "CONNECTING", { value: original.WebSocket.CONNECTING }); + Object.defineProperty(window.WebSocket, "OPEN", { value: original.WebSocket.OPEN }); + Object.defineProperty(window.WebSocket, "CLOSING", { value: original.WebSocket.CLOSING }); + Object.defineProperty(window.WebSocket, "CLOSED", { value: original.WebSocket.CLOSED }); + } + + if (typeof original.Worker === "function") { + window.Worker = function (...args) { + const worker = new original.Worker(...args); + if (pageTrackingEnabled) tracked.workers.add(worker); + return worker; + }; + window.Worker.prototype = original.Worker.prototype; + } +} + +function mergeHead(nextDoc, baseUrl) { + document.title = nextDoc.title || document.title; + + const currentHtmlClasses = Array.from(document.documentElement.classList).filter((name) => ( + name.startsWith("nav-pre") || name === "dark" + )); + document.documentElement.className = nextDoc.documentElement.className || ""; + currentHtmlClasses.forEach((name) => document.documentElement.classList.add(name)); + + nextDoc.head.querySelectorAll("link").forEach((link) => { + const href = link.getAttribute("href"); + const rel = link.getAttribute("rel") || ""; + if (!href) return; + const normalized = normalizeUrl(href, baseUrl); + const exists = Array.from(document.head.querySelectorAll("link")).some((existing) => ( + (existing.getAttribute("rel") || "") === rel + && normalizeUrl(existing.getAttribute("href") || "") === normalized + )); + if (!exists) { + const clone = link.cloneNode(true); + clone.setAttribute("href", normalized); + document.head.appendChild(clone); + } + }); + + nextDoc.head.querySelectorAll("style").forEach((style) => { + const text = style.textContent || ""; + const exists = Array.from(document.head.querySelectorAll("style[data-soft-nav-style]")) + .some((existing) => existing.textContent === text); + if (!exists) { + const clone = style.cloneNode(true); + clone.dataset.softNavStyle = "true"; + document.head.appendChild(clone); + } + }); +} + +function replaceBody(nextDoc) { + const preserved = Array.from(document.body.children).filter(isPersistentNode); + const nav = document.getElementById("top-nav"); + const anchor = document.getElementById("cert-trust-banner") + || document.getElementById("sb-mobile-bar") + || nav; + const preservedClasses = Array.from(document.body.classList).filter((name) => ( + name.startsWith("sb-") || name === "nav-hidden" + )); + + Array.from(document.body.children).forEach((node) => { + if (!preserved.includes(node)) node.remove(); + }); + + document.body.className = nextDoc.body.className || ""; + preservedClasses.forEach((name) => document.body.classList.add(name)); + + let insertAfter = anchor; + Array.from(nextDoc.body.children).forEach((node) => { + if (node.matches?.("script") || node.id === "top-nav") return; + const clone = document.importNode(node, true); + if (insertAfter?.parentNode) { + insertAfter.parentNode.insertBefore(clone, insertAfter.nextSibling); + insertAfter = clone; + } else { + document.body.appendChild(clone); + insertAfter = clone; + } + }); +} + +function appendScript(script, baseUrl) { + if (shouldSkipScript(script)) return Promise.resolve(); + + const src = script.getAttribute("src"); + const type = script.getAttribute("type") || ""; + if (src && type !== "module") { + const normalized = normalizeUrl(src, baseUrl); + if (loadedClassicScripts.has(normalized)) return Promise.resolve(); + loadedClassicScripts.add(normalized); + } + + return new Promise((resolve, reject) => { + const next = document.createElement("script"); + for (const { name, value } of script.attributes) { + if (name !== "src") next.setAttribute(name, value); + } + next.dataset.softNavScript = "true"; + next.onload = () => resolve(); + next.onerror = () => reject(new Error(`Failed to load ${src || "inline script"}`)); + + if (src) { + const url = new URL(src, baseUrl); + if (type === "module" && url.origin === window.location.origin) { + url.searchParams.set("softNav", String(navSeq)); + } + next.src = url.href; + } else { + next.textContent = script.textContent || ""; + } + + document.body.appendChild(next); + if (!src) resolve(); + }); +} + +async function runPageScripts(nextDoc, baseUrl) { + const scripts = Array.from(nextDoc.querySelectorAll("script")); + for (const script of scripts) { + await appendScript(script, baseUrl); + } +} + +function refreshGlobalEnhancements() { + if (typeof window.addRippleEffect === "function") { + document + .querySelectorAll("button:not(.no-ripple), .button:not(.no-ripple)") + .forEach((button) => window.addRippleEffect(button)); + } +} + +function isSoftNavigationCandidate(url, link) { + if (url.origin !== window.location.origin) return false; + if (link?.target && link.target !== "_self") return false; + if (link?.hasAttribute("download")) return false; + if (url.pathname.startsWith("/api/")) return false; + if (url.pathname.includes(".")) return false; + if (url.pathname === window.location.pathname && url.search === window.location.search) return false; + return true; +} + +export function setupSoftNavigation(host, { onPathChange } = {}) { + if (!host || host.dataset.softNavReady === "true") return; + host.dataset.softNavReady = "true"; + + async function navigateTo(href, { replace = false } = {}) { + const url = new URL(href, window.location.href); + if (!isSoftNavigationCandidate(url)) { + window.location.href = url.href; + return; + } + + navSeq += 1; + document.body.classList.add("soft-nav-loading"); + + try { + const res = await fetch(url.href, { credentials: "include" }); + if (!res.ok) throw new Error(`Navigation failed: ${res.status}`); + const html = await res.text(); + const nextDoc = new DOMParser().parseFromString(html, "text/html"); + if (!nextDoc.getElementById("top-nav")) { + window.location.href = url.href; + return; + } + + cleanupTrackedResources(); + mergeHead(nextDoc, url.href); + replaceBody(nextDoc); + window.scrollTo(0, 0); + if (replace) history.replaceState({}, "", url.href); + else history.pushState({}, "", url.href); + onPathChange?.(url.pathname); + await runPageScripts(nextDoc, url.href); + refreshGlobalEnhancements(); + window.dispatchEvent(new CustomEvent("overlord:soft-nav", { detail: { path: url.pathname } })); + } catch (err) { + console.error("Soft navigation failed:", err); + window.location.href = url.href; + } finally { + document.body.classList.remove("soft-nav-loading"); + } + } + + host.addEventListener("click", (event) => { + const link = event.target?.closest?.("a[href]"); + if (!link) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + + const url = new URL(link.getAttribute("href"), window.location.href); + if (!isSoftNavigationCandidate(url, link)) return; + + event.preventDefault(); + navigateTo(url.href); + }, true); + + window.addEventListener("popstate", () => { + navigateTo(window.location.href, { replace: true }); + }); + + window.overlordSoftNavigate = navigateTo; +} diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts index c5007cf..2c89f32 100644 --- a/Overlord-Server/src/client-order.test.ts +++ b/Overlord-Server/src/client-order.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; -import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db"; +import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db"; const createdClientIds: string[] = []; @@ -110,4 +110,33 @@ describe("client list ordering", () => { cleanupCreatedClients(); } }); -}); \ No newline at end of file + + test("search tolerates typos across client metadata", () => { + try { + const prefix = `fuse-${Date.now().toString(36)}`; + const id = `${prefix}-finance-terminal`; + + createTempClient(id, { + online: true, + lastSeen: Date.now(), + host: "finance-terminal", + }); + setClientTag(id, "Payroll Workstation", "Quarterly reporting machine"); + + const result = listClients({ + page: 1, + pageSize: 12, + search: "payrol workstaton", + sort: "last_seen_desc", + statusFilter: "all", + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "all", + }); + + expect(result.items.some((item) => item.id === id)).toBe(true); + } finally { + cleanupCreatedClients(); + } + }); +}); diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 37901b4..a4d9550 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -1,4 +1,5 @@ import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types"; +import Fuse from "fuse.js"; import { getThumbnailSummaries } from "../thumbnails"; import { db, dbPath } from "./connection"; import "./schema"; @@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags( return flags; } +const CLIENT_SEARCH_KEYS = [ + { name: "nickname", weight: 0.32 }, + { name: "host", weight: 0.28 }, + { name: "user", weight: 0.18 }, + { name: "id", weight: 0.18 }, + { name: "customTag", weight: 0.2 }, + { name: "customTagNote", weight: 0.1 }, + { name: "groupName", weight: 0.14 }, + { name: "os", weight: 0.12 }, + { name: "ip", weight: 0.1 }, + { name: "hwid", weight: 0.08 }, + { name: "country", weight: 0.06 }, + { name: "version", weight: 0.06 }, + { name: "buildTag", weight: 0.06 }, + { name: "cpu", weight: 0.05 }, + { name: "gpu", weight: 0.05 }, + { name: "ram", weight: 0.04 }, +] as const; + +const CLIENT_SEARCH_SQL_COLUMNS = [ + "COALESCE(c.nickname,'')", + "COALESCE(c.host,'')", + "COALESCE(c.user,'')", + "c.id", + "COALESCE(c.custom_tag,'')", + "COALESCE(c.custom_tag_note,'')", + "COALESCE(g.name,'')", + "COALESCE(c.os,'')", + "COALESCE(c.ip,'')", + "COALESCE(c.hwid,'')", + "COALESCE(c.country,'')", + "COALESCE(c.version,'')", + "COALESCE(c.build_tag,'')", + "COALESCE(c.cpu,'')", + "COALESCE(c.gpu,'')", + "COALESCE(c.ram,'')", +] as const; + +function escapeLikeTerm(term: string): string { + return term.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } { + const query = search.trim().toLowerCase(); + if (!query) return { sql: "", params: [] }; + + const terms = Array.from(new Set([ + query, + ...query.split(/[\s,;:|/\\]+/), + ].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8); + + if (!terms.length) return { sql: "", params: [] }; + + const fieldClause = CLIENT_SEARCH_SQL_COLUMNS + .map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`) + .join(" OR "); + const sql = terms.map(() => `(${fieldClause})`).join(" OR "); + const params = terms.flatMap((term) => { + const like = `%${escapeLikeTerm(term)}%`; + return CLIENT_SEARCH_SQL_COLUMNS.map(() => like); + }); + + return { sql: `(${sql})`, params }; +} + +function buildClientSearchFtsQuery(search: string): string { + const terms = Array.from(new Set( + search + .trim() + .split(/[\s,;:|/\\]+/) + .map((term) => term.replace(/["*]/g, "").trim()) + .filter((term) => term.length >= 2), + )).slice(0, 8); + + return terms.map((term) => `"${term}"*`).join(" OR "); +} + +function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null { + const ftsQuery = buildClientSearchFtsQuery(search); + if (!ftsQuery) return []; + + try { + return db + .query<{ id: string }>( + `SELECT id + FROM client_search_fts + WHERE client_search_fts MATCH ? + ORDER BY rank + LIMIT ?`, + ) + .all(ftsQuery, limit) + .map((row) => row.id) + .filter(Boolean); + } catch { + return null; + } +} + +function fuzzySearchClientRows(rows: any[], search: string): any[] { + const query = search.trim(); + if (!query) return rows; + + const fuse = new Fuse(rows, { + keys: CLIENT_SEARCH_KEYS as any, + threshold: 0.36, + distance: 120, + ignoreLocation: true, + shouldSort: true, + }); + + return fuse.search(query).map((result) => result.item); +} + export function listClients(filters: ListFilters): ListResult { const { page, @@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult { const where: string[] = []; const params: any[] = []; - if (search) { - where.push( - "(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)", - ); - const needle = `%${search}%`; - params.push(needle, needle, needle, needle, needle, needle, needle); - } - if (statusFilter === "online") { where.push("c.online=1"); } else if (statusFilter === "offline") { @@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult { const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const needsGroupJoinForFilter = - !!search || sort === "group_asc" || sort === "group_desc" || (groupFilter !== undefined && groupFilter !== "all"); - const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : ""; const orderBy = (() => { const online = "c.online DESC"; @@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult { } })(); - const totalRow = db - .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`) - .get(...params) ?? { c: 0 }; - const onlineRow = db - .query<{ c: number }>( - `SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`, - ) - .get(...params) ?? { c: 0 }; const offset = (page - 1) * pageSize; const clientFields = "c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason"; - const rows = needsGroupJoinForFilter - ? db - .query( - `SELECT ${clientFields}, g.name as groupName, g.color as groupColor - FROM clients c - LEFT JOIN client_groups g ON g.id = c.group_id - ${whereSql} - ${orderBy} - LIMIT ? OFFSET ?`, - ) - .all(...params, pageSize, offset) - : db - .query( - `WITH page_clients AS ( - SELECT ${clientFields} + let rows: any[] | undefined; + let totalCount = 0; + let onlineCount = 0; + + if (search) { + const searchWhere = [...where]; + const searchParams = [...params]; + const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8)); + const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit); + + if (ftsCandidateIds) { + if (!ftsCandidateIds.length) { + rows = []; + totalCount = 0; + onlineCount = 0; + } else { + searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`); + searchParams.push(...ftsCandidateIds); + } + } else { + const searchCandidate = buildFuzzyCandidateWhere(search); + if (searchCandidate.sql) { + searchWhere.push(searchCandidate.sql); + searchParams.push(...searchCandidate.params); + } + } + + if (rows === undefined) { + const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : ""; + const candidates = db + .query( + `SELECT ${clientFields}, g.name as groupName, g.color as groupColor FROM clients c + LEFT JOIN client_groups g ON g.id = c.group_id + ${searchWhereSql} + ${orderBy}`, + ) + .all(...searchParams); + const matches = fuzzySearchClientRows(candidates, search); + totalCount = matches.length; + onlineCount = matches.filter((row: any) => row.online === 1).length; + rows = matches.slice(offset, offset + pageSize); + } + } else { + const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : ""; + const totalRow = db + .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`) + .get(...params) ?? { c: 0 }; + const onlineRow = db + .query<{ c: number }>( + `SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`, + ) + .get(...params) ?? { c: 0 }; + totalCount = totalRow.c; + onlineCount = onlineRow.c; + + rows = needsGroupJoinForFilter + ? db + .query( + `SELECT ${clientFields}, g.name as groupName, g.color as groupColor + FROM clients c + LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql} ${orderBy} - LIMIT ? OFFSET ? - ) - SELECT pc.*, g.name as groupName, g.color as groupColor - FROM page_clients pc - LEFT JOIN client_groups g ON g.id = pc.groupId`, - ) - .all(...params, pageSize, offset); + LIMIT ? OFFSET ?`, + ) + .all(...params, pageSize, offset) + : db + .query( + `WITH page_clients AS ( + SELECT ${clientFields} + FROM clients c + ${whereSql} + ${orderBy} + LIMIT ? OFFSET ? + ) + SELECT pc.*, g.name as groupName, g.color as groupColor + FROM page_clients pc + LEFT JOIN client_groups g ON g.id = pc.groupId`, + ) + .all(...params, pageSize, offset); + } - const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS; + const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS); const suspiciousSets = shouldScanSuspicious ? getFloodSetsWithCache() : { floodedHwids: new Set(), floodedHardware: new Set(), floodedIps: new Set() }; + rows = rows || []; const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id)); const items = rows.map((c: any) => { @@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult { }; }); - return { page, pageSize, total: totalRow.c, online: onlineRow.c, items }; + return { page, pageSize, total: totalCount, online: onlineCount, items }; } export type ClientMetricsSummary = { @@ -998,7 +1152,56 @@ export type ClientMetricsSummary = { byCountryOnline: Record; }; +const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000; +let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null; +const userClientMetricsSummaryCache = new Map(); + +function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary { + return { + total: summary.total, + online: summary.online, + byOS: { ...summary.byOS }, + byCountry: { ...summary.byCountry }, + byOSOnline: { ...summary.byOSOnline }, + byCountryOnline: { ...summary.byCountryOnline }, + }; +} + +function makeClientMetricsSummary( + counts: { total: number | null; online: number | null }, + osRows: { key: string; total: number; online: number | null }[], + countryRows: { key: string; total: number; online: number | null }[], +): ClientMetricsSummary { + const byOS: Record = {}; + const byOSOnline: Record = {}; + for (const row of osRows) { + byOS[row.key] = Number(row.total) || 0; + byOSOnline[row.key] = Number(row.online) || 0; + } + + const byCountry: Record = {}; + const byCountryOnline: Record = {}; + for (const row of countryRows) { + byCountry[row.key] = Number(row.total) || 0; + byCountryOnline[row.key] = Number(row.online) || 0; + } + + return { + total: Number(counts.total) || 0, + online: Number(counts.online) || 0, + byOS, + byCountry, + byOSOnline, + byCountryOnline, + }; +} + export function getClientMetricsSummary(): ClientMetricsSummary { + const now = Date.now(); + if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) { + return cloneClientMetricsSummary(clientMetricsSummaryCache.summary); + } + const counts = db .query<{ total: number; online: number }>( `SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`, @@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary { ) .all(); - const byOS: Record = {}; - const byOSOnline: Record = {}; - for (const row of osRows) { - byOS[row.key] = Number(row.total) || 0; - byOSOnline[row.key] = Number(row.online) || 0; - } - - const byCountry: Record = {}; - const byCountryOnline: Record = {}; - for (const row of countryRows) { - byCountry[row.key] = Number(row.total) || 0; - byCountryOnline[row.key] = Number(row.online) || 0; - } - - return { - total: Number(counts.total) || 0, - online: Number(counts.online) || 0, - byOS, - byCountry, - byOSOnline, - byCountryOnline, + const summary = makeClientMetricsSummary(counts, osRows, countryRows); + clientMetricsSummaryCache = { + expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS, + summary, }; + return cloneClientMetricsSummary(summary); } export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary { + const now = Date.now(); + const cached = userClientMetricsSummaryCache.get(userId); + if (cached && cached.expiresAt > now) { + return cloneClientMetricsSummary(cached.summary); + } + const filter = `WHERE built_by_user_id = ?`; const counts = db @@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum ) .all(userId); - const byOS: Record = {}; - const byOSOnline: Record = {}; - for (const row of osRows) { - byOS[row.key] = Number(row.total) || 0; - byOSOnline[row.key] = Number(row.online) || 0; - } - - const byCountry: Record = {}; - const byCountryOnline: Record = {}; - for (const row of countryRows) { - byCountry[row.key] = Number(row.total) || 0; - byCountryOnline[row.key] = Number(row.online) || 0; - } - - return { - total: Number(counts.total) || 0, - online: Number(counts.online) || 0, - byOS, - byCountry, - byOSOnline, - byCountryOnline, - }; + const summary = makeClientMetricsSummary(counts, osRows, countryRows); + userClientMetricsSummaryCache.set(userId, { + expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS, + summary, + }); + return cloneClientMetricsSummary(summary); } export function getOnlineClientCountForUser(userId: number): number { diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 9cbc316..ec61773 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -148,6 +148,82 @@ try { } catch {} db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`); +try { + db.run(` + CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5( + id UNINDEXED, + host, + user, + nickname, + custom_tag, + custom_tag_note, + os, + ip, + hwid, + country, + version, + build_tag, + cpu, + gpu, + ram, + tokenize = 'unicode61' + ); + `); + + const counts = db + .query<{ clients: number; indexed: number }>( + `SELECT + (SELECT COUNT(*) FROM clients) as clients, + (SELECT COUNT(*) FROM client_search_fts) as indexed`, + ) + .get() ?? { clients: 0, indexed: 0 }; + + if (counts.clients !== counts.indexed) { + db.run(`DELETE FROM client_search_fts`); + db.run(` + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + SELECT + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + FROM clients + `); + } + + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + VALUES ( + new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note, + new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram + ); + END; + `); + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN + DELETE FROM client_search_fts WHERE rowid = old.rowid; + END; + `); + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN + DELETE FROM client_search_fts WHERE rowid = old.rowid; + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + VALUES ( + new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note, + new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram + ); + END; + `); +} catch {} + db.run(` CREATE TABLE IF NOT EXISTS banned_ips ( ip TEXT PRIMARY KEY, diff --git a/Overlord-Server/src/server/http-security.test.ts b/Overlord-Server/src/server/http-security.test.ts index dccc516..7080ba1 100644 --- a/Overlord-Server/src/server/http-security.test.ts +++ b/Overlord-Server/src/server/http-security.test.ts @@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => { const csp = SECURITY_HEADERS["Content-Security-Policy"]; expect(csp).toContain("default-src 'self'"); expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("font-src 'self' data:"); expect(csp).toContain("frame-ancestors 'none'"); }); diff --git a/Overlord-Server/src/server/http-security.ts b/Overlord-Server/src/server/http-security.ts index 202135b..bdaaf4a 100644 --- a/Overlord-Server/src/server/http-security.ts +++ b/Overlord-Server/src/server/http-security.ts @@ -5,7 +5,7 @@ export const CORS_HEADERS = { }; export const SECURITY_HEADERS = { - "Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block", diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index 1f6017e..1098477 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -160,6 +160,7 @@ function broadcastFrameToViewers( } const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 }; +const rdDebugFrameLogAt = new Map(); export const rdStreamingState = new Map, commandId: string) { - if (!target) return false; + if (!target) { + logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`); + return false; + } try { - logger.debug(`[rd] send command ${commandType} -> ${target.id}`); + logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`); target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId })); metrics.recordCommand(commandType); return true; @@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`); switch (payload.type) { case "desktop_start": + logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`); if (!state.isStreaming) { if (target.os === "darwin" && target.permissions) { const missing: string[] = []; @@ -363,14 +368,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket sendDesktopCommand(target, "desktop_start", {}); state.isStreaming = true; rdStreamingState.set(clientId, state); - logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`); + logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`); } else { - logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`); + logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`); } break; case "desktop_stop": { const otherViewers = sessionManager.getRdSessionsForClient(clientId) .filter(s => s.id !== ws.data.sessionId); + logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`); if (otherViewers.length === 0) { stopRemoteDesktopRecording(clientId, "desktop stopped"); sendDesktopCommand(target, "desktop_stop", {}); @@ -648,6 +654,12 @@ function handleRemoteDesktopFrame(payload: any) { const frameFps = Number(header?.fps) || 0; if (frameFps > 0) state.lastFps = frameFps; rdStreamingState.set(clientId, state); + const now = Date.now(); + const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0; + if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) { + rdDebugFrameLogAt.set(clientId, now); + logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`); + } broadcastRemoteDesktopFrame(clientId, bytes, header); } @@ -660,7 +672,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header } recordRemoteDesktopFrame(clientId, header, bytes); const buf = buildViewerFrameBuffer(bytes, header); - return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header); + const sessions = sessionManager.getRdSessionsForClient(clientId); + const sent = broadcastFrameToViewers(sessions, buf, header); + return sent; } (globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => { From fa069fd37414ca67191c6f6bbb72ac57f5f40768 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 00:14:18 -0500 Subject: [PATCH 4/8] more tests --- Overlord-Server/src/client-order.test.ts | 73 ++++++++++ .../src/server/ws-console-rd-hvnc.test.ts | 125 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 Overlord-Server/src/server/ws-console-rd-hvnc.test.ts diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts index 2c89f32..2ce4a5e 100644 --- a/Overlord-Server/src/client-order.test.ts +++ b/Overlord-Server/src/client-order.test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; +import { db } from "./db/connection"; import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db"; const createdClientIds: string[] = []; @@ -140,3 +141,75 @@ describe("client list ordering", () => { } }); }); + +describe("client search index", () => { + function getSearchIndexRow(id: string) { + return db + .query<{ id: string; host: string | null; customTag: string | null; customTagNote: string | null }>( + `SELECT id, host, custom_tag as customTag, custom_tag_note as customTagNote + FROM client_search_fts + WHERE id = ?`, + ) + .get(id); + } + + test("keeps the FTS search index in sync when client metadata changes", () => { + try { + const id = `fts-sync-${Date.now().toString(36)}`; + + createTempClient(id, { + online: true, + lastSeen: Date.now(), + host: "dispatch-terminal-old", + }); + + expect(getSearchIndexRow(id)?.host).toBe("dispatch-terminal-old"); + + createTempClient(id, { + online: true, + lastSeen: Date.now(), + host: "dispatch-terminal-new", + }); + setClientTag(id, "Priority Ops", "Shift handoff workstation"); + + const updatedRow = getSearchIndexRow(id); + expect(updatedRow?.host).toBe("dispatch-terminal-new"); + expect(updatedRow?.customTag).toBe("Priority Ops"); + expect(updatedRow?.customTagNote).toBe("Shift handoff workstation"); + + deleteClientRow(id); + expect(getSearchIndexRow(id)).toBeNull(); + } finally { + cleanupCreatedClients(); + } + }); + + test("uses indexed candidates before fuzzy matching so exact token searches stay fast", () => { + try { + const prefix = `fts-candidate-${Date.now().toString(36)}`; + const id = `${prefix}-ops-node`; + + createTempClient(id, { + online: true, + lastSeen: Date.now(), + host: "northbridge-ops-node", + }); + setClientTag(id, "Incident Desk", "CPU spike triage host"); + + const result = listClients({ + page: 1, + pageSize: 12, + search: "northbridge", + sort: "last_seen_desc", + statusFilter: "all", + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "all", + }); + + expect(result.items.some((item) => item.id === id)).toBe(true); + } finally { + cleanupCreatedClients(); + } + }); +}); diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts new file mode 100644 index 0000000..882317c --- /dev/null +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as clientManager from "../clientManager"; +import { decodeMessage } from "../protocol"; +import * as sessionManager from "../sessions/sessionManager"; +import type { SocketData } from "../sessions/types"; +import type { ClientInfo } from "../types"; +import { + handleRemoteDesktopViewerMessage, + handleRemoteDesktopViewerOpen, + rdStreamingState, +} from "./ws-console-rd-hvnc"; + +type MockWs = { + data: SocketData; + sent: unknown[]; + closedCode?: number; + closedReason?: string; + send: (msg: unknown) => void; + close: (code: number, reason: string) => void; + getBufferedAmount: () => number; +}; + +const clientIdsToCleanup = new Set(); + +function createMockWs(data: Partial): MockWs { + return { + data: { + role: "rd_viewer", + clientId: "rd-test-client", + ...data, + } as SocketData, + sent: [], + send(msg: unknown) { + this.sent.push(msg); + }, + close(code: number, reason: string) { + this.closedCode = code; + this.closedReason = reason; + }, + getBufferedAmount() { + return 0; + }, + }; +} + +function createClient(id: string) { + const agentWs = createMockWs({ role: "client", clientId: id }); + const info: ClientInfo = { + id, + role: "client", + ws: agentWs, + lastSeen: Date.now(), + online: true, + host: "rd-test-host", + os: "windows", + user: "tester", + monitors: 1, + }; + clientManager.addClient(id, info); + clientIdsToCleanup.add(id); + return { info, agentWs }; +} + +function agentCommands(ws: MockWs) { + return ws.sent.map((msg) => decodeMessage(msg as Uint8Array) as any); +} + +afterEach(() => { + for (const clientId of clientIdsToCleanup) { + for (const session of sessionManager.getRdSessionsForClient(clientId)) { + sessionManager.deleteRdSession(session.id); + } + rdStreamingState.delete(clientId); + clientManager.deleteClient(clientId); + } + clientIdsToCleanup.clear(); +}); + +describe("remote desktop viewer control", () => { + test("starts once, ignores duplicate starts, and only stops after the last viewer leaves", () => { + const clientId = `rd-control-${Date.now().toString(36)}`; + const { agentWs } = createClient(clientId); + const firstViewer = createMockWs({ clientId }); + const secondViewer = createMockWs({ clientId }); + + handleRemoteDesktopViewerOpen(firstViewer as any); + handleRemoteDesktopViewerOpen(secondViewer as any); + + handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_start" })); + handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_start" })); + + let commands = agentCommands(agentWs); + expect(commands.filter((msg) => msg.commandType === "desktop_start")).toHaveLength(1); + expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true); + + handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_stop" })); + + commands = agentCommands(agentWs); + expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(0); + expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true); + + sessionManager.deleteRdSession(firstViewer.data.sessionId!); + handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_stop" })); + + commands = agentCommands(agentWs); + expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(1); + expect(commands.filter((msg) => msg.commandType === "webrtc_stop")).toHaveLength(1); + expect(rdStreamingState.get(clientId)?.isStreaming).toBe(false); + }); + + test("does not forward desktop_start when a macOS client is missing required permissions", () => { + const clientId = `rd-mac-perms-${Date.now().toString(36)}`; + const { info, agentWs } = createClient(clientId); + info.os = "darwin"; + info.permissions = { screenRecording: false, accessibility: true }; + const viewer = createMockWs({ clientId }); + + handleRemoteDesktopViewerOpen(viewer as any); + handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_start" })); + + expect(agentCommands(agentWs).filter((msg) => msg.commandType === "desktop_start")).toHaveLength(0); + expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true); + expect(viewer.sent.length).toBeGreaterThan(0); + }); +}); From 8240dd61f17a9d67e7f26ec8e980fed3bb3fda3c Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 16:07:42 -0500 Subject: [PATCH 5/8] add graph view for people --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + Overlord-Server/public/assets/client-graph.js | 341 ++++++++++++++++++ Overlord-Server/public/assets/main.css | 305 ++++++++++++++++ Overlord-Server/public/assets/nav.js | 1 + Overlord-Server/public/assets/nav/template.js | 1 + Overlord-Server/public/graph.html | 68 ++++ Overlord-Server/scripts/vendor.ts | 8 + .../src/server/client-graph.test.ts | 55 +++ Overlord-Server/src/server/client-graph.ts | 213 +++++++++++ .../src/server/routes/misc-routes.ts | 45 ++- .../src/server/routes/page-routes.ts | 1 + 12 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 Overlord-Server/public/assets/client-graph.js create mode 100644 Overlord-Server/public/graph.html create mode 100644 Overlord-Server/src/server/client-graph.test.ts create mode 100644 Overlord-Server/src/server/client-graph.ts diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 7fae326..218ad01 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -9,6 +9,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "cytoscape": "^3.34.0", "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", @@ -264,6 +265,8 @@ "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 6f85831..494d30a 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -20,6 +20,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "cytoscape": "^3.34.0", "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", diff --git a/Overlord-Server/public/assets/client-graph.js b/Overlord-Server/public/assets/client-graph.js new file mode 100644 index 0000000..a00538a --- /dev/null +++ b/Overlord-Server/public/assets/client-graph.js @@ -0,0 +1,341 @@ +const container = document.getElementById("client-graph"); +const searchInput = document.getElementById("graph-search"); +const statusSelect = document.getElementById("graph-status"); +const layoutSelect = document.getElementById("graph-layout"); +const refreshBtn = document.getElementById("graph-refresh"); +const fitBtn = document.getElementById("graph-fit"); +const summaryEl = document.getElementById("graph-summary"); +const detailTitle = document.getElementById("graph-detail-title"); +const detailMeta = document.getElementById("graph-detail-meta"); +const detailActions = document.getElementById("graph-detail-actions"); + +let cy = null; +let currentGraph = null; +let searchTimer = null; +const LARGE_GRAPH_ANIMATION_CLIENTS = 30000; + +const typeColors = { + client: "#38bdf8", + group: "#a78bfa", + build: "#f59e0b", + os: "#22c55e", + country: "#f472b6", + subnet: "#60a5fa", + user: "#f97316", + status: "#64748b", +}; + +function flagEmoji(country) { + const cc = String(country || "").trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(cc) || cc === "ZZ") return ""; + const base = 0x1f1e6; + return String.fromCodePoint( + base + cc.charCodeAt(0) - 65, + base + cc.charCodeAt(1) - 65, + ); +} + +function osSymbol(osRaw) { + const os = String(osRaw || "").toLowerCase(); + if (os.includes("win")) return "⊞"; + if (os.includes("mac") || os.includes("darwin")) return "⌘"; + if (os.includes("android")) return "▣"; + if (os.includes("ubuntu")) return "◌"; + if (os.includes("debian")) return "◆"; + if (os.includes("fedora")) return "●"; + if (os.includes("freebsd")) return "◇"; + if (os.includes("linux")) return "⬢"; + return "◯"; +} + +function nodeDisplayLabel(data) { + const label = String(data.label || data.id || ""); + if (data.type === "country") { + const flag = flagEmoji(label); + return flag ? `${flag} ${label} ${flag}` : label; + } + if (data.type === "os") return `${osSymbol(label)}\n${label}`; + if (data.type === "status") return `${data.online ? "●" : "○"}\n${label}`; + if (data.type === "group") return `◫\n${label}`; + if (data.type === "build") return `⬡\n${label}`; + if (data.type === "subnet") return `⌁\n${label}`; + if (data.type === "user") return `@\n${label}`; + return label; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function setSummary(summary) { + if (!summaryEl) return; + const total = summary.totalClients && summary.totalClients !== summary.clients + ? ` of ${summary.totalClients}` + : ""; + summaryEl.textContent = `${summary.clients}${total} clients, ${summary.online} online, ${summary.relationships} relationships`; +} + +function graphElements(graph) { + return [ + ...graph.nodes.map((node) => ({ + group: "nodes", + data: { + ...node.data, + color: node.data.color || typeColors[node.data.type] || "#94a3b8", + displayLabel: nodeDisplayLabel(node.data), + }, + })), + ...graph.edges.map((edge) => ({ group: "edges", data: edge.data })), + ]; +} + +function shouldAnimateLayout(graph = currentGraph) { + const total = Number(graph?.summary?.totalClients || graph?.summary?.clients || 0); + return total < LARGE_GRAPH_ANIMATION_CLIENTS; +} + +function layoutOptions(name, graph = currentGraph) { + const animate = shouldAnimateLayout(graph); + if (name === "grid") { + return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 }; + } + if (name === "circle") { + return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 }; + } + if (name === "breadthfirst") { + return { name, directed: false, animate, animationDuration: animate ? 260 : 0, padding: 36, spacingFactor: 1.15 }; + } + return { + name: "cose", + animate, + animationDuration: animate ? 360 : 0, + padding: 40, + idealEdgeLength: 110, + nodeRepulsion: 5200, + gravity: 0.28, + numIter: 900, + }; +} + +function showSelection(node) { + if (!detailTitle || !detailMeta || !detailActions) return; + if (!node) { + detailTitle.textContent = "Nothing selected"; + detailMeta.innerHTML = ""; + detailActions.innerHTML = ""; + return; + } + + const data = node.data(); + const meta = data.meta || {}; + const countryFlag = flagEmoji(meta.country || (data.type === "country" ? data.label : "")); + detailTitle.textContent = data.type === "country" && countryFlag + ? `${countryFlag} ${data.label || data.id} ${countryFlag}` + : data.label || data.id; + const rows = [ + ["Type", data.type], + ["Count", data.type === "client" ? "" : data.count || 1], + ["Online", data.type === "client" ? (data.online ? "Yes" : "No") : data.onlineCount || 0], + ["Client ID", data.clientId || meta.id || ""], + ["Host", meta.host || ""], + ["User", meta.user || ""], + ["OS", meta.os || ""], + ["IP", meta.ip || ""], + ["Country", meta.country ? `${countryFlag ? `${countryFlag} ` : ""}${meta.country}${countryFlag ? ` ${countryFlag}` : ""}` : ""], + ["Group", meta.groupName || ""], + ["Build", meta.buildTag || ""], + ].filter(([, value]) => value !== "" && value !== undefined && value !== null); + + detailMeta.innerHTML = rows.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); + + if (data.clientId) { + const id = encodeURIComponent(data.clientId); + const online = data.online === true; + const disabled = online ? "" : " disabled aria-disabled=\"true\" title=\"Client is offline\""; + const disabledNote = online + ? "" + : `
Client is offline
`; + detailActions.innerHTML = ` + ${disabledNote} + + + + + `; + } else { + detailActions.innerHTML = ""; + } +} + +function bindInspectorActions() { + detailActions?.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + if (button.disabled || button.getAttribute("aria-disabled") === "true") return; + const href = button.dataset.graphAction; + if (href) { + window.open(href, "_blank", "noopener"); + return; + } + const copyValue = button.dataset.graphCopy; + if (copyValue) { + await navigator.clipboard?.writeText(copyValue); + } + }); +} + +function selectNode(node) { + if (!cy) return; + cy.elements().removeClass("graph-dim graph-focus"); + if (!node) { + showSelection(null); + return; + } + const neighborhood = node.closedNeighborhood(); + cy.elements().difference(neighborhood).addClass("graph-dim"); + neighborhood.addClass("graph-focus"); + showSelection(node); +} + +function renderGraph(graph) { + currentGraph = graph; + setSummary(graph.summary); + + if (!window.cytoscape) { + if (summaryEl) summaryEl.textContent = "Cytoscape failed to load"; + return; + } + + if (!cy) { + cy = window.cytoscape({ + container, + elements: graphElements(graph), + minZoom: 0.18, + maxZoom: 5, + wheelSensitivity: 1.8, + style: [ + { + selector: "node", + style: { + label: "data(displayLabel)", + "background-color": "data(color)", + "border-color": "#020617", + "border-width": 2, + color: "#dbeafe", + "font-family": "Inter, Segoe UI, sans-serif", + "font-size": 11, + "font-weight": 600, + "text-outline-color": "#020617", + "text-outline-width": 3, + "text-valign": "center", + "text-halign": "center", + "line-height": 1.15, + "text-wrap": "wrap", + "text-max-width": 88, + width: "mapData(weight, 1, 12, 28, 74)", + height: "mapData(weight, 1, 12, 28, 74)", + }, + }, + { + selector: 'node[type = "client"]', + style: { + shape: "round-rectangle", + "border-color": "data(color)", + "border-width": 3, + }, + }, + { + selector: 'node[online = true]', + style: { + "border-color": "#22c55e", + }, + }, + { + selector: "edge", + style: { + width: 1.35, + "line-color": "rgba(148, 163, 184, 0.42)", + "target-arrow-shape": "none", + "curve-style": "bezier", + }, + }, + { + selector: ".graph-dim", + style: { + opacity: 0.12, + }, + }, + { + selector: ".graph-focus", + style: { + opacity: 1, + }, + }, + ], + }); + + cy.on("tap", "node", (event) => selectNode(event.target)); + cy.on("tap", (event) => { + if (event.target === cy) selectNode(null); + }); + } else { + cy.elements().remove(); + cy.add(graphElements(graph)); + } + + cy.layout(layoutOptions(layoutSelect?.value || "cose", graph)).run(); + setTimeout(() => cy?.fit(undefined, 32), 420); +} + +async function loadGraph() { + if (!container) return; + if (summaryEl) summaryEl.textContent = "Loading..."; + const params = new URLSearchParams({ + limit: "350", + q: searchInput?.value || "", + status: statusSelect?.value || "all", + }); + const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" }); + if (!res.ok) { + if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`; + return; + } + renderGraph(await res.json()); +} + +function debounceLoad() { + clearTimeout(searchTimer); + searchTimer = setTimeout(loadGraph, 220); +} + +layoutSelect?.addEventListener("change", () => { + if (cy) cy.layout(layoutOptions(layoutSelect.value, currentGraph)).run(); +}); +searchInput?.addEventListener("input", debounceLoad); +statusSelect?.addEventListener("change", loadGraph); +refreshBtn?.addEventListener("click", loadGraph); +fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32)); +window.addEventListener("resize", () => { + cy?.resize(); + cy?.fit(undefined, 32); +}); + +bindInspectorActions(); +loadGraph(); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index fc433c3..3fee38c 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1135,6 +1135,311 @@ article.card[data-bookmarked="true"] { justify-content: center; } +.graph-shell { + min-height: calc(100vh - 64px); + display: grid; + grid-template-rows: auto 1fr; + gap: 14px; + padding: 18px 20px 20px; +} + +.graph-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; + border: 1px solid rgba(30, 41, 59, 0.86); + background: rgba(2, 6, 23, 0.78); + border-radius: 8px; + padding: 12px; +} + +.graph-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.graph-title > i { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 8px; + background: rgba(8, 145, 178, 0.12); + color: #67e8f9; + border: 1px solid rgba(103, 232, 249, 0.18); +} + +.graph-title h1 { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: #e2e8f0; +} + +.graph-title p { + margin: 2px 0 0; + color: #94a3b8; + font-size: 0.78rem; +} + +.graph-controls { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + +.graph-field { + height: 38px; + min-width: min(280px, 44vw); + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(51, 65, 85, 0.95); + border-radius: 8px; + background: rgba(15, 23, 42, 0.92); + color: #94a3b8; + padding: 0 10px; +} + +.graph-field:focus-within { + border-color: rgba(56, 189, 248, 0.65); + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.12); +} + +.graph-field input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #e2e8f0; + font-size: 0.86rem; +} + +.graph-select { + height: 38px; + border: 1px solid rgba(51, 65, 85, 0.95); + border-radius: 8px; + background: #0f172a; + color: #dbeafe; + padding: 0 10px; + font-size: 0.84rem; +} + +.graph-workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 14px; +} + +.graph-canvas { + min-height: 560px; + height: calc(100vh - 170px); + border: 1px solid rgba(30, 41, 59, 0.9); + border-radius: 8px; + background: + linear-gradient(rgba(15, 23, 42, 0.86), rgba(2, 6, 23, 0.94)), + linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px), + linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: auto, 34px 34px, 34px 34px; + overflow: hidden; +} + +.graph-inspector { + min-height: 0; + height: calc(100vh - 170px); + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + background: #141c2b; + box-shadow: + 0 14px 38px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255, 255, 255, 0.03); + padding: 8px; + scrollbar-color: rgba(100, 116, 139, 0.7) transparent; +} + +.graph-inspector-kicker { + color: #94a3b8; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 8px 8px 0; +} + +.graph-inspector h2 { + margin: 4px 8px 10px; + color: #f8fafc; + font-size: 1.05rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.graph-detail-meta { + display: grid; + gap: 1px; + padding: 4px; + border-top: 1px solid rgba(148, 163, 184, 0.13); + border-bottom: 1px solid rgba(148, 163, 184, 0.13); +} + +.graph-meta-row { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 32px; + border-radius: 5px; + padding: 6px 8px; + background: transparent; +} + +.graph-meta-row span { + color: #7c8aa0; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.graph-meta-row strong { + color: #cbd5e1; + font-size: 0.8rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.graph-meta-row:hover { + background: rgba(71, 85, 105, 0.32); +} + +.graph-detail-actions { + display: grid; + gap: 1px; + margin-top: 8px; + padding: 4px; +} + +.graph-action-note { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + color: #fca5a5; + background: rgba(127, 29, 29, 0.18); + border: 1px solid rgba(248, 113, 113, 0.14); + border-radius: 5px; + padding: 7px 10px; + font-size: 0.78rem; + font-weight: 600; + margin-bottom: 4px; +} + +.graph-action { + min-height: 34px; + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 9px; + border-radius: 5px; + border: none; + background: transparent; + color: #cbd5e1; + padding: 7px 10px; + font-size: 0.82rem; + font-weight: 600; + text-align: left; + transition: background 0.1s ease, color 0.1s ease; +} + +.graph-action:hover:not(:disabled) { + background: rgba(71, 85, 105, 0.55); + color: #f1f5f9; +} + +.graph-action:disabled { + cursor: not-allowed; + opacity: 0.38; + filter: grayscale(0.55); +} + +.graph-action--remote { + color: #cbd5e1; +} + +.graph-action--remote i { + color: #c084fc; + width: 16px; + text-align: center; +} + +.graph-action--console { + color: #cbd5e1; +} + +.graph-action--console i { + color: #34d399; + width: 16px; + text-align: center; +} + +.graph-action--files { + color: #cbd5e1; +} + +.graph-action--files i { + color: #60a5fa; + width: 16px; + text-align: center; +} + +.graph-action--copy { + color: #cbd5e1; +} + +.graph-action--copy i { + color: #94a3b8; + width: 16px; + text-align: center; +} + +@media (max-width: 980px) { + .graph-toolbar { + align-items: stretch; + flex-direction: column; + } + + .graph-controls { + justify-content: flex-start; + } + + .graph-field { + min-width: min(100%, 360px); + } + + .graph-workspace { + grid-template-columns: 1fr; + } + + .graph-canvas, + .graph-inspector { + height: auto; + } + + .graph-canvas { + min-height: 520px; + } +} + @keyframes radioactiveShake { 0%, 100% { diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index d6e704e..f953bc6 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -36,6 +36,7 @@ if (host) { const activeMap = { "/": "nav-clients", "/metrics": "metrics-link", + "/graph": "graph-link", "/screenshots": "screenshots-link", "/logs": "logs-link", "/scripts": "scripts-link", diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index f346ab7..77aa4d9 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -63,6 +63,7 @@ export const NAV_GROUPS = [ iconColor: "text-emerald-400", children: [ { href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" }, + { href: "/graph", label: "Graph", icon: "fa-diagram-project", iconColor: "text-cyan-400", linkId: "graph-link" }, { href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true }, ], }, diff --git a/Overlord-Server/public/graph.html b/Overlord-Server/public/graph.html new file mode 100644 index 0000000..56a180f --- /dev/null +++ b/Overlord-Server/public/graph.html @@ -0,0 +1,68 @@ + + + + + + + + Overlord Graph + + + + + + + + + + + + +
+
+
+
+ +
+

Client Graph

+

Loading...

+
+
+
+ + + + + +
+
+ +
+
+ +
+
+ + + diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 1086f05..90c6722 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -199,6 +199,14 @@ copyFile( path.join(VENDOR, "gridstack", "gridstack.min.css"), ); +/* ── Cytoscape.js ────────────────────────────────────────────────── */ + +console.log("Copying Cytoscape.js ..."); +copyFile( + path.join(NM, "cytoscape", "dist", "cytoscape.min.js"), + path.join(VENDOR, "cytoscape", "cytoscape.min.js"), +); + /* ── highlight.js (bundle core + languages) ──────────────────────── */ console.log("Bundling highlight.js ..."); diff --git a/Overlord-Server/src/server/client-graph.test.ts b/Overlord-Server/src/server/client-graph.test.ts new file mode 100644 index 0000000..d754a04 --- /dev/null +++ b/Overlord-Server/src/server/client-graph.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { buildClientGraph, clientIpSubnet } from "./client-graph"; + +describe("client graph", () => { + test("builds client relationship nodes and edges", () => { + const graph = buildClientGraph([ + { + id: "client-a", + host: "alpha", + user: "alice", + os: "windows", + ip: "10.20.30.40", + country: "US", + online: true, + lastSeen: Date.now(), + groupId: 7, + groupName: "Ops", + groupColor: "#38bdf8", + buildTag: "prod-build", + } as any, + { + id: "client-b", + host: "beta", + user: "alice", + os: "windows", + ip: "10.20.30.55", + country: "US", + online: false, + lastSeen: Date.now(), + groupId: 7, + groupName: "Ops", + groupColor: "#38bdf8", + buildTag: "prod-build", + } as any, + ]); + + const nodeIds = new Set(graph.nodes.map((node) => node.data.id)); + expect(nodeIds.has("client:client-a")).toBe(true); + expect(nodeIds.has("client:client-b")).toBe(true); + expect(nodeIds.has("group:7")).toBe(true); + expect(nodeIds.has("build:prod-build")).toBe(true); + expect(nodeIds.has("os:windows")).toBe(true); + expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true); + expect(nodeIds.has("user:alice")).toBe(true); + expect(graph.summary.clients).toBe(2); + expect(graph.summary.online).toBe(1); + expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true); + }); + + test("normalizes IPv4 and IPv6 subnets", () => { + expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24"); + expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64"); + expect(clientIpSubnet("not-an-ip")).toBeNull(); + }); +}); diff --git a/Overlord-Server/src/server/client-graph.ts b/Overlord-Server/src/server/client-graph.ts new file mode 100644 index 0000000..428ab44 --- /dev/null +++ b/Overlord-Server/src/server/client-graph.ts @@ -0,0 +1,213 @@ +import type { ListItem } from "../types"; + +export type GraphNodeKind = + | "client" + | "group" + | "build" + | "os" + | "country" + | "subnet" + | "user" + | "status"; + +export type ClientGraphNode = { + data: { + id: string; + label: string; + type: GraphNodeKind; + weight: number; + count?: number; + onlineCount?: number; + clientId?: string; + online?: boolean; + href?: string; + color?: string | null; + meta?: Record; + }; +}; + +export type ClientGraphEdge = { + data: { + id: string; + source: string; + target: string; + label: string; + type: string; + }; +}; + +export type ClientGraph = { + nodes: ClientGraphNode[]; + edges: ClientGraphEdge[]; + summary: { + clients: number; + totalClients?: number; + online: number; + categories: number; + relationships: number; + generatedAt: number; + }; +}; + +function compactLabel(value: unknown, fallback = "Unknown"): string { + const text = String(value ?? "").trim(); + if (!text) return fallback; + return text.length > 48 ? `${text.slice(0, 45)}...` : text; +} + +function safeKey(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown"; +} + +function ipv4Subnet(ip: string): string | null { + const parts = ip.trim().split("."); + if (parts.length !== 4) return null; + const octets = parts.map((part) => Number(part)); + if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null; + return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`; +} + +function ipv6Subnet(ip: string): string | null { + if (!ip.includes(":")) return null; + const parts = ip.split(":").filter(Boolean); + if (parts.length < 2) return null; + return `${parts.slice(0, 4).join(":")}::/64`; +} + +export function clientIpSubnet(ip: unknown): string | null { + const value = String(ip ?? "").trim(); + if (!value) return null; + return ipv4Subnet(value) || ipv6Subnet(value); +} + +export function buildClientGraph(clients: ListItem[]): ClientGraph { + const nodes = new Map(); + const edges = new Map(); + + const addNode = ( + id: string, + label: string, + type: GraphNodeKind, + data: Partial = {}, + ) => { + const existing = nodes.get(id); + if (existing) { + existing.data.count = (existing.data.count || 0) + 1; + if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1; + existing.data.weight = Math.max(existing.data.weight, data.weight || 1); + return existing; + } + const node: ClientGraphNode = { + data: { + id, + label, + type, + weight: data.weight || 1, + count: data.count, + onlineCount: data.online ? 1 : data.onlineCount, + ...data, + }, + }; + nodes.set(id, node); + return node; + }; + + const addEdge = (source: string, target: string, label: string, type: string) => { + const id = `${source}->${target}:${type}`; + if (edges.has(id)) return; + edges.set(id, { data: { id, source, target, label, type } }); + }; + + for (const client of clients) { + const clientNodeId = `client:${client.id}`; + const clientLabel = compactLabel(client.nickname || client.host || client.id); + addNode(clientNodeId, clientLabel, "client", { + weight: client.online ? 5 : 3, + clientId: client.id, + online: client.online, + href: `/?q=${encodeURIComponent(client.id)}`, + meta: { + id: client.id, + host: client.host || "", + user: client.user || "", + os: client.os || "", + ip: client.ip || "", + country: client.country || "", + groupName: client.groupName || "", + buildTag: client.buildTag || "", + lastSeen: client.lastSeen, + }, + }); + + const statusNode = client.online + ? { id: "status:online", label: "Online", color: "#22c55e" } + : { id: "status:offline", label: "Offline", color: "#64748b" }; + addNode(statusNode.id, statusNode.label, "status", { + weight: 4, + online: client.online, + color: statusNode.color, + }); + addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status"); + + if (client.groupName || client.groupId) { + const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`; + addNode(groupId, compactLabel(client.groupName || "Group"), "group", { + weight: 4, + online: client.online, + color: client.groupColor || null, + }); + addEdge(clientNodeId, groupId, "group", "group"); + } + + if (client.buildTag) { + const id = `build:${safeKey(client.buildTag)}`; + addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online }); + addEdge(clientNodeId, id, "build", "build"); + } + + if (client.os) { + const id = `os:${safeKey(client.os)}`; + addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online }); + addEdge(clientNodeId, id, "os", "os"); + } + + if (client.country) { + const label = String(client.country).toUpperCase(); + const id = `country:${safeKey(label)}`; + addNode(id, label, "country", { weight: 3, online: client.online }); + addEdge(clientNodeId, id, "country", "country"); + } + + const subnet = clientIpSubnet(client.ip); + if (subnet) { + const id = `subnet:${safeKey(subnet)}`; + addNode(id, subnet, "subnet", { weight: 3, online: client.online }); + addEdge(clientNodeId, id, "subnet", "subnet"); + } + + if (client.user) { + const id = `user:${safeKey(client.user)}`; + addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online }); + addEdge(clientNodeId, id, "user", "user"); + } + } + + const sortedNodes = Array.from(nodes.values()).map((node) => { + if (node.data.type !== "client") { + node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1))); + } + return node; + }); + + return { + nodes: sortedNodes, + edges: Array.from(edges.values()), + summary: { + clients: clients.length, + online: clients.filter((client) => client.online).length, + categories: sortedNodes.filter((node) => node.data.type !== "client").length, + relationships: edges.size, + generatedAt: Date.now(), + }, + }; +} diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 11e7ae0..48c186a 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -2,12 +2,13 @@ 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, getDatabaseFileSizeBytes } from "../../db"; +import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } 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, getUserById } from "../../users"; +import { buildClientGraph } from "../client-graph"; import { runCertbotSetup } from "../certbot-setup"; import { getActiveProxies, @@ -336,6 +337,48 @@ export async function handleMiscRoutes( }); } + if (req.method === "GET" && url.pathname === "/api/client-graph") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300))); + const search = (url.searchParams.get("q") || "").trim().toLowerCase(); + const statusFilter = url.searchParams.get("status") || "all"; + const groupFilter = url.searchParams.get("group") || "all"; + let allowedClientIds: string[] | undefined; + let deniedClientIds: string[] | undefined; + + if (user.role !== "admin") { + const scope = getUserClientAccessScope(user.userId); + if (scope === "none") { + return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS }); + } + if (scope === "allowlist") { + allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow"); + } else if (scope === "denylist") { + deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny"); + } + } + + const clients = listClients({ + page: 1, + pageSize: limit, + search, + sort: "last_seen_desc", + statusFilter, + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "approved", + groupFilter, + allowedClientIds, + deniedClientIds, + }); + + const graph = buildClientGraph(clients.items); + graph.summary.totalClients = clients.total; + return Response.json(graph, { headers: deps.CORS_HEADERS }); + } + if (url.pathname === "/health") { return new Response("ok", { headers: deps.CORS_HEADERS }); } diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index 2444bbc..05d135b 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -82,6 +82,7 @@ type QueryClientPageDef = { /** Static pages: each entry maps a URL path to its HTML file + access rules. */ const STATIC_PAGES: StaticPageDef[] = [ { path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true }, + { path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true }, { path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true }, { path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true }, { path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" }, From d9d9507828bd3ecf3d1ec40dc543924197c89d5d Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 16:35:06 -0500 Subject: [PATCH 6/8] more tests and better metrics --- Overlord-Server/public/assets/metrics.js | 488 +++++++++++++++++- Overlord-Server/public/metrics.html | 154 ++++-- Overlord-Server/src/metrics.test.ts | 45 ++ Overlord-Server/src/metrics.ts | 168 +++++- .../src/server/http-dispatch.test.ts | 21 +- Overlord-Server/src/server/http-dispatch.ts | 27 +- .../src/server/routes/misc-routes.ts | 6 +- 7 files changed, 842 insertions(+), 67 deletions(-) diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js index d1573d6..7bb3e6e 100644 --- a/Overlord-Server/public/assets/metrics.js +++ b/Overlord-Server/public/assets/metrics.js @@ -1,5 +1,12 @@ 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 = {}; @@ -7,6 +14,8 @@ 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"; @@ -14,9 +23,69 @@ if (typeof Chart !== "undefined") { 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", @@ -119,13 +188,283 @@ function initCharts() { }, }, }); + + 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 (bytes === 0) return "0 B"; + 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.floor(Math.log(bytes) / Math.log(k)); + 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]; } @@ -269,6 +608,10 @@ function updateMetrics(data, debug) { 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); @@ -314,6 +657,57 @@ function updateMetrics(data, debug) { osList.innerHTML = '
No clients
'; } + if (sessionsChart) { + sessionsChart.data.datasets[0].data = [ + data.sessions.console || 0, + data.sessions.remoteDesktop || 0, + data.sessions.fileBrowser || 0, + data.sessions.process || 0, + ]; + sessionsChart.update("none"); + } + + if (osChart) { + const osEntries = Object.entries(data.clients.byOS || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + osChart.data.labels = osEntries.length ? osEntries.map(([label]) => label) : ["No clients"]; + osChart.data.datasets[0].data = osEntries.length ? osEntries.map(([, count]) => count) : [0]; + osChart.update("none"); + } + + const httpRoutesList = document.getElementById("http-routes"); + if (httpRoutesList) { + const routes = Array.isArray(data.http.routes) ? data.http.routes : []; + if (routes.length > 0) { + httpRoutesList.innerHTML = routes + .map((route) => { + const errorClass = route.errorsLastMinute > 0 ? "text-red-300" : "text-slate-400"; + return ` +
+
+ ${escapeHtml(route.route)} +
+
+ p95 + ${formatMs(route.latencyP95 || 0)} + avg + ${formatMs(route.latencyAvg || 0)} + req/min + ${Number(route.countLastMinute || 0).toLocaleString()} + errors + ${Number(route.errorsLastMinute || 0).toLocaleString()} +
+
+ `; + }) + .join(""); + } else { + httpRoutesList.innerHTML = + '
No HTTP route samples yet
'; + } + } + const commandTypesList = document.getElementById("command-types"); if (Object.keys(data.commands.byType).length > 0) { const topCommands = Object.entries(data.commands.byType) @@ -462,7 +856,11 @@ async function initCountryMap() { } function updateCharts(history, snapshot) { - if (history.length === 0) { + const points = Array.isArray(history) && history.length > 0 + ? history.slice(-MAX_CHART_POINTS) + : []; + + if (points.length === 0) { const now = new Date(); const label = formatTime(now.getTime()); @@ -473,26 +871,100 @@ function updateCharts(history, snapshot) { commandsChart.data.labels = [label]; commandsChart.data.datasets[0].data = [snapshot.commands.lastMinute]; commandsChart.update("none"); + + if (bandwidthChart) { + bandwidthChart.data.labels = [label]; + bandwidthChart.data.datasets[0].data = [snapshot.bandwidth.sentPerSecond || 0]; + bandwidthChart.data.datasets[1].data = [snapshot.bandwidth.receivedPerSecond || 0]; + bandwidthChart.update("none"); + } + + if (httpChart) { + httpChart.data.labels = [label]; + httpChart.data.datasets[0].data = [snapshot.http.latencyP99 || 0]; + httpChart.data.datasets[1].data = [snapshot.http.latencyP95 || 0]; + httpChart.data.datasets[2].data = [snapshot.http.latencyAvg || 0]; + httpChart.update("none"); + } + + if (httpRequestsChart) { + httpRequestsChart.data.labels = [label]; + httpRequestsChart.data.datasets[0].data = [snapshot.http.lastMinute || 0]; + httpRequestsChart.data.datasets[1].data = [snapshot.http.lastMinuteErrors || 0]; + httpRequestsChart.update("none"); + } + + if (memoryChart) { + memoryChart.data.labels = [label]; + memoryChart.data.datasets[0].data = [snapshot.server.memoryUsage.heapUsed || 0]; + memoryChart.data.datasets[1].data = [snapshot.server.memoryUsage.rss || 0]; + memoryChart.data.datasets[2].data = [snapshot.server.systemMemory.usedPercent || 0]; + memoryChart.update("none"); + } + + if (eventLoopChart) { + eventLoopChart.data.labels = [label]; + eventLoopChart.data.datasets[0].data = [snapshot.eventLoop.p95 || 0]; + eventLoopChart.data.datasets[1].data = [snapshot.eventLoop.avg || 0]; + eventLoopChart.update("none"); + } return; } - const labels = history.map((h) => formatTime(h.timestamp)); - const clientsData = history.map((h) => h.clientsOnline); + const labels = points.map((h) => formatTime(h.timestamp)); + const clientsData = points.map((h) => h.clientsOnline || 0); clientsChart.data.labels = labels; clientsChart.data.datasets[0].data = clientsData; clientsChart.update("none"); - const commandsData = history.map((h) => h.commandsPerMinute); + const commandsData = points.map((h) => h.commandsPerMinute || 0); commandsChart.data.labels = labels; commandsChart.data.datasets[0].data = commandsData; commandsChart.update("none"); + + if (bandwidthChart) { + bandwidthChart.data.labels = labels; + bandwidthChart.data.datasets[0].data = points.map((h) => h.bandwidthSent || 0); + bandwidthChart.data.datasets[1].data = points.map((h) => h.bandwidthReceived || 0); + bandwidthChart.update("none"); + } + + if (httpChart) { + httpChart.data.labels = labels; + httpChart.data.datasets[0].data = points.map((h) => h.httpLatencyP99 || 0); + httpChart.data.datasets[1].data = points.map((h) => h.httpLatencyP95 || 0); + httpChart.data.datasets[2].data = points.map((h) => h.httpLatencyAvg || 0); + httpChart.update("none"); + } + + if (httpRequestsChart) { + httpRequestsChart.data.labels = labels; + httpRequestsChart.data.datasets[0].data = points.map((h) => h.httpRequestsPerMinute || 0); + httpRequestsChart.data.datasets[1].data = points.map((h) => h.httpErrorsPerMinute || 0); + httpRequestsChart.update("none"); + } + + if (memoryChart) { + memoryChart.data.labels = labels; + memoryChart.data.datasets[0].data = points.map((h) => h.heapUsed || 0); + memoryChart.data.datasets[1].data = points.map((h) => h.rss || 0); + memoryChart.data.datasets[2].data = points.map((h) => h.systemMemoryUsedPercent || 0); + memoryChart.update("none"); + } + + if (eventLoopChart) { + eventLoopChart.data.labels = labels; + eventLoopChart.data.datasets[0].data = points.map((h) => h.eventLoopP95 || 0); + eventLoopChart.data.datasets[1].data = points.map((h) => h.eventLoopAvg || 0); + eventLoopChart.update("none"); + } } async function fetchMetrics() { try { - const response = await fetch("/api/metrics", { + const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, { credentials: "include", }); @@ -589,7 +1061,7 @@ document.addEventListener("DOMContentLoaded", async () => { await fetchMetrics(); - setInterval(fetchMetrics, 2000); + setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS); const logoutBtn = document.getElementById("logout-btn"); if (logoutBtn && !logoutBtn.dataset.boundLogout) { diff --git a/Overlord-Server/public/metrics.html b/Overlord-Server/public/metrics.html index 6114fda..1fc48f1 100644 --- a/Overlord-Server/public/metrics.html +++ b/Overlord-Server/public/metrics.html @@ -145,6 +145,101 @@
+ +
+
+

+ + Network Throughput +

+
+ +
+
+ +
+

+ + HTTP Requests +

+
+ +
+
+ +
+

+ + HTTP Latency +

+
+ +
+
+ +
+

+ + Runtime Memory +

+
+ +
+
+ +
+

+ + Event Loop Lag +

+
+ +
+
+
+ + +
+
+

+ + Slowest HTTP Routes +

+
Sorted by p95 latency, last minute
+
+
+
+ No HTTP route samples yet +
+
+
+ + +
+
+

+ + Session Mix +

+
+ +
+
+ +
+

+ + Operating Systems +

+
+ +
+
+
+
@@ -181,36 +276,22 @@
- +

- - Ping Statistics + + Clients by OS

-
-
- Average: - - -
-
- Min / Max: - - / - - -
-
- Samples: - 0 -
+
+
No data
- +

- HTTP & Event Loop + HTTP, Ping & Event Loop

@@ -222,10 +303,11 @@ 0
- Latency avg/p95: + Latency avg/p95/p99: 0 / - 0 + 0 / + 0
@@ -239,17 +321,21 @@ Loop lag max: 0
-
-
- - -
-

- - Clients by OS -

-
-
No data
+
+ Ping avg: + - +
+
+ Ping min/max: + + - / + - + +
+
+ Ping samples: + 0 +
diff --git a/Overlord-Server/src/metrics.test.ts b/Overlord-Server/src/metrics.test.ts index f9c059f..2fc6bab 100644 --- a/Overlord-Server/src/metrics.test.ts +++ b/Overlord-Server/src/metrics.test.ts @@ -125,6 +125,31 @@ describe("MetricsCollector", () => { const snap = metrics.getSnapshot(); expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0); expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90); + expect(snap.http.latencyP99).toBeGreaterThanOrEqual(99); + }); + + test("tracks slow HTTP routes", () => { + metrics.recordHttpRequest(15, 200, "GET /api/metrics"); + metrics.recordHttpRequest(120, 200, "GET /api/clients/:id/rd/ws"); + metrics.recordHttpRequest(200, 500, "GET /api/clients/:id/rd/ws"); + + const snap = metrics.getSnapshot(); + expect(snap.http.routes[0].route).toBe("GET /api/clients/:id/rd/ws"); + expect(snap.http.routes[0].countLastMinute).toBe(2); + expect(snap.http.routes[0].errorsLastMinute).toBe(1); + expect(snap.http.routes[0].latencyP95).toBeGreaterThanOrEqual(120); + }); + + test("does not count the metrics dashboard poll as HTTP pressure", () => { + metrics.recordHttpRequest(100, 200, "GET /api/metrics"); + metrics.recordHttpRequest(20, 200, "GET /api/clients"); + + const snap = metrics.getSnapshot(); + expect(snap.http.total).toBe(1); + expect(snap.http.lastMinute).toBe(1); + expect(snap.http.routes.map((route) => route.route)).toEqual([ + "GET /api/clients", + ]); }); }); @@ -177,6 +202,26 @@ describe("MetricsCollector", () => { expect(history[0].timestamp).toBe(snap.timestamp); }); + test("history captures pressure metrics for charting", () => { + metrics.recordHttpRequest(25, 200); + metrics.recordHttpRequest(80, 500); + metrics.recordBytesSent(1024); + metrics.recordBytesReceived(2048); + + const snap = metrics.getSnapshot(); + metrics.recordHistoryEntry(snap); + + const [entry] = metrics.getHistory(); + expect(entry.httpRequestsPerMinute).toBeGreaterThanOrEqual(2); + expect(entry.httpErrorsPerMinute).toBeGreaterThanOrEqual(1); + expect(entry.httpLatencyAvg).toBeGreaterThan(0); + expect(entry.httpLatencyP95).toBeGreaterThan(0); + expect(entry.heapUsed).toBeGreaterThan(0); + expect(entry.rss).toBeGreaterThan(0); + expect(entry.systemMemoryUsedPercent).toBeGreaterThanOrEqual(0); + expect(entry.activeSessions).toBe(0); + }); + test("history is capped at maxHistoryPoints", () => { const weekOfFiveSecondSamples = 7 * 24 * 60 * 12; for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) { diff --git a/Overlord-Server/src/metrics.ts b/Overlord-Server/src/metrics.ts index 4e56101..36a3d48 100644 --- a/Overlord-Server/src/metrics.ts +++ b/Overlord-Server/src/metrics.ts @@ -59,6 +59,8 @@ export interface MetricsSnapshot { lastMinuteErrors: number; latencyAvg: number; latencyP95: number; + latencyP99: number; + routes: HttpRouteStats[]; }; eventLoop: { avg: number; @@ -67,12 +69,41 @@ export interface MetricsSnapshot { }; } +export interface HttpRouteStats { + route: string; + countLastMinute: number; + errorsLastMinute: number; + latencyAvg: number; + latencyP95: number; + latencyP99: number; + latencyMax: number; + lastDuration: number; + lastStatus: number; +} + export interface MetricsHistory { timestamp: number; clientsOnline: number; commandsPerMinute: number; bandwidthSent: number; bandwidthReceived: number; + httpRequestsPerMinute?: number; + httpErrorsPerMinute?: number; + httpLatencyAvg?: number; + httpLatencyP95?: number; + httpLatencyP99?: number; + eventLoopAvg?: number; + eventLoopP95?: number; + heapUsed?: number; + rss?: number; + systemMemoryUsedPercent?: number; + activeSessions?: number; +} + +interface TimedHttpSample { + ts: number; + duration: number; + statusCode: number; } class MetricsCollector { @@ -103,8 +134,11 @@ class MetricsCollector { private httpTotal: number = 0; private httpTimestamps: number[] = []; private httpErrorTimestamps: number[] = []; - private httpLatencies: number[] = []; - private maxHttpLatencyHistory: number = 1000; + private httpSamples: TimedHttpSample[] = []; + private httpRouteSamples: Map = new Map(); + private maxHttpLatencyHistory: number = 2000; + private maxHttpRouteSamples: number = 300; + private ignoredHttpMetricRoutes: Set = new Set(["GET /api/metrics"]); private eventLoopDelays: number[] = []; private maxEventLoopHistory: number = 300; @@ -236,7 +270,74 @@ class MetricsCollector { }, intervalMs); } - recordHttpRequest(durationMs: number, statusCode: number) { + private percentile(samples: number[], percentile: number): number { + if (samples.length === 0) return 0; + const index = Math.max(0, Math.ceil(samples.length * percentile) - 1); + return samples[index] ?? 0; + } + + private average(samples: number[]): number { + return samples.length + ? samples.reduce((sum, value) => sum + value, 0) / samples.length + : 0; + } + + private pruneHttpSamples(list: TimedHttpSample[], minTs: number): void { + let removeCount = 0; + while (removeCount < list.length && list[removeCount].ts <= minTs) { + removeCount += 1; + } + if (removeCount > 0) { + list.splice(0, removeCount); + } + } + + private countHttpErrors(samples: TimedHttpSample[]): number { + let count = 0; + for (const sample of samples) { + if (sample.statusCode >= 400) count += 1; + } + return count; + } + + private getTopHttpRoutes(minTs: number): HttpRouteStats[] { + const routes: HttpRouteStats[] = []; + for (const [route, samples] of this.httpRouteSamples.entries()) { + this.pruneHttpSamples(samples, minTs); + if (samples.length === 0) { + this.httpRouteSamples.delete(route); + continue; + } + + const durations = samples + .map((sample) => sample.duration) + .sort((a, b) => a - b); + const last = samples[samples.length - 1]; + routes.push({ + route, + countLastMinute: samples.length, + errorsLastMinute: this.countHttpErrors(samples), + latencyAvg: this.average(durations), + latencyP95: this.percentile(durations, 0.95), + latencyP99: this.percentile(durations, 0.99), + latencyMax: durations[durations.length - 1] ?? 0, + lastDuration: last?.duration ?? 0, + lastStatus: last?.statusCode ?? 0, + }); + } + + return routes + .sort((a, b) => { + if (b.latencyP95 !== a.latencyP95) return b.latencyP95 - a.latencyP95; + if (b.latencyAvg !== a.latencyAvg) return b.latencyAvg - a.latencyAvg; + return b.countLastMinute - a.countLastMinute; + }) + .slice(0, 8); + } + + recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") { + if (this.ignoredHttpMetricRoutes.has(route)) return; + this.httpTotal++; const now = Date.now(); this.httpTimestamps.push(now); @@ -246,14 +347,25 @@ class MetricsCollector { this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000); } if (Number.isFinite(durationMs)) { - this.httpLatencies.push(durationMs); - if (this.httpLatencies.length > this.maxHttpLatencyHistory) { - this.httpLatencies.shift(); + const sample = { ts: now, duration: Math.max(0, durationMs), statusCode }; + this.httpSamples.push(sample); + if (this.httpSamples.length > this.maxHttpLatencyHistory) { + this.httpSamples.splice(0, this.httpSamples.length - this.maxHttpLatencyHistory); } + + const routeSamples = this.httpRouteSamples.get(route) || []; + routeSamples.push(sample); + if (routeSamples.length > this.maxHttpRouteSamples) { + routeSamples.splice(0, routeSamples.length - this.maxHttpRouteSamples); + } + this.httpRouteSamples.set(route, routeSamples); } } - async withHttpMetrics(handler: () => Promise): Promise { + async withHttpMetrics( + handler: () => Promise, + route = "unknown", + ): Promise { const start = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); @@ -262,13 +374,13 @@ class MetricsCollector { const end = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); - this.recordHttpRequest(end - start, response?.status ?? 0); + this.recordHttpRequest(end - start, response?.status ?? 0, route); return response; } catch (err) { const end = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); - this.recordHttpRequest(end - start, 500); + this.recordHttpRequest(end - start, 500, route); throw err; } } @@ -280,6 +392,21 @@ class MetricsCollector { commandsPerMinute: snapshot.commands.lastMinute, bandwidthSent: this.sentPerSecond, bandwidthReceived: this.receivedPerSecond, + httpRequestsPerMinute: snapshot.http.lastMinute, + httpErrorsPerMinute: snapshot.http.lastMinuteErrors, + httpLatencyAvg: snapshot.http.latencyAvg, + httpLatencyP95: snapshot.http.latencyP95, + httpLatencyP99: snapshot.http.latencyP99, + eventLoopAvg: snapshot.eventLoop.avg, + eventLoopP95: snapshot.eventLoop.p95, + heapUsed: snapshot.server.memoryUsage.heapUsed, + rss: snapshot.server.memoryUsage.rss, + systemMemoryUsedPercent: snapshot.server.systemMemory.usedPercent, + activeSessions: + snapshot.sessions.console + + snapshot.sessions.remoteDesktop + + snapshot.sessions.fileBrowser + + snapshot.sessions.process, }; this.history.push(historyEntry); @@ -297,6 +424,7 @@ class MetricsCollector { this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo); this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo); this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo); + this.pruneHttpSamples(this.httpSamples, oneMinuteAgo); const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo); const commandsLastHour = this.commandTimestamps.length; @@ -309,16 +437,13 @@ class MetricsCollector { const httpLastMinute = this.httpTimestamps.length; const httpErrorsLastMinute = this.httpErrorTimestamps.length; - const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b); - const httpLatencyAvg = httpLatencySamples.length - ? httpLatencySamples.reduce((a, b) => a + b, 0) / httpLatencySamples.length - : 0; - const httpP95Index = httpLatencySamples.length - ? Math.max(0, Math.floor(httpLatencySamples.length * 0.95) - 1) - : 0; - const httpLatencyP95 = httpLatencySamples.length - ? httpLatencySamples[httpP95Index] ?? 0 - : 0; + const httpLatencySamples = this.httpSamples + .map((sample) => sample.duration) + .sort((a, b) => a - b); + const httpLatencyAvg = this.average(httpLatencySamples); + const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95); + const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99); + const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo); const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b); const eventLoopAvg = eventLoopSamples.length @@ -400,6 +525,8 @@ class MetricsCollector { lastMinuteErrors: httpErrorsLastMinute, latencyAvg: httpLatencyAvg, latencyP95: httpLatencyP95, + latencyP99: httpLatencyP99, + routes: httpRoutes, }, eventLoop: { avg: eventLoopAvg, @@ -428,7 +555,8 @@ class MetricsCollector { this.httpTotal = 0; this.httpTimestamps = []; this.httpErrorTimestamps = []; - this.httpLatencies = []; + this.httpSamples = []; + this.httpRouteSamples.clear(); this.eventLoopDelays = []; } } diff --git a/Overlord-Server/src/server/http-dispatch.test.ts b/Overlord-Server/src/server/http-dispatch.test.ts index e767f69..dd86673 100644 --- a/Overlord-Server/src/server/http-dispatch.test.ts +++ b/Overlord-Server/src/server/http-dispatch.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { clearRequestRateLimitsForTests } from "../rateLimit"; -import { createHttpFetchHandler } from "./http-dispatch"; +import { createHttpFetchHandler, normalizeHttpRoute } from "./http-dispatch"; const metrics = { withHttpMetrics: (fn: () => Promise) => fn(), @@ -35,3 +35,22 @@ describe("createHttpFetchHandler rate limiting", () => { expect(limited.headers.get("Retry-After")).toBeTruthy(); }); }); + +describe("normalizeHttpRoute", () => { + test("collapses dynamic route segments", () => { + const req = new Request( + "https://localhost/api/clients/0a44a8d4d61c7840540b43fd187fe3c43e2c46c9600b2aa1a61a6b1ab03c5b92/rd/ws?cache=1", + { method: "POST" }, + ); + + expect(normalizeHttpRoute(req, new URL(req.url))).toBe( + "POST /api/clients/:id/rd/ws", + ); + }); + + test("groups static asset files", () => { + const req = new Request("https://localhost/assets/metrics.js"); + + expect(normalizeHttpRoute(req, new URL(req.url))).toBe("GET /assets/:file"); + }); +}); diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index d1356f3..bd6cd95 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -3,6 +3,9 @@ import { consumeUnauthorizedRateLimit } from "../rateLimit"; export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise; +const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i; + function tooManyRequestsResponse(retryAfter = 60): Response { return new Response("Too many requests", { status: 429, @@ -13,17 +16,35 @@ function tooManyRequestsResponse(retryAfter = 60): Response { }); } +function normalizeRouteSegment(segment: string): string { + if (/^\d+$/.test(segment)) return ":id"; + if (UUID_SEGMENT.test(segment) || LONG_ID_SEGMENT.test(segment)) return ":id"; + if (segment.includes(".") && !segment.startsWith(".")) return ":file"; + if (segment.length >= 24 && /^[a-z0-9_-]+$/i.test(segment)) return ":id"; + return segment; +} + +export function normalizeHttpRoute(req: Request, url: URL): string { + const path = url.pathname + .split("/") + .filter(Boolean) + .map(normalizeRouteSegment) + .join("/"); + return `${req.method.toUpperCase()} /${path}`; +} + export function createHttpFetchHandler(deps: { - metrics: { withHttpMetrics: (fn: () => Promise) => Promise }; + metrics: { withHttpMetrics: (fn: () => Promise, route?: string) => Promise }; CORS_HEADERS: Record; routes: RouteHandler[]; }) { return async function fetchHandler(req: Request, server: unknown): Promise { + const url = new URL(req.url); + const routeLabel = normalizeHttpRoute(req, url); return deps.metrics.withHttpMetrics(async () => { if (req.method === "OPTIONS") { return new Response("", { headers: deps.CORS_HEADERS }); } - const url = new URL(req.url); const wrapped = wrapServerWithClientIp(server as RequestServerLike); const ip = wrapped.requestIP(req)?.address || "unknown"; @@ -38,6 +59,6 @@ export function createHttpFetchHandler(deps: { } } return new Response("Not found", { status: 404 }); - }); + }, routeLabel); }; } diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 48c186a..c4965c1 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -330,7 +330,11 @@ export async function handleMiscRoutes( snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount(); snapshot.sessions.process = deps.getProcessSessionCount(); - const history = metrics.getHistory(); + const requestedHistoryLimit = Number(url.searchParams.get("historyLimit") || 240); + const historyLimit = Number.isFinite(requestedHistoryLimit) + ? Math.max(1, Math.min(2000, requestedHistoryLimit)) + : 240; + const history = metrics.getHistory().slice(-historyLimit); return new Response(JSON.stringify({ snapshot, history }), { headers: { "Content-Type": "application/json" }, From 8e205c01ac5dce79a4fda3a2dfb5c01e84958ee8 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 19:44:36 -0500 Subject: [PATCH 7/8] better settings --- Overlord-Server/public/assets/settings.js | 209 +++++++++++++++--- Overlord-Server/public/settings.html | 69 ++++-- Overlord-Server/src/rbac.ts | 63 +++++- .../auth-routes.permissions-array.test.ts | 13 ++ .../src/server/routes/misc-routes.ts | 26 +-- .../routes/notifications-config-routes.ts | 4 +- 6 files changed, 316 insertions(+), 68 deletions(-) diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index caebeb4..435a4cf 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -124,7 +124,7 @@ function formatDate(timestamp) { } function canManageClientBans(role) { - return role === "admin" || role === "operator"; + return userHas("network:manage-bans"); } function isAdmin(role) { @@ -135,6 +135,12 @@ function userHas(perm) { return !!currentUser?.permissions?.includes(perm); } +function requireUiPermission(perm, message = "You do not have permission for this setting.") { + if (userHas(perm)) return true; + showMessage(message, "error"); + return false; +} + function applyPermissionVisibility() { document.querySelectorAll("[data-permission]").forEach((el) => { const perm = el.dataset.permission; @@ -164,6 +170,91 @@ function applyPermissionVisibility() { } } +async function renderPermissionsOverview() { + const container = document.getElementById("settings-permissions-summary"); + if (!container || !currentUser) return; + + const granted = new Set(currentUser.permissions || []); + let catalog = []; + try { + const res = await fetch("/api/permissions", { credentials: "include" }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + catalog = Array.isArray(data.permissions) ? data.permissions : []; + } + } catch {} + + const byId = new Map(catalog.map((perm) => [perm.id, perm.description])); + const groups = [ + { + title: "User Administration", + icon: "fa-users-gear", + perms: ["users:manage", "audit:view"], + }, + { + title: "Client Operations", + icon: "fa-desktop", + perms: [ + "clients:control", + "clients:build", + "clients:metadata", + "clients:disconnect", + "clients:reconnect", + "clients:uninstall", + "clients:elevate", + "clients:winre", + ], + }, + { + title: "System Settings", + icon: "fa-sliders", + perms: [ + "system:security", + "system:tls", + "system:registration", + "system:notifications", + "system:chat", + "system:appearance", + "system:thumbnails", + "system:build-limits", + "system:export-import", + "system:health", + "system:health:manage", + "system:profiler", + ], + }, + ]; + + container.innerHTML = groups + .map((group) => { + const rows = group.perms + .map((perm) => { + const has = granted.has(perm); + return ` +
+
+
${escapeHtml(perm)}
+
${escapeHtml(byId.get(perm) || "")}
+
+ + + +
+ `; + }) + .join(""); + return ` +
+

+ ${escapeHtml(group.title)} +

+
${rows}
+
+ `; + }) + .join(""); +} + function getPasswordRequirementsText() { const minLength = Number(securityConfig?.passwordMinLength) || 6; const requirements = [`min ${minLength} chars`]; @@ -256,7 +347,7 @@ function applySecurityForm() { async function loadSecurityPolicy() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:security")) { securityPermissionNote.classList.remove("hidden"); setSecurityFormDisabled(true); securityConfig = { @@ -301,7 +392,7 @@ function applyTlsForm() { async function loadTlsSettings() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:tls")) { tlsPermissionNote.classList.remove("hidden"); setTlsFormDisabled(true); tlsConfig = { @@ -559,8 +650,7 @@ function savePrefs(event) { async function saveSecurityPolicy(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:security", "Security policy permission required.")) { return; } @@ -598,8 +688,7 @@ async function saveSecurityPolicy(event) { async function saveTlsSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } @@ -633,8 +722,7 @@ async function saveTlsSettings(event) { } async function runCertbotAutoSetup() { - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } @@ -1079,7 +1167,7 @@ function initSettingsSidebar() { async function loadAppearanceSettings() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:appearance")) { if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden"); if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true; if (appearanceSaveBtn) appearanceSaveBtn.disabled = true; @@ -1104,8 +1192,7 @@ async function loadAppearanceSettings() { async function saveAppearanceSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) { return; } @@ -1136,7 +1223,7 @@ const chatSettingsForm = document.getElementById("chat-settings-form"); const chatRetentionDaysInput = document.getElementById("chat-retention-days"); async function loadChatSettings() { - if (!isAdmin(currentUser?.role)) return; + if (!userHas("system:chat")) return; if (chatSettingsSection) chatSettingsSection.classList.remove("hidden"); try { const res = await fetch("/api/settings/chat", { credentials: "include" }); @@ -1151,8 +1238,7 @@ async function loadChatSettings() { async function saveChatSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:chat", "Team chat settings permission required.")) { return; } @@ -1198,6 +1284,10 @@ function showExportImportMessage(text, type = "ok") { } async function exportSettings() { + if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { + return; + } + try { const res = await fetch("/api/settings/export", { credentials: "include" }); if (!res.ok) { @@ -1227,6 +1317,11 @@ async function exportSettings() { } async function importSettings(event) { + if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { + event.target.value = ""; + return; + } + const file = event.target.files?.[0]; if (!file) return; @@ -1486,6 +1581,8 @@ async function handleRemoveInactiveSessions() { // ── Registration Settings ────────────────────────────────────────────────── async function loadRegistrationSettings() { + if (!userHas("system:registration")) return; + try { const [regRes, groupsRes] = await Promise.all([ fetch("/api/settings/registration", { credentials: "include" }), @@ -1522,8 +1619,8 @@ async function loadRegistrationSettings() { } updateRegSubsections(reg.mode || "off"); - if (reg.mode === "key") loadRegistrationKeys(); - if (reg.mode === "approval") loadPendingRegistrations(); + if (reg.mode === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (reg.mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch (e) { console.error("Failed to load registration settings", e); } @@ -1532,8 +1629,8 @@ async function loadRegistrationSettings() { function updateRegSubsections(mode) { const keySection = document.getElementById("reg-key-section"); const pendingSection = document.getElementById("reg-pending-section"); - if (keySection) keySection.classList.toggle("hidden", mode !== "key"); - if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval"); + if (keySection) keySection.classList.toggle("hidden", mode !== "key" || !userHas("users:manage")); + if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval" || !userHas("users:manage")); } function showRegMsg(text, type) { @@ -1547,6 +1644,10 @@ function showRegMsg(text, type) { async function saveRegistrationSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:registration", "Registration settings permission required.")) { + return; + } + const mode = document.getElementById("reg-mode")?.value; const defaultRole = document.getElementById("reg-default-role")?.value; const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0; @@ -1568,14 +1669,15 @@ async function saveRegistrationSettings(e) { } showRegMsg("Registration settings saved.", "success"); updateRegSubsections(mode); - if (mode === "key") loadRegistrationKeys(); - if (mode === "approval") loadPendingRegistrations(); + if (mode === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch { showRegMsg("Network error.", "error"); } } async function loadRegistrationKeys() { + if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-keys-tbody"); if (!tbody) return; try { @@ -1608,6 +1710,10 @@ async function loadRegistrationKeys() { } async function generateRegistrationKeys() { + if (!requireUiPermission("users:manage", "User management permission required to generate registration keys.")) { + return; + } + const count = Number(document.getElementById("reg-key-count")?.value) || 1; const label = document.getElementById("reg-key-label")?.value || undefined; const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined; @@ -1631,6 +1737,10 @@ async function generateRegistrationKeys() { } async function deleteRegistrationKey(keyId) { + if (!requireUiPermission("users:manage", "User management permission required to delete registration keys.")) { + return; + } + try { const res = await fetch(`/api/registration/keys/${keyId}`, { method: "DELETE", @@ -1641,6 +1751,7 @@ async function deleteRegistrationKey(keyId) { } async function loadPendingRegistrations() { + if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-pending-tbody"); const emptyEl = document.getElementById("reg-pending-empty"); if (!tbody) return; @@ -1673,6 +1784,10 @@ async function loadPendingRegistrations() { } async function handlePendingAction(id, action) { + if (!requireUiPermission("users:manage", "User management permission required to approve registrations.")) { + return; + } + try { const res = await fetch(`/api/registration/pending/${id}/${action}`, { method: "POST", @@ -1697,8 +1812,8 @@ function initRegistrationHandlers() { const modeEl = document.getElementById("reg-mode"); if (modeEl) modeEl.addEventListener("change", () => { updateRegSubsections(modeEl.value); - if (modeEl.value === "key") loadRegistrationKeys(); - if (modeEl.value === "approval") loadPendingRegistrations(); + if (modeEl.value === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (modeEl.value === "approval" && userHas("users:manage")) loadPendingRegistrations(); }); const genBtn = document.getElementById("reg-key-generate-btn"); @@ -1726,6 +1841,8 @@ function initRegistrationHandlers() { // ── Build Rate Limit Settings ────────────────────────────────────────────── async function loadBuildRateLimitSettings() { + if (!userHas("system:build-limits")) return; + try { const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" }); if (!res.ok) return; @@ -1753,6 +1870,10 @@ function showBrlMsg(text, type) { async function saveBuildRateLimitSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:build-limits", "Build rate limit settings permission required.")) { + return; + } + const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5; const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1; const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3; @@ -1783,6 +1904,8 @@ function initBuildRateLimitHandlers() { // ── Thumbnail Settings ──────────────────────────────────────────────────── async function loadThumbnailSettings() { + if (!userHas("system:thumbnails")) return; + try { const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); if (!res.ok) return; @@ -1808,6 +1931,10 @@ function showThumbnailsMsg(text, type) { async function saveThumbnailSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:thumbnails", "Thumbnail settings permission required.")) { + return; + } + const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked; const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked; try { @@ -1858,6 +1985,8 @@ function formatUptime(totalSeconds) { let latestServerCpuProfile = null; async function loadHealthStats() { + if (!userHas("system:health")) return; + const loading = document.getElementById("health-loading"); const content = document.getElementById("health-content"); if (loading) { loading.innerHTML = 'Loading...'; loading.classList.remove("hidden"); } @@ -2060,6 +2189,10 @@ function renderServerProfileResults(data) { } async function runServerProfile() { + if (!requireUiPermission("system:profiler", "Server profiler permission required.")) { + return; + } + const btn = document.getElementById("server-profile-btn"); const durationEl = document.getElementById("server-profile-duration"); const durationMs = Number(durationEl?.value || 5000); @@ -2113,6 +2246,10 @@ function downloadServerCpuProfile() { } async function runGC() { + if (!requireUiPermission("system:health:manage", "Server health maintenance permission required.")) { + return; + } + const gcBtn = document.getElementById("health-gc-btn"); const msgEl = document.getElementById("health-gc-message"); if (gcBtn) gcBtn.disabled = true; @@ -2139,6 +2276,14 @@ function initHealthHandlers() { const gcBtn = document.getElementById("health-gc-btn"); const profileBtn = document.getElementById("server-profile-btn"); const downloadProfileBtn = document.getElementById("server-profile-download-btn"); + if (gcBtn && !userHas("system:health:manage")) { + gcBtn.disabled = true; + gcBtn.title = "Requires system:health:manage"; + } + if (profileBtn && !userHas("system:profiler")) { + profileBtn.disabled = true; + profileBtn.title = "Requires system:profiler"; + } if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats); if (gcBtn) gcBtn.addEventListener("click", runGC); if (profileBtn) profileBtn.addEventListener("click", runServerProfile); @@ -2165,14 +2310,24 @@ async function init() { await loadChatSettings(); await loadBannedIps(); - if (userHas("system:configure")) { + if (userHas("users:manage")) { + await renderPermissionsOverview(); + } + + if (userHas("system:registration")) { await loadRegistrationSettings(); - await loadBuildRateLimitSettings(); - await loadThumbnailSettings(); - await loadHealthStats(); initRegistrationHandlers(); + } + if (userHas("system:build-limits")) { + await loadBuildRateLimitSettings(); initBuildRateLimitHandlers(); + } + if (userHas("system:thumbnails")) { + await loadThumbnailSettings(); initThumbnailHandlers(); + } + if (userHas("system:health")) { + await loadHealthStats(); initHealthHandlers(); } diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 4230b34..499c4f2 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -90,15 +90,16 @@ Wipe Offline Clients Admin - Security Policy - Screenshot Thumbnails - TLS Certificates - Team Chat - Registration - Build Rate Limit - Custom CSS - Export / Import - Server Health + Access Overview + Security Policy + TLS Certificates + Team Chat + Registration + Screenshot Thumbnails + Build Rate Limit + Custom CSS + Export / Import + Server Health @@ -552,8 +553,30 @@ - -