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