big optimizations
This commit is contained in:
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user