big optimizations

This commit is contained in:
KingKDot
2026-03-22 23:21:27 -05:00
parent f69ca74346
commit 5c9c8dd543
18 changed files with 602 additions and 114 deletions
+59
View File
@@ -10,6 +10,7 @@
"flag-icons": "latest", "flag-icons": "latest",
"geoip-lite": "latest", "geoip-lite": "latest",
"jose": "^6.1.3", "jose": "^6.1.3",
"sharp": "^0.34.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"winston": "^3.11.0", "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=="], "@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/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=="], "@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=="], "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=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sprintf-js": ["sprintf-js@1.1.2", "", {}, "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="], "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=="], "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=="], "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=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+1
View File
@@ -18,6 +18,7 @@
"flag-icons": "latest", "flag-icons": "latest",
"geoip-lite": "latest", "geoip-lite": "latest",
"jose": "^6.1.3", "jose": "^6.1.3",
"sharp": "^0.34.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
+30
View File
@@ -10,6 +10,31 @@ let dashboardWs = null;
let dashboardWsConnected = false; let dashboardWsConnected = false;
let wsReconnectTimer = null; 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) { export function registerRenderer(fn) {
render = fn; render = fn;
} }
@@ -129,6 +154,11 @@ function connectDashboardWs() {
const msg = typeof event.data === "string" ? JSON.parse(event.data) : null; const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
if (msg && msg.type === "clients_changed") { if (msg && msg.type === "clients_changed") {
loadWithOptions({ force: false, reorder: true }); loadWithOptions({ force: false, reorder: true });
return;
}
if (msg && msg.type === "client_event") {
moveClientCardImmediately(msg);
loadWithOptions({ force: false, reorder: true });
} }
} catch {} } catch {}
}; };
+9 -4
View File
@@ -33,6 +33,13 @@ export function createRenderer({
const TOUCH_MOVE_CANCEL_PX = 10; const TOUCH_MOVE_CANCEL_PX = 10;
let renderToken = 0; 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 = {}) { function renderMerge(data, options = {}) {
const { reorder = false } = options; const { reorder = false } = options;
totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`; totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
@@ -66,10 +73,7 @@ export function createRenderer({
.forEach((el) => el.remove()); .forEach((el) => el.remove());
if (reorder) { if (reorder) {
items.forEach((client) => { reorderCards(items);
const card = grid.querySelector(`article[data-id="${client.id}"]`);
if (card) grid.appendChild(card);
});
} }
if (newClients.length === 0) { if (newClients.length === 0) {
@@ -101,6 +105,7 @@ export function createRenderer({
requestAnimationFrame(insertBatch); requestAnimationFrame(insertBatch);
return; return;
} }
reorderCards(items);
}; };
insertBatch(); insertBatch();
+113
View File
@@ -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();
}
});
});
+9 -8
View File
@@ -429,24 +429,25 @@ export function listClients(filters: ListFilters): ListResult {
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => { const orderBy = (() => {
const online = "online DESC";
const bookmark = "bookmarked DESC"; const bookmark = "bookmarked DESC";
switch (sort) { switch (sort) {
case "stable": case "stable":
return `ORDER BY ${bookmark}, online DESC, id ASC`; return `ORDER BY ${online}, ${bookmark}, id ASC`;
case "ping_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": 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": 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": 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": 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": 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: default:
return `ORDER BY ${bookmark}, last_seen DESC, id ASC`; return `ORDER BY ${online}, ${bookmark}, last_seen DESC, id ASC`;
} }
})(); })();
+6 -7
View File
@@ -51,24 +51,24 @@ export function handleCommand(target: ClientInfo, action: string, req: Request)
return Response.json({ ok: true }); return Response.json({ ok: true });
} }
if (action === "desktop_start") { 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 }); return Response.json({ ok: true });
} }
if (action === "desktop_stop") { 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 }); return Response.json({ ok: true });
} }
if (action === "desktop_select_display") { 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 }); return Response.json({ ok: true });
} }
if (action === "desktop_enable_mouse") { 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 }); return Response.json({ ok: true });
} }
if (action === "desktop_enable_keyboard") { 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 }); return Response.json({ ok: true });
} }
if (action === "disconnect") { if (action === "disconnect") {
@@ -111,12 +111,11 @@ export function handleCommand(target: ClientInfo, action: string, req: Request)
} }
return new Response("Bad request", { status: 400 }); return new Response("Bad request", { status: 400 });
} }
}
export function markOffline(id: string) { export function markOffline(id: string) {
setOnlineState(id, false); setOnlineState(id, false);
} }
export function markOnline(info: ClientInfo) { 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 });
} }
+3 -4
View File
@@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "./auth"; import { authenticateRequest } from "./auth";
import { loadConfig, getConfig } from "./config"; import { loadConfig, getConfig } from "./config";
import { flushAuditLogsSync } from "./auditLog"; 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 { requireAuth, requirePermission } from "./rbac";
import { metrics } from "./metrics"; import { metrics } from "./metrics";
import { ensureDataDir } from "./paths"; import { ensureDataDir } from "./paths";
@@ -233,9 +233,7 @@ const storeNotificationScreenshotForPending = (
) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height); ) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height);
const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => { const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => {
const getUserDeliveryTargets = (clientId: string): UserDeliveryTarget[] => { const getUserDeliveryTargets = (clientId: string): UserDeliveryTarget[] => {
const deliveryUsers = getUsersForNotificationDelivery(); return getUsersForNotificationDeliveryByClient(clientId)
return deliveryUsers
.filter((u) => canUserAccessClient(u.id, u.role, clientId))
.map((u) => ({ .map((u) => ({
userId: u.id, userId: u.id,
username: u.username, username: u.username,
@@ -473,6 +471,7 @@ async function startServer() {
notifyRemoteDesktopStatus, notifyRemoteDesktopStatus,
handleBuildTagConnection, handleBuildTagConnection,
notifyDashboard: sessionManager.notifyDashboardViewers, notifyDashboard: sessionManager.notifyDashboardViewers,
notifyDashboardClientEvent: sessionManager.notifyDashboardClientEvent,
broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent, broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent,
}; };
+42 -21
View File
@@ -108,6 +108,27 @@ class MetricsCollector {
private eventLoopDelays: number[] = []; private eventLoopDelays: number[] = [];
private maxEventLoopHistory: number = 300; 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() { constructor() {
setInterval(() => this.updateBandwidthRates(), 1000); setInterval(() => this.updateBandwidthRates(), 1000);
@@ -126,15 +147,13 @@ class MetricsCollector {
recordCommand(type: string) { recordCommand(type: string) {
this.commandCount++; this.commandCount++;
this.commandTimestamps.push(Date.now()); const now = Date.now();
this.commandTimestamps.push(now);
const count = this.commandTypeCount.get(type) || 0; const count = this.commandTypeCount.get(type) || 0;
this.commandTypeCount.set(type, count + 1); this.commandTypeCount.set(type, count + 1);
const oneHourAgo = Date.now() - 3600000; this.pruneTimestampWindow(this.commandTimestamps, now - 3600000);
this.commandTimestamps = this.commandTimestamps.filter(
(ts) => ts > oneHourAgo,
);
} }
recordBytesSent(bytes: number) { recordBytesSent(bytes: number) {
@@ -173,9 +192,14 @@ class MetricsCollector {
return { min: null, max: null, avg: null, count: 0 }; return { min: null, max: null, avg: null, count: 0 };
} }
const min = Math.min(...this.pingValues); let min = Number.POSITIVE_INFINITY;
const max = Math.max(...this.pingValues); let max = Number.NEGATIVE_INFINITY;
const sum = this.pingValues.reduce((a, b) => a + b, 0); 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; const avg = sum / this.pingValues.length;
return { min, max, avg, count: this.pingValues.length }; return { min, max, avg, count: this.pingValues.length };
@@ -201,8 +225,10 @@ class MetricsCollector {
this.httpTotal++; this.httpTotal++;
const now = Date.now(); const now = Date.now();
this.httpTimestamps.push(now); this.httpTimestamps.push(now);
this.pruneTimestampWindow(this.httpTimestamps, now - 60000);
if (statusCode >= 400) { if (statusCode >= 400) {
this.httpErrorTimestamps.push(now); this.httpErrorTimestamps.push(now);
this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000);
} }
if (Number.isFinite(durationMs)) { if (Number.isFinite(durationMs)) {
this.httpLatencies.push(durationMs); this.httpLatencies.push(durationMs);
@@ -253,25 +279,20 @@ class MetricsCollector {
const oneMinuteAgo = now - 60000; const oneMinuteAgo = now - 60000;
const oneHourAgo = now - 3600000; const oneHourAgo = now - 3600000;
const commandsLastMinute = this.commandTimestamps.filter( this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
(ts) => ts > oneMinuteAgo, this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
).length; this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
const commandsLastHour = this.commandTimestamps.filter(
(ts) => ts > oneHourAgo, const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
).length; const commandsLastHour = this.commandTimestamps.length;
const commandsByType: Record<string, number> = {}; const commandsByType: Record<string, number> = {};
for (const [type, count] of this.commandTypeCount.entries()) { for (const [type, count] of this.commandTypeCount.entries()) {
commandsByType[type] = count; commandsByType[type] = count;
} }
const oneMinuteAgoForHttp = now - 60000; const httpLastMinute = this.httpTimestamps.length;
const httpLastMinute = this.httpTimestamps.filter( const httpErrorsLastMinute = this.httpErrorTimestamps.length;
(ts) => ts > oneMinuteAgoForHttp,
).length;
const httpErrorsLastMinute = this.httpErrorTimestamps.filter(
(ts) => ts > oneMinuteAgoForHttp,
).length;
const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b); const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b);
const httpLatencyAvg = httpLatencySamples.length const httpLatencyAvg = httpLatencySamples.length
@@ -234,7 +234,7 @@ export async function handleClientRoutes(
); );
metrics.recordCommand("screenshot"); metrics.recordCommand("screenshot");
} }
const success = generateThumbnail(clientId); const success = await generateThumbnail(clientId);
return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS }); return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS });
} }
@@ -86,6 +86,10 @@ type WsLifecycleDeps = {
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
handleBuildTagConnection: (clientId: string, buildTag: string) => void; handleBuildTagConnection: (clientId: string, buildTag: string) => void;
notifyDashboard: () => 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: ( broadcastClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory", event: "client_online" | "client_offline" | "client_purgatory",
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, 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`); logger.info(`[purgatory] client ${resolvedId} is pending approval`);
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" })); ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
deps.notifyDashboard(); 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", { deps.broadcastClientEvent("client_purgatory", {
id: resolvedId, id: resolvedId,
host: (payload as any).host || undefined, host: (payload as any).host || undefined,
@@ -361,7 +373,16 @@ export async function handleWebSocketMessage(
clientManager.addClient(infoObj.id, infoObj); clientManager.addClient(infoObj.id, infoObj);
deps.dispatchAutoScriptsForConnection(infoObj, ws); 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, id: infoObj.id,
host: infoObj.host, host: infoObj.host,
user: infoObj.user, user: infoObj.user,
@@ -618,14 +639,14 @@ export function handleWebSocketClose(
if (role === "file_browser_viewer") { if (role === "file_browser_viewer") {
if (sessionId) { if (sessionId) {
sessionManager.getAllFileBrowserSessions().delete(sessionId); sessionManager.deleteFileBrowserSession(sessionId);
} }
return; return;
} }
if (role === "process_viewer") { if (role === "process_viewer") {
if (sessionId) { if (sessionId) {
sessionManager.getAllProcessSessions().delete(sessionId); sessionManager.deleteProcessSession(sessionId);
} }
return; return;
} }
@@ -661,6 +682,14 @@ export function handleWebSocketClose(
} }
if (role === "client" && currentClient) { 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", { deps.broadcastClientEvent("client_offline", {
id: clientId, id: clientId,
host: currentClient.host, host: currentClient.host,
@@ -235,10 +235,9 @@ export function stopConsoleOnTarget(target: ClientInfo | undefined, sessionId: s
} }
export function notifyConsoleClosed(clientId: string, reason: string) { export function notifyConsoleClosed(clientId: string, reason: string) {
for (const [sessionId, session] of sessionManager.getAllConsoleSessions().entries()) { for (const session of sessionManager.getConsoleSessionsByClient(clientId)) {
if (session.clientId !== clientId) continue;
safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId }); safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId });
sessionManager.deleteConsoleSession(sessionId); sessionManager.deleteConsoleSession(session.id);
} }
} }
@@ -58,7 +58,7 @@ export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
const sessionId = uuidv4(); const sessionId = uuidv4();
const target = clientManager.getClient(clientId); const target = clientManager.getClient(clientId);
const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
sessionManager.getAllFileBrowserSessions().set(sessionId, session); sessionManager.addFileBrowserSession(session);
ws.data.sessionId = sessionId; ws.data.sessionId = sessionId;
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target, clientUser: target?.user || "", clientOs: target?.os || "" }); safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target, clientUser: target?.user || "", clientOs: target?.os || "" });
if (!target) { if (!target) {
@@ -240,8 +240,7 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
} }
let hasSession = false; let hasSession = false;
for (const session of sessionManager.getAllFileBrowserSessions().values()) { for (const session of sessionManager.getFileBrowserSessionsByClient(clientId)) {
if (session.clientId !== clientId) continue;
if (!hasSession) { if (!hasSession) {
hasSession = true; hasSession = true;
if (type && type !== "command_result" && type !== "command_progress") { if (type && type !== "command_result" && type !== "command_progress") {
@@ -265,7 +264,7 @@ export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
const sessionId = uuidv4(); const sessionId = uuidv4();
const target = clientManager.getClient(clientId); const target = clientManager.getClient(clientId);
const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
sessionManager.getAllProcessSessions().set(sessionId, session); sessionManager.addProcessSession(session);
ws.data.sessionId = sessionId; ws.data.sessionId = sessionId;
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
if (!target) { if (!target) {
@@ -305,8 +304,7 @@ export function handleProcessViewerMessage(ws: ServerWebSocket<SocketData>, raw:
} }
export function handleProcessMessage(clientId: string, payload: any) { export function handleProcessMessage(clientId: string, payload: any) {
for (const session of sessionManager.getAllProcessSessions().values()) { for (const session of sessionManager.getProcessSessionsByClient(clientId)) {
if (session.clientId !== clientId) continue;
safeSendViewer(session.viewer, payload); safeSendViewer(session.viewer, payload);
} }
} }
@@ -371,8 +369,7 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket<SocketData>, ra
} }
export function handleKeyloggerMessage(clientId: string, payload: any) { export function handleKeyloggerMessage(clientId: string, payload: any) {
for (const session of sessionManager.getAllKeyloggerSessions().values()) { for (const session of sessionManager.getKeyloggerSessionsByClient(clientId)) {
if (session.clientId !== clientId) continue;
safeSendViewer(session.viewer, payload); safeSendViewer(session.viewer, payload);
} }
} }
+1 -3
View File
@@ -88,11 +88,9 @@ export function handleVoiceUplink(clientId: string, payload: any) {
: null; : null;
if (!bytes || bytes.byteLength === 0) return; if (!bytes || bytes.byteLength === 0) return;
for (const session of sessionManager.getAllVoiceSessions().values()) { for (const session of sessionManager.getVoiceSessionsByClient(clientId)) {
if (sessionId) { if (sessionId) {
if (session.id !== sessionId) continue; if (session.id !== sessionId) continue;
} else if (session.clientId !== clientId) {
continue;
} }
try { try {
session.viewer.send(bytes); session.viewer.send(bytes);
+99 -18
View File
@@ -22,14 +22,19 @@ const consoleSessions = new Map<string, ConsoleSession>();
const rdSessions = new Map<string, RemoteDesktopViewer>(); const rdSessions = new Map<string, RemoteDesktopViewer>();
const webcamSessions = new Map<string, RemoteDesktopViewer>(); const webcamSessions = new Map<string, RemoteDesktopViewer>();
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
const consoleSessionsByClient = new Map<string, Set<string>>();
const rdSessionsByClient = new Map<string, Set<string>>(); const rdSessionsByClient = new Map<string, Set<string>>();
const webcamSessionsByClient = new Map<string, Set<string>>(); const webcamSessionsByClient = new Map<string, Set<string>>();
const hvncSessionsByClient = new Map<string, Set<string>>(); const hvncSessionsByClient = new Map<string, Set<string>>();
const fileBrowserSessions = new Map<string, FileBrowserViewer>(); const fileBrowserSessions = new Map<string, FileBrowserViewer>();
const processSessions = new Map<string, ProcessViewer>(); const processSessions = new Map<string, ProcessViewer>();
const fileBrowserSessionsByClient = new Map<string, Set<string>>();
const processSessionsByClient = new Map<string, Set<string>>();
const notificationSessions = new Map<string, NotificationsViewer>(); const notificationSessions = new Map<string, NotificationsViewer>();
const keyloggerSessions = new Map<string, KeyloggerViewer>(); const keyloggerSessions = new Map<string, KeyloggerViewer>();
const keyloggerSessionsByClient = new Map<string, Set<string>>();
const voiceSessions = new Map<string, VoiceViewer>(); const voiceSessions = new Map<string, VoiceViewer>();
const voiceSessionsByClient = new Map<string, Set<string>>();
const dashboardSessions = new Map<string, DashboardViewer>(); const dashboardSessions = new Map<string, DashboardViewer>();
function addSessionToClientIndex( function addSessionToClientIndex(
@@ -60,6 +65,7 @@ function removeSessionFromClientIndex(
export function addConsoleSession(session: ConsoleSession): void { export function addConsoleSession(session: ConsoleSession): void {
consoleSessions.set(session.id, session); consoleSessions.set(session.id, session);
addSessionToClientIndex(consoleSessionsByClient, session.clientId, session.id);
} }
export function getConsoleSession( export function getConsoleSession(
@@ -69,13 +75,22 @@ export function getConsoleSession(
} }
export function deleteConsoleSession(sessionId: string): boolean { 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[] { export function getConsoleSessionsByClient(clientId: string): ConsoleSession[] {
return Array.from(consoleSessions.values()).filter( const ids = consoleSessionsByClient.get(clientId);
(s) => s.clientId === 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<string, ConsoleSession> { export function getAllConsoleSessions(): Map<string, ConsoleSession> {
@@ -221,6 +236,7 @@ export function getHvncSessionCount(): number {
export function addFileBrowserSession(session: FileBrowserViewer): void { export function addFileBrowserSession(session: FileBrowserViewer): void {
fileBrowserSessions.set(session.id, session); fileBrowserSessions.set(session.id, session);
addSessionToClientIndex(fileBrowserSessionsByClient, session.clientId, session.id);
} }
export function getFileBrowserSession( export function getFileBrowserSession(
@@ -230,15 +246,24 @@ export function getFileBrowserSession(
} }
export function deleteFileBrowserSession(sessionId: string): boolean { 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( export function getFileBrowserSessionsByClient(
clientId: string, clientId: string,
): FileBrowserViewer[] { ): FileBrowserViewer[] {
return Array.from(fileBrowserSessions.values()).filter( const ids = fileBrowserSessionsByClient.get(clientId);
(s) => s.clientId === 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<string, FileBrowserViewer> { export function getAllFileBrowserSessions(): Map<string, FileBrowserViewer> {
@@ -247,6 +272,7 @@ export function getAllFileBrowserSessions(): Map<string, FileBrowserViewer> {
export function addProcessSession(session: ProcessViewer): void { export function addProcessSession(session: ProcessViewer): void {
processSessions.set(session.id, session); processSessions.set(session.id, session);
addSessionToClientIndex(processSessionsByClient, session.clientId, session.id);
} }
export function getProcessSession( export function getProcessSession(
@@ -256,13 +282,22 @@ export function getProcessSession(
} }
export function deleteProcessSession(sessionId: string): boolean { 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[] { export function getProcessSessionsByClient(clientId: string): ProcessViewer[] {
return Array.from(processSessions.values()).filter( const ids = processSessionsByClient.get(clientId);
(s) => s.clientId === 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<string, ProcessViewer> { export function getAllProcessSessions(): Map<string, ProcessViewer> {
@@ -337,6 +372,7 @@ export function safeSendViewerFrame(
export function addKeyloggerSession(session: KeyloggerViewer): void { export function addKeyloggerSession(session: KeyloggerViewer): void {
keyloggerSessions.set(session.id, session); keyloggerSessions.set(session.id, session);
addSessionToClientIndex(keyloggerSessionsByClient, session.clientId, session.id);
} }
export function getKeyloggerSession( export function getKeyloggerSession(
@@ -346,15 +382,24 @@ export function getKeyloggerSession(
} }
export function deleteKeyloggerSession(sessionId: string): boolean { 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( export function getKeyloggerSessionsByClient(
clientId: string, clientId: string,
): KeyloggerViewer[] { ): KeyloggerViewer[] {
return Array.from(keyloggerSessions.values()).filter( const ids = keyloggerSessionsByClient.get(clientId);
(s) => s.clientId === 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<string, KeyloggerViewer> { export function getAllKeyloggerSessions(): Map<string, KeyloggerViewer> {
@@ -363,6 +408,7 @@ export function getAllKeyloggerSessions(): Map<string, KeyloggerViewer> {
export function addVoiceSession(session: VoiceViewer): void { export function addVoiceSession(session: VoiceViewer): void {
voiceSessions.set(session.id, session); voiceSessions.set(session.id, session);
addSessionToClientIndex(voiceSessionsByClient, session.clientId, session.id);
} }
export function getVoiceSession(sessionId: string): VoiceViewer | undefined { export function getVoiceSession(sessionId: string): VoiceViewer | undefined {
@@ -370,11 +416,22 @@ export function getVoiceSession(sessionId: string): VoiceViewer | undefined {
} }
export function deleteVoiceSession(sessionId: string): boolean { 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[] { 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<string, VoiceViewer> { export function getAllVoiceSessions(): Map<string, VoiceViewer> {
@@ -400,6 +457,30 @@ export function getDashboardSessionCount(): number {
let dashboardBroadcastTimer: ReturnType<typeof setTimeout> | null = null; let dashboardBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
const DASHBOARD_DEBOUNCE_MS = 150; 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 { export function notifyDashboardViewers(): void {
if (dashboardBroadcastTimer) return; if (dashboardBroadcastTimer) return;
dashboardBroadcastTimer = setTimeout(() => { dashboardBroadcastTimer = setTimeout(() => {
+54 -9
View File
@@ -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<string, string>(); const thumbnails = new Map<string, string>();
const latestFrames = new Map<string, { bytes: Uint8Array; format: string }>(); const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
const thumbnailRequests = new Map<string, number>(); const thumbnailRequests = new Map<string, number>();
export function setThumbnail(id: string, dataUrl: string) { 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) { 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<string | null> {
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<boolean> {
const frameData = latestFrames.get(id); const frameData = latestFrames.get(id);
if (!frameData) { if (!frameData) {
return false; return false;
} }
const { bytes, format } = frameData; try {
const b64 = Buffer.from(bytes).toString("base64"); const dataUrl = await buildThumbnailDataUrl(frameData.bytes, frameData.format);
const mime = format === "webp" ? "image/webp" : "image/jpeg"; if (!dataUrl) {
thumbnails.set(id, `data:${mime};base64,${b64}`); return false;
return true; }
thumbnails.set(id, dataUrl);
latestFrames.delete(id);
return true;
} catch {
return false;
}
} }
export function markThumbnailRequested(id: string) { export function markThumbnailRequested(id: string) {
+84 -18
View File
@@ -44,6 +44,15 @@ export interface UserClientAccessRule {
access: ClientAccessRuleKind; access: ClientAccessRuleKind;
} }
type UserAccessCacheEntry = {
scope: ClientAccessScope;
allow: Set<string>;
deny: Set<string>;
};
const userAccessCache = new Map<number, UserAccessCacheEntry>();
let notificationDeliveryCache: UserDeliveryRow[] | null = null;
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -207,10 +216,7 @@ export function listUsers(): UserInfo[] {
} }
export function getUserClientAccessScope(userId: number): ClientAccessScope { export function getUserClientAccessScope(userId: number): ClientAccessScope {
const row = db return getUserAccessCacheEntry(userId).scope;
.prepare("SELECT client_scope FROM users WHERE id = ?")
.get(userId) as { client_scope?: ClientAccessScope } | undefined;
return row?.client_scope || "none";
} }
export function listUserClientAccessRules(userId: number): UserClientAccessRule[] { export function listUserClientAccessRules(userId: number): UserClientAccessRule[] {
@@ -225,12 +231,54 @@ export function listUserClientRuleIdsByAccess(
userId: number, userId: number,
access: ClientAccessRuleKind, access: ClientAccessRuleKind,
): string[] { ): 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( .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) .all(userId) as Array<{ clientId: string; access: ClientAccessRuleKind }>;
.map((row: { clientId: string }) => row.clientId);
const entry: UserAccessCacheEntry = {
scope: row?.client_scope || "none",
allow: new Set<string>(),
deny: new Set<string>(),
};
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( export function setUserClientAccessScope(
@@ -243,6 +291,7 @@ export function setUserClientAccessScope(
try { try {
db.prepare("UPDATE users SET client_scope = ? WHERE id = ?").run(scope, userId); db.prepare("UPDATE users SET client_scope = ? WHERE id = ?").run(scope, userId);
invalidateUserAccessCache(userId);
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
logger.error("[users] setUserClientAccessScope error:", err); logger.error("[users] setUserClientAccessScope error:", err);
@@ -267,6 +316,7 @@ export function setUserClientAccessRule(
db.prepare( db.prepare(
"INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)", "INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)",
).run(userId, normalizedClientId, access, Date.now()); ).run(userId, normalizedClientId, access, Date.now());
invalidateUserAccessCache(userId);
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
logger.error("[users] setUserClientAccessRule error:", err); logger.error("[users] setUserClientAccessRule error:", err);
@@ -283,6 +333,7 @@ export function removeUserClientAccessRule(
userId, userId,
clientId, clientId,
); );
invalidateUserAccessCache(userId);
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
logger.error("[users] removeUserClientAccessRule error:", err); logger.error("[users] removeUserClientAccessRule error:", err);
@@ -297,21 +348,16 @@ export function canUserAccessClient(
): boolean { ): boolean {
if (role === "admin") return true; if (role === "admin") return true;
const scope = getUserClientAccessScope(userId); const access = getUserAccessCacheEntry(userId);
const scope = access.scope;
if (scope === "none") return false; if (scope === "none") return false;
if (scope === "all") return true; 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") { if (scope === "allowlist") {
return row?.access === "allow"; return access.allow.has(clientId);
} }
if (scope === "denylist") { if (scope === "denylist") {
return row?.access !== "deny"; return !access.deny.has(clientId);
} }
return false; 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); .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 }; return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) { } catch (err: any) {
logger.error("[users] Create user error:", err); logger.error("[users] Create user error:", err);
@@ -429,6 +477,8 @@ export function updateUserRole(
nextScope, nextScope,
userId, userId,
); );
invalidateUserAccessCache(userId);
invalidateNotificationDeliveryCache();
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
console.error("[users] Update role error:", err); console.error("[users] Update role error:", err);
@@ -451,6 +501,8 @@ export function deleteUser(userId: number): {
try { try {
db.prepare("DELETE FROM users WHERE id = ?").run(userId); db.prepare("DELETE FROM users WHERE id = ?").run(userId);
invalidateUserAccessCache(userId);
invalidateNotificationDeliveryCache();
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
console.error("[users] Delete user error:", err); console.error("[users] Delete user error:", err);
@@ -538,6 +590,7 @@ export function setUserTelegramChatId(
): { success: boolean; error?: string } { ): { success: boolean; error?: string } {
try { try {
db.prepare("UPDATE users SET telegram_chat_id = ? WHERE id = ?").run(chatId, userId); db.prepare("UPDATE users SET telegram_chat_id = ? WHERE id = ?").run(chatId, userId);
invalidateNotificationDeliveryCache();
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
logger.error("[users] setUserTelegramChatId error:", err); logger.error("[users] setUserTelegramChatId error:", err);
@@ -622,6 +675,7 @@ export function updateUserNotificationSettings(
try { try {
values.push(userId); values.push(userId);
db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values); db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values);
invalidateNotificationDeliveryCache();
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
logger.error("[users] updateUserNotificationSettings error:", err); logger.error("[users] updateUserNotificationSettings error:", err);
@@ -644,7 +698,11 @@ export interface UserDeliveryRow {
} }
export function getUsersForNotificationDelivery(): UserDeliveryRow[] { export function getUsersForNotificationDelivery(): UserDeliveryRow[] {
return db if (notificationDeliveryCache) {
return notificationDeliveryCache;
}
notificationDeliveryCache = db
.prepare( .prepare(
`SELECT id, username, role, client_scope, `SELECT id, username, role, client_scope,
webhook_enabled, webhook_url, webhook_template, 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 != '')`, AND telegram_chat_id IS NOT NULL AND telegram_chat_id != '')`,
) )
.all() as UserDeliveryRow[]; .all() as UserDeliveryRow[];
return notificationDeliveryCache;
}
export function getUsersForNotificationDeliveryByClient(clientId: string): UserDeliveryRow[] {
return getUsersForNotificationDelivery().filter((user) =>
canUserAccessClient(user.id, user.role, clientId),
);
} }
+52 -7
View File
@@ -14,6 +14,48 @@ import { metrics } from "./metrics";
const MAX_PING_RTT_MS = 15_000; const MAX_PING_RTT_MS = 15_000;
const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000); const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
const lastClientDbSync = new Map<string, number>(); const lastClientDbSync = new Map<string, number>();
const pendingClientDbUpdates = new Map<
string,
Partial<ClientInfo> & {
id: string;
lastSeen?: number;
online?: number;
}
>();
function queueClientDbUpdate(
partial: Partial<ClientInfo> & {
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 { function shouldSyncClientToDb(clientId: string, now: number): boolean {
const last = lastClientDbSync.get(clientId) || 0; const last = lastClientDbSync.get(clientId) || 0;
@@ -26,6 +68,7 @@ function shouldSyncClientToDb(clientId: string, now: number): boolean {
export function clearClientSyncState(clientId: string): void { export function clearClientSyncState(clientId: string): void {
lastClientDbSync.delete(clientId); lastClientDbSync.delete(clientId);
pendingClientDbUpdates.delete(clientId);
} }
export function handleHello( export function handleHello(
@@ -81,7 +124,7 @@ export function handlePing(info: ClientInfo, payload: WireMessage, ws: any) {
info.lastSeen = now; info.lastSeen = now;
info.online = true; info.online = true;
if (shouldSyncClientToDb(info.id, now)) { if (shouldSyncClientToDb(info.id, now)) {
upsertClientRow({ queueClientDbUpdate({
id: info.id, id: info.id,
lastSeen: info.lastSeen, lastSeen: info.lastSeen,
online: 1, online: 1,
@@ -136,7 +179,7 @@ export function handlePong(info: ClientInfo, payload: WireMessage) {
if (rtt >= 0 && rtt < maxRttMs) { if (rtt >= 0 && rtt < maxRttMs) {
info.pingMs = rtt; info.pingMs = rtt;
upsertClientRow({ queueClientDbUpdate({
id: info.id, id: info.id,
pingMs: info.pingMs, pingMs: info.pingMs,
lastSeen: info.lastSeen, lastSeen: info.lastSeen,
@@ -147,7 +190,7 @@ export function handlePong(info: ClientInfo, payload: WireMessage) {
metrics.recordPing(rtt); metrics.recordPing(rtt);
} else { } else {
if (shouldSyncClientToDb(info.id, nowTs)) { if (shouldSyncClientToDb(info.id, nowTs)) {
upsertClientRow({ queueClientDbUpdate({
id: info.id, id: info.id,
lastSeen: info.lastSeen, lastSeen: info.lastSeen,
online: 1, online: 1,
@@ -192,14 +235,16 @@ export function handleFrame(info: ClientInfo, payload: any) {
if (safeFormat) { if (safeFormat) {
const now = Date.now(); const now = Date.now();
setLatestFrame(info.id, bytes, safeFormat); const thumbnailRequested = consumeThumbnailRequest(info.id);
if (consumeThumbnailRequest(info.id) || !getThumbnail(info.id)) { const hasThumbnail = Boolean(getThumbnail(info.id));
generateThumbnail(info.id); if (thumbnailRequested || !hasThumbnail) {
setLatestFrame(info.id, bytes, safeFormat);
void generateThumbnail(info.id);
} }
info.lastSeen = now; info.lastSeen = now;
info.online = true; info.online = true;
if (shouldSyncClientToDb(info.id, now)) { if (shouldSyncClientToDb(info.id, now)) {
upsertClientRow({ id: info.id, lastSeen: now, online: 1 }); queueClientDbUpdate({ id: info.id, lastSeen: now, online: 1 });
} }
} }
} }