huge optimizations
This commit is contained in:
@@ -66,6 +66,7 @@ plugins/build-sysprofile.sh
|
|||||||
plugins/build-ranreg.bat
|
plugins/build-ranreg.bat
|
||||||
plugins/build-toxi.bat
|
plugins/build-toxi.bat
|
||||||
Overlord-Server/plugins/*
|
Overlord-Server/plugins/*
|
||||||
|
plugins/toxi/
|
||||||
plugins/toxi/*
|
plugins/toxi/*
|
||||||
plugins/toxi copy/*
|
plugins/toxi copy/*
|
||||||
plugins/extension/*
|
plugins/extension/*
|
||||||
@@ -79,6 +80,8 @@ Overlord-Server/data/
|
|||||||
release/
|
release/
|
||||||
|
|
||||||
stress/
|
stress/
|
||||||
|
Overlord-Client/cmd/loadgen/
|
||||||
|
start-prod-tuned.bat
|
||||||
|
|
||||||
## Ignore Visual Studio temporary files, build results, and
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
## files generated by popular Visual Studio add-ons.
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { digestData } from "./utils.js";
|
|||||||
const POLL_INTERVAL_MS = 5000;
|
const POLL_INTERVAL_MS = 5000;
|
||||||
const FALLBACK_POLL_MS = 30000;
|
const FALLBACK_POLL_MS = 30000;
|
||||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||||
|
const REALTIME_MIN_LOAD_MS = 1500;
|
||||||
|
const REALTIME_CHURN_WINDOW_MS = 5000;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let render = () => {};
|
let render = () => {};
|
||||||
let lastClientData = null;
|
let lastClientData = null;
|
||||||
@@ -12,6 +14,12 @@ const PAGE_CACHE_LIMIT = 8;
|
|||||||
let dashboardWs = null;
|
let dashboardWs = null;
|
||||||
let dashboardWsConnected = false;
|
let dashboardWsConnected = false;
|
||||||
let wsReconnectTimer = null;
|
let wsReconnectTimer = null;
|
||||||
|
let realtimeLoadTimer = null;
|
||||||
|
let lastRealtimeLoadAt = 0;
|
||||||
|
let lastRealtimeEventAt = 0;
|
||||||
|
let realtimeEventCount = 0;
|
||||||
|
let realtimeForce = false;
|
||||||
|
let realtimeReorder = false;
|
||||||
const manuallyDisconnecting = new Set();
|
const manuallyDisconnecting = new Set();
|
||||||
|
|
||||||
function stopDashboardRealtime() {
|
function stopDashboardRealtime() {
|
||||||
@@ -19,6 +27,8 @@ function stopDashboardRealtime() {
|
|||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
wsReconnectTimer = null;
|
wsReconnectTimer = null;
|
||||||
|
clearTimeout(realtimeLoadTimer);
|
||||||
|
realtimeLoadTimer = null;
|
||||||
if (dashboardWs) {
|
if (dashboardWs) {
|
||||||
try {
|
try {
|
||||||
dashboardWs.onopen = null;
|
dashboardWs.onopen = null;
|
||||||
@@ -32,6 +42,38 @@ function stopDashboardRealtime() {
|
|||||||
dashboardWsConnected = false;
|
dashboardWsConnected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noteRealtimeEvent() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRealtimeEventAt > REALTIME_CHURN_WINDOW_MS) {
|
||||||
|
realtimeEventCount = 0;
|
||||||
|
}
|
||||||
|
lastRealtimeEventAt = now;
|
||||||
|
realtimeEventCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRealtimeChurnActive() {
|
||||||
|
return Date.now() - lastRealtimeEventAt < REALTIME_CHURN_WINDOW_MS && realtimeEventCount > 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRealtimeLoad(options = {}) {
|
||||||
|
noteRealtimeEvent();
|
||||||
|
realtimeForce = realtimeForce || !!options.force;
|
||||||
|
realtimeReorder = realtimeReorder || options.reorder !== false;
|
||||||
|
if (realtimeLoadTimer) return;
|
||||||
|
|
||||||
|
const elapsed = Date.now() - lastRealtimeLoadAt;
|
||||||
|
const delay = Math.max(0, REALTIME_MIN_LOAD_MS - elapsed);
|
||||||
|
realtimeLoadTimer = setTimeout(() => {
|
||||||
|
realtimeLoadTimer = null;
|
||||||
|
lastRealtimeLoadAt = Date.now();
|
||||||
|
const force = realtimeForce;
|
||||||
|
const reorder = realtimeReorder;
|
||||||
|
realtimeForce = false;
|
||||||
|
realtimeReorder = false;
|
||||||
|
loadWithOptions({ force, reorder });
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
function bindDashboardPagehideCleanup() {
|
function bindDashboardPagehideCleanup() {
|
||||||
window.removeEventListener("pagehide", stopDashboardRealtime);
|
window.removeEventListener("pagehide", stopDashboardRealtime);
|
||||||
window.addEventListener("pagehide", stopDashboardRealtime);
|
window.addEventListener("pagehide", stopDashboardRealtime);
|
||||||
@@ -101,6 +143,7 @@ async function prefetchClientPage(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prefetchAdjacentPages(data) {
|
function prefetchAdjacentPages(data) {
|
||||||
|
if (isRealtimeChurnActive()) return;
|
||||||
const totalPages = Math.max(1, Math.ceil((Number(data.total) || 0) / (Number(data.pageSize) || state.pageSize || 1)));
|
const totalPages = Math.max(1, Math.ceil((Number(data.total) || 0) / (Number(data.pageSize) || state.pageSize || 1)));
|
||||||
const current = Number(data.page) || state.page;
|
const current = Number(data.page) || state.page;
|
||||||
if (current < totalPages) prefetchClientPage(current + 1);
|
if (current < totalPages) prefetchClientPage(current + 1);
|
||||||
@@ -219,16 +262,16 @@ function connectDashboardWs() {
|
|||||||
try {
|
try {
|
||||||
const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
|
const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
|
||||||
if (msg && msg.type === "clients_changed") {
|
if (msg && msg.type === "clients_changed") {
|
||||||
loadWithOptions({ force: false, reorder: true });
|
scheduleRealtimeLoad({ force: false, reorder: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg && msg.type === "client_event") {
|
if (msg && msg.type === "client_event") {
|
||||||
if (msg.event === "client_offline" && manuallyDisconnecting.has(msg.clientId)) {
|
if (msg.event === "client_offline" && manuallyDisconnecting.has(msg.clientId)) {
|
||||||
manuallyDisconnecting.delete(msg.clientId);
|
manuallyDisconnecting.delete(msg.clientId);
|
||||||
loadWithOptions({ force: true });
|
scheduleRealtimeLoad({ force: true, reorder: true });
|
||||||
} else {
|
} else {
|
||||||
moveClientCardImmediately(msg);
|
if (!isRealtimeChurnActive()) moveClientCardImmediately(msg);
|
||||||
loadWithOptions({ force: false, reorder: true });
|
scheduleRealtimeLoad({ force: false, reorder: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -780,6 +780,67 @@ function updateMetrics(data, debug) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const internalTasksList = document.getElementById("internal-tasks");
|
||||||
|
if (internalTasksList) {
|
||||||
|
const tasks = Array.isArray(data.internal?.tasks) ? data.internal.tasks : [];
|
||||||
|
if (tasks.length > 0) {
|
||||||
|
internalTasksList.innerHTML = tasks
|
||||||
|
.map((task) => `
|
||||||
|
<div class="bg-slate-800/50 rounded p-3 min-w-0">
|
||||||
|
<div class="text-xs text-slate-400 mb-2 truncate" title="${escapeHtml(task.task)}">
|
||||||
|
${escapeHtml(task.task)}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span class="text-slate-500">p95</span>
|
||||||
|
<span class="font-semibold text-right">${formatMs(task.durationP95 || 0)}</span>
|
||||||
|
<span class="text-slate-500">avg</span>
|
||||||
|
<span class="font-semibold text-right">${formatMs(task.durationAvg || 0)}</span>
|
||||||
|
<span class="text-slate-500">max</span>
|
||||||
|
<span class="font-semibold text-right">${formatMs(task.durationMax || 0)}</span>
|
||||||
|
<span class="text-slate-500">runs/min</span>
|
||||||
|
<span class="font-semibold text-right">${Number(task.countLastMinute || 0).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
internalTasksList.innerHTML =
|
||||||
|
'<div class="text-slate-500 col-span-full text-center py-4">No internal job samples yet</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedStateList = document.getElementById("retained-state");
|
||||||
|
if (retainedStateList) {
|
||||||
|
const retained = data.diagnostics?.retained || {};
|
||||||
|
const entries = Object.entries(retained)
|
||||||
|
.filter(([, value]) => typeof value === "number" || typeof value === "boolean")
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
if (entries.length > 0) {
|
||||||
|
retainedStateList.innerHTML = entries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const displayValue = key.toLowerCase().includes("bytes")
|
||||||
|
? formatBytes(Number(value || 0))
|
||||||
|
: typeof value === "boolean"
|
||||||
|
? value ? "yes" : "no"
|
||||||
|
: Number(value || 0).toLocaleString();
|
||||||
|
return `
|
||||||
|
<div class="bg-slate-800/50 rounded p-3 min-w-0">
|
||||||
|
<div class="text-xs text-slate-500 mb-1 truncate" title="${escapeHtml(key)}">
|
||||||
|
${escapeHtml(key)}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold truncate" title="${escapeHtml(displayValue)}">
|
||||||
|
${escapeHtml(displayValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
retainedStateList.innerHTML =
|
||||||
|
'<div class="text-slate-500 col-span-full text-center py-4">No retained state counters yet</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const commandTypesList = document.getElementById("command-types");
|
const commandTypesList = document.getElementById("command-types");
|
||||||
if (Object.keys(data.commands.byType).length > 0) {
|
if (Object.keys(data.commands.byType).length > 0) {
|
||||||
const topCommands = Object.entries(data.commands.byType)
|
const topCommands = Object.entries(data.commands.byType)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async function triggerAgentThumbnail(clientId) {
|
|||||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/thumbnail`, {
|
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/thumbnail`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
headers: { "X-Overlord-Thumbnail-Source": "dashboard" },
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return await res.json().catch(() => null);
|
return await res.json().catch(() => null);
|
||||||
|
|||||||
@@ -258,6 +258,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Slow Internal Jobs -->
|
||||||
|
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-gears text-cyan-400"></i>
|
||||||
|
Slowest Internal Jobs
|
||||||
|
</h3>
|
||||||
|
<div class="text-xs text-slate-500">Sorted by p95 duration, last minute</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="internal-tasks"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3"
|
||||||
|
>
|
||||||
|
<div class="text-slate-500 col-span-full text-center py-4">
|
||||||
|
No internal job samples yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retained Runtime State -->
|
||||||
|
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-box-archive text-violet-400"></i>
|
||||||
|
Retained Runtime State
|
||||||
|
</h3>
|
||||||
|
<div class="text-xs text-slate-500">Long-lived maps, queues, and caches</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="retained-state"
|
||||||
|
class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-3"
|
||||||
|
>
|
||||||
|
<div class="text-slate-500 col-span-full text-center py-4">
|
||||||
|
No retained state counters yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mix Charts -->
|
<!-- Mix Charts -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Database from "bun:sqlite";
|
import Database from "bun:sqlite";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
|
import { metrics } from "./metrics";
|
||||||
|
|
||||||
const dataDir = ensureDataDir();
|
const dataDir = ensureDataDir();
|
||||||
const dbPath = resolve(dataDir, "overlord.db");
|
const dbPath = resolve(dataDir, "overlord.db");
|
||||||
@@ -145,6 +146,7 @@ function flushAuditLogs(): void {
|
|||||||
if (auditQueue.length === 0) return;
|
if (auditQueue.length === 0) return;
|
||||||
|
|
||||||
const logsToWrite = auditQueue.splice(0, auditQueue.length);
|
const logsToWrite = auditQueue.splice(0, auditQueue.length);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
@@ -168,6 +170,7 @@ function flushAuditLogs(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
transaction(logsToWrite);
|
transaction(logsToWrite);
|
||||||
|
metrics.recordInternalTask("audit-flush", Date.now() - startedAt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[audit] Failed to flush ${logsToWrite.length} audit entries:`,
|
`[audit] Failed to flush ${logsToWrite.length} audit entries:`,
|
||||||
@@ -180,6 +183,10 @@ export function flushAuditLogsSync(): void {
|
|||||||
flushAuditLogs();
|
flushAuditLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAuditQueueStats(): { queued: number } {
|
||||||
|
return { queued: auditQueue.length };
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuditLogs(filters: AuditLogFilters = {}): {
|
export function getAuditLogs(filters: AuditLogFilters = {}): {
|
||||||
logs: AuditLogEntry[];
|
logs: AuditLogEntry[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { upsertClientRow, type ClientDbRow } from "./db";
|
import { upsertClientRows, type ClientDbRow } from "./db";
|
||||||
|
import { metrics } from "./metrics";
|
||||||
|
|
||||||
export const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
|
export const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
|
||||||
|
export const CLIENT_DB_SYNC_BATCH_SIZE = Math.max(
|
||||||
|
1,
|
||||||
|
Number(process.env.OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE || 100),
|
||||||
|
);
|
||||||
|
|
||||||
const lastClientDbSync = new Map<string, number>();
|
const lastClientDbSync = new Map<string, number>();
|
||||||
const pendingClientDbUpdates = new Map<string, ClientDbRow>();
|
const pendingClientDbUpdates = new Map<string, ClientDbRow>();
|
||||||
|
let flushSoonTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
export function queueClientDbUpdate(partial: ClientDbRow): void {
|
export function queueClientDbUpdate(partial: ClientDbRow): void {
|
||||||
const existing = pendingClientDbUpdates.get(partial.id);
|
const existing = pendingClientDbUpdates.get(partial.id);
|
||||||
@@ -23,10 +29,27 @@ export function queueClientDbUpdate(partial: ClientDbRow): void {
|
|||||||
|
|
||||||
export function flushQueuedClientDbUpdates(): void {
|
export function flushQueuedClientDbUpdates(): void {
|
||||||
if (pendingClientDbUpdates.size === 0) return;
|
if (pendingClientDbUpdates.size === 0) return;
|
||||||
for (const update of pendingClientDbUpdates.values()) {
|
|
||||||
upsertClientRow(update);
|
const startedAt = Date.now();
|
||||||
|
let processed = 0;
|
||||||
|
const updates: ClientDbRow[] = [];
|
||||||
|
for (const [clientId, update] of pendingClientDbUpdates.entries()) {
|
||||||
|
updates.push(update);
|
||||||
|
pendingClientDbUpdates.delete(clientId);
|
||||||
|
processed += 1;
|
||||||
|
if (processed >= CLIENT_DB_SYNC_BATCH_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pendingClientDbUpdates.clear();
|
upsertClientRows(updates);
|
||||||
|
|
||||||
|
if (pendingClientDbUpdates.size > 0 && !flushSoonTimer) {
|
||||||
|
flushSoonTimer = setTimeout(() => {
|
||||||
|
flushSoonTimer = null;
|
||||||
|
flushQueuedClientDbUpdates();
|
||||||
|
}, 25);
|
||||||
|
}
|
||||||
|
metrics.recordInternalTask("client-db-flush", Date.now() - startedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS);
|
setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS);
|
||||||
@@ -46,3 +69,15 @@ export function clearClientSyncState(clientId: string): void {
|
|||||||
lastClientDbSync.delete(clientId);
|
lastClientDbSync.delete(clientId);
|
||||||
pendingClientDbUpdates.delete(clientId);
|
pendingClientDbUpdates.delete(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClientDbSyncStats(): {
|
||||||
|
trackedClients: number;
|
||||||
|
pendingUpdates: number;
|
||||||
|
flushScheduled: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
trackedClients: lastClientDbSync.size,
|
||||||
|
pendingUpdates: pendingClientDbUpdates.size,
|
||||||
|
flushScheduled: flushSoonTimer !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function envBoolOverride(name: string): boolean | undefined {
|
||||||
|
const raw = String(process.env[name] || "").trim().toLowerCase();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (["1", "true", "yes", "on"].includes(raw)) return true;
|
||||||
|
if (["0", "false", "no", "off"].includes(raw)) return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
type SaveSecrets = {
|
type SaveSecrets = {
|
||||||
auth?: {
|
auth?: {
|
||||||
jwtSecret?: string;
|
jwtSecret?: string;
|
||||||
@@ -591,13 +599,15 @@ export function loadConfig(): Config {
|
|||||||
},
|
},
|
||||||
thumbnails: {
|
thumbnails: {
|
||||||
dashboardEnabled:
|
dashboardEnabled:
|
||||||
fileConfig.thumbnails?.dashboardEnabled !== undefined
|
envBoolOverride("OVERLORD_THUMBNAILS_DASHBOARD_ENABLED") ??
|
||||||
|
(fileConfig.thumbnails?.dashboardEnabled !== undefined
|
||||||
? Boolean(fileConfig.thumbnails.dashboardEnabled)
|
? Boolean(fileConfig.thumbnails.dashboardEnabled)
|
||||||
: DEFAULT_CONFIG.thumbnails.dashboardEnabled,
|
: DEFAULT_CONFIG.thumbnails.dashboardEnabled),
|
||||||
wallEnabled:
|
wallEnabled:
|
||||||
fileConfig.thumbnails?.wallEnabled !== undefined
|
envBoolOverride("OVERLORD_THUMBNAILS_WALL_ENABLED") ??
|
||||||
|
(fileConfig.thumbnails?.wallEnabled !== undefined
|
||||||
? Boolean(fileConfig.thumbnails.wallEnabled)
|
? Boolean(fileConfig.thumbnails.wallEnabled)
|
||||||
: DEFAULT_CONFIG.thumbnails.wallEnabled,
|
: DEFAULT_CONFIG.thumbnails.wallEnabled),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -215,12 +215,13 @@ export type ClientDbRow = Omit<Partial<ClientInfo>, "online"> & {
|
|||||||
online?: number;
|
online?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function upsertClientRow(
|
export type OfflineStateUpdate = {
|
||||||
partial: ClientDbRow,
|
id: string;
|
||||||
) {
|
disconnectReason?: string;
|
||||||
const now = partial.lastSeen ?? Date.now();
|
disconnectDetail?: string;
|
||||||
db.run(
|
};
|
||||||
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, permissions)
|
|
||||||
|
const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, permissions)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
hwid=COALESCE(excluded.hwid, clients.hwid),
|
hwid=COALESCE(excluded.hwid, clients.hwid),
|
||||||
@@ -252,7 +253,13 @@ export function upsertClientRow(
|
|||||||
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
|
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
|
||||||
elevation=COALESCE(excluded.elevation, clients.elevation),
|
elevation=COALESCE(excluded.elevation, clients.elevation),
|
||||||
permissions=COALESCE(excluded.permissions, clients.permissions)
|
permissions=COALESCE(excluded.permissions, clients.permissions)
|
||||||
`,
|
`;
|
||||||
|
|
||||||
|
const upsertClientRowStmt = db.prepare(UPSERT_CLIENT_ROW_SQL);
|
||||||
|
|
||||||
|
function upsertClientRowInternal(partial: ClientDbRow): void {
|
||||||
|
const now = partial.lastSeen ?? Date.now();
|
||||||
|
upsertClientRowStmt.run(
|
||||||
partial.id,
|
partial.id,
|
||||||
partial.hwid ?? partial.id,
|
partial.hwid ?? partial.id,
|
||||||
partial.role ?? null,
|
partial.role ?? null,
|
||||||
@@ -292,6 +299,24 @@ export function upsertClientRow(
|
|||||||
partial.id,
|
partial.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertClientRowsTx = db.transaction((rows: ClientDbRow[]) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
upsertClientRowInternal(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function upsertClientRow(
|
||||||
|
partial: ClientDbRow,
|
||||||
|
) {
|
||||||
|
upsertClientRowInternal(partial);
|
||||||
|
invalidateClientMetricsSummaryCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertClientRows(rows: ClientDbRow[]): void {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
upsertClientRowsTx(rows);
|
||||||
invalidateClientMetricsSummaryCache();
|
invalidateClientMetricsSummaryCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +339,28 @@ export function setOnlineState(id: string, online: boolean, disconnectReason?: s
|
|||||||
invalidateClientMetricsSummaryCache();
|
invalidateClientMetricsSummaryCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setOfflineStateStmt = db.prepare(
|
||||||
|
`UPDATE clients SET online=0, last_seen=?, disconnect_reason=?, disconnect_detail=? WHERE id=?`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setOfflineStatesTx = db.transaction((updates: OfflineStateUpdate[]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const update of updates) {
|
||||||
|
setOfflineStateStmt.run(
|
||||||
|
now,
|
||||||
|
update.disconnectReason ?? null,
|
||||||
|
update.disconnectDetail ?? null,
|
||||||
|
update.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setOfflineStates(updates: OfflineStateUpdate[]): void {
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
setOfflineStatesTx(updates);
|
||||||
|
invalidateClientMetricsSummaryCache();
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteClientRow(id: string) {
|
export function deleteClientRow(id: string) {
|
||||||
db.run(`DELETE FROM clients WHERE id=?`, id);
|
db.run(`DELETE FROM clients WHERE id=?`, id);
|
||||||
invalidateClientMetricsSummaryCache();
|
invalidateClientMetricsSummaryCache();
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import { ClientInfo, ClientRole } from "./types";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "./auth";
|
import { authenticateRequest } from "./auth";
|
||||||
import { loadConfig, getConfig } from "./config";
|
import { loadConfig, getConfig } from "./config";
|
||||||
import { flushAuditLogsSync } from "./auditLog";
|
import { flushAuditLogsSync, getAuditQueueStats } from "./auditLog";
|
||||||
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient } from "./users";
|
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient } from "./users";
|
||||||
import { requireAuth, requirePermission } from "./rbac";
|
import { requireAuth, requirePermission } from "./rbac";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
|
import { getClientDbSyncStats } from "./client-db-sync";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||||
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||||
@@ -146,6 +147,7 @@ import {
|
|||||||
} from "./server/ws-desktop-audio";
|
} from "./server/ws-desktop-audio";
|
||||||
import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin";
|
import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin";
|
||||||
import { loadOrGenerateVapidKeys } from "./server/web-push";
|
import { loadOrGenerateVapidKeys } from "./server/web-push";
|
||||||
|
import { getThumbnailStats } from "./thumbnails";
|
||||||
import * as clientManager from "./clientManager";
|
import * as clientManager from "./clientManager";
|
||||||
import * as sessionManager from "./sessions/sessionManager";
|
import * as sessionManager from "./sessions/sessionManager";
|
||||||
import type { SocketData } from "./sessions/types";
|
import type { SocketData } from "./sessions/types";
|
||||||
@@ -156,6 +158,9 @@ import { handleBuildTagConnection } from "./server/build-tag";
|
|||||||
|
|
||||||
metrics.setSnapshotEnricher((snapshot) => {
|
metrics.setSnapshotEnricher((snapshot) => {
|
||||||
const summary = getClientMetricsSummary();
|
const summary = getClientMetricsSummary();
|
||||||
|
const dbSync = getClientDbSyncStats();
|
||||||
|
const audit = getAuditQueueStats();
|
||||||
|
const thumbnails = getThumbnailStats();
|
||||||
snapshot.clients.total = summary.total;
|
snapshot.clients.total = summary.total;
|
||||||
snapshot.clients.online = summary.online;
|
snapshot.clients.online = summary.online;
|
||||||
snapshot.clients.offline = summary.total - summary.online;
|
snapshot.clients.offline = summary.total - summary.online;
|
||||||
@@ -165,6 +170,35 @@ metrics.setSnapshotEnricher((snapshot) => {
|
|||||||
snapshot.sessions.remoteDesktop = sessionManager.getRdSessionCount();
|
snapshot.sessions.remoteDesktop = sessionManager.getRdSessionCount();
|
||||||
snapshot.sessions.fileBrowser = sessionManager.getFileBrowserSessionCount();
|
snapshot.sessions.fileBrowser = sessionManager.getFileBrowserSessionCount();
|
||||||
snapshot.sessions.process = sessionManager.getProcessSessionCount();
|
snapshot.sessions.process = sessionManager.getProcessSessionCount();
|
||||||
|
snapshot.diagnostics = {
|
||||||
|
retained: {
|
||||||
|
clientsMap: clientManager.getClientCount(),
|
||||||
|
clientDbSyncTracked: dbSync.trackedClients,
|
||||||
|
clientDbSyncPending: dbSync.pendingUpdates,
|
||||||
|
clientDbSyncFlushScheduled: dbSync.flushScheduled,
|
||||||
|
auditQueued: audit.queued,
|
||||||
|
thumbnailsCached: thumbnails.cachedCount,
|
||||||
|
thumbnailBytes: thumbnails.cachedBytes,
|
||||||
|
thumbnailPendingFrames: thumbnails.pendingFrames,
|
||||||
|
thumbnailGenQueued: thumbnails.genQueued,
|
||||||
|
thumbnailGenState: thumbnails.genStateTracked,
|
||||||
|
dashboardSessions: sessionManager.getDashboardSessionCount(),
|
||||||
|
notificationSessions: sessionManager.getNotificationSessionCount(),
|
||||||
|
chatSessions: sessionManager.getChatSessionCount(),
|
||||||
|
pluginLoadedClients: pluginLoadedByClient.size,
|
||||||
|
pluginLoadingClients: pluginLoadingByClient.size,
|
||||||
|
pendingPluginEvents: pendingPluginEvents.size,
|
||||||
|
pendingHttpDownloads: pendingHttpDownloads.size,
|
||||||
|
downloadIntents: downloadIntents.size,
|
||||||
|
deployUploads: deployUploads.size,
|
||||||
|
winreUploads: winreUploads.size,
|
||||||
|
notificationRateClients: notificationRate.size,
|
||||||
|
pendingNotificationScreenshots: pendingNotificationScreenshots.size,
|
||||||
|
rdStreamingClients: rdStreamingState.size,
|
||||||
|
hvncStreamingClients: hvncStreamingState.size,
|
||||||
|
webcamStreamingClients: webcamStreamingState.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export interface MetricsSnapshot {
|
|||||||
avg: number;
|
avg: number;
|
||||||
max: number;
|
max: number;
|
||||||
p95: number;
|
p95: number;
|
||||||
|
sampleMs: number;
|
||||||
|
samples: number;
|
||||||
|
};
|
||||||
|
internal: {
|
||||||
|
tasks: InternalTaskStats[];
|
||||||
|
};
|
||||||
|
diagnostics?: {
|
||||||
|
retained: Record<string, number | boolean>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +89,17 @@ export interface HttpRouteStats {
|
|||||||
lastStatus: number;
|
lastStatus: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InternalTaskStats {
|
||||||
|
task: string;
|
||||||
|
countLastMinute: number;
|
||||||
|
durationAvg: number;
|
||||||
|
durationP95: number;
|
||||||
|
durationP99: number;
|
||||||
|
durationMax: number;
|
||||||
|
lastDuration: number;
|
||||||
|
lastAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MetricsHistory {
|
export interface MetricsHistory {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
clientsOnline: number;
|
clientsOnline: number;
|
||||||
@@ -106,6 +125,11 @@ interface TimedHttpSample {
|
|||||||
statusCode: number;
|
statusCode: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimedInternalTaskSample {
|
||||||
|
ts: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
class MetricsCollector {
|
class MetricsCollector {
|
||||||
private startTime: number = Date.now();
|
private startTime: number = Date.now();
|
||||||
|
|
||||||
@@ -141,7 +165,17 @@ class MetricsCollector {
|
|||||||
private ignoredHttpMetricRoutes: Set<string> = new Set(["GET /api/metrics"]);
|
private ignoredHttpMetricRoutes: Set<string> = new Set(["GET /api/metrics"]);
|
||||||
|
|
||||||
private eventLoopDelays: number[] = [];
|
private eventLoopDelays: number[] = [];
|
||||||
private maxEventLoopHistory: number = 300;
|
private eventLoopSampleMs: number = Math.max(
|
||||||
|
20,
|
||||||
|
Number(process.env.OVERLORD_EVENT_LOOP_SAMPLE_MS || 100),
|
||||||
|
);
|
||||||
|
private maxEventLoopHistory: number = Math.max(
|
||||||
|
10,
|
||||||
|
Math.ceil((Number(process.env.OVERLORD_EVENT_LOOP_HISTORY_SECONDS || 60) * 1000) / this.eventLoopSampleMs),
|
||||||
|
);
|
||||||
|
|
||||||
|
private internalTaskSamples: Map<string, TimedInternalTaskSample[]> = new Map();
|
||||||
|
private maxInternalTaskSamples: number = 300;
|
||||||
|
|
||||||
private pruneTimestampWindow(list: number[], minTs: number): void {
|
private pruneTimestampWindow(list: number[], minTs: number): void {
|
||||||
let removeCount = 0;
|
let removeCount = 0;
|
||||||
@@ -257,7 +291,7 @@ class MetricsCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private trackEventLoopDelay() {
|
private trackEventLoopDelay() {
|
||||||
const intervalMs = 1000;
|
const intervalMs = this.eventLoopSampleMs;
|
||||||
let last = Date.now();
|
let last = Date.now();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -282,7 +316,7 @@ class MetricsCollector {
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private pruneHttpSamples(list: TimedHttpSample[], minTs: number): void {
|
private pruneTimedSamples(list: Array<{ ts: number }>, minTs: number): void {
|
||||||
let removeCount = 0;
|
let removeCount = 0;
|
||||||
while (removeCount < list.length && list[removeCount].ts <= minTs) {
|
while (removeCount < list.length && list[removeCount].ts <= minTs) {
|
||||||
removeCount += 1;
|
removeCount += 1;
|
||||||
@@ -303,7 +337,7 @@ class MetricsCollector {
|
|||||||
private getTopHttpRoutes(minTs: number): HttpRouteStats[] {
|
private getTopHttpRoutes(minTs: number): HttpRouteStats[] {
|
||||||
const routes: HttpRouteStats[] = [];
|
const routes: HttpRouteStats[] = [];
|
||||||
for (const [route, samples] of this.httpRouteSamples.entries()) {
|
for (const [route, samples] of this.httpRouteSamples.entries()) {
|
||||||
this.pruneHttpSamples(samples, minTs);
|
this.pruneTimedSamples(samples, minTs);
|
||||||
if (samples.length === 0) {
|
if (samples.length === 0) {
|
||||||
this.httpRouteSamples.delete(route);
|
this.httpRouteSamples.delete(route);
|
||||||
continue;
|
continue;
|
||||||
@@ -335,6 +369,40 @@ class MetricsCollector {
|
|||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTopInternalTasks(minTs: number): InternalTaskStats[] {
|
||||||
|
const tasks: InternalTaskStats[] = [];
|
||||||
|
for (const [task, samples] of this.internalTaskSamples.entries()) {
|
||||||
|
this.pruneTimedSamples(samples, minTs);
|
||||||
|
if (samples.length === 0) {
|
||||||
|
this.internalTaskSamples.delete(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durations = samples
|
||||||
|
.map((sample) => sample.duration)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const last = samples[samples.length - 1];
|
||||||
|
tasks.push({
|
||||||
|
task,
|
||||||
|
countLastMinute: samples.length,
|
||||||
|
durationAvg: this.average(durations),
|
||||||
|
durationP95: this.percentile(durations, 0.95),
|
||||||
|
durationP99: this.percentile(durations, 0.99),
|
||||||
|
durationMax: durations[durations.length - 1] ?? 0,
|
||||||
|
lastDuration: last?.duration ?? 0,
|
||||||
|
lastAt: last?.ts ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.durationP95 !== a.durationP95) return b.durationP95 - a.durationP95;
|
||||||
|
if (b.durationAvg !== a.durationAvg) return b.durationAvg - a.durationAvg;
|
||||||
|
return b.countLastMinute - a.countLastMinute;
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") {
|
recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") {
|
||||||
if (this.ignoredHttpMetricRoutes.has(route)) return;
|
if (this.ignoredHttpMetricRoutes.has(route)) return;
|
||||||
|
|
||||||
@@ -362,6 +430,18 @@ class MetricsCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordInternalTask(task: string, durationMs: number) {
|
||||||
|
if (!task || !Number.isFinite(durationMs)) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const sample = { ts: now, duration: Math.max(0, durationMs) };
|
||||||
|
const samples = this.internalTaskSamples.get(task) || [];
|
||||||
|
samples.push(sample);
|
||||||
|
if (samples.length > this.maxInternalTaskSamples) {
|
||||||
|
samples.splice(0, samples.length - this.maxInternalTaskSamples);
|
||||||
|
}
|
||||||
|
this.internalTaskSamples.set(task, samples);
|
||||||
|
}
|
||||||
|
|
||||||
async withHttpMetrics<T extends Response>(
|
async withHttpMetrics<T extends Response>(
|
||||||
handler: () => Promise<T>,
|
handler: () => Promise<T>,
|
||||||
route = "unknown",
|
route = "unknown",
|
||||||
@@ -424,7 +504,7 @@ class MetricsCollector {
|
|||||||
this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
|
this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
|
||||||
this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
|
this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
|
||||||
this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
|
this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
|
||||||
this.pruneHttpSamples(this.httpSamples, oneMinuteAgo);
|
this.pruneTimedSamples(this.httpSamples, oneMinuteAgo);
|
||||||
|
|
||||||
const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
|
const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
|
||||||
const commandsLastHour = this.commandTimestamps.length;
|
const commandsLastHour = this.commandTimestamps.length;
|
||||||
@@ -444,6 +524,7 @@ class MetricsCollector {
|
|||||||
const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95);
|
const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95);
|
||||||
const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99);
|
const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99);
|
||||||
const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo);
|
const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo);
|
||||||
|
const internalTasks = this.getTopInternalTasks(oneMinuteAgo);
|
||||||
|
|
||||||
const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
|
const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
|
||||||
const eventLoopAvg = eventLoopSamples.length
|
const eventLoopAvg = eventLoopSamples.length
|
||||||
@@ -532,6 +613,11 @@ class MetricsCollector {
|
|||||||
avg: eventLoopAvg,
|
avg: eventLoopAvg,
|
||||||
max: eventLoopMax,
|
max: eventLoopMax,
|
||||||
p95: eventLoopP95,
|
p95: eventLoopP95,
|
||||||
|
sampleMs: this.eventLoopSampleMs,
|
||||||
|
samples: eventLoopSamples.length,
|
||||||
|
},
|
||||||
|
internal: {
|
||||||
|
tasks: internalTasks,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -558,6 +644,7 @@ class MetricsCollector {
|
|||||||
this.httpSamples = [];
|
this.httpSamples = [];
|
||||||
this.httpRouteSamples.clear();
|
this.httpRouteSamples.clear();
|
||||||
this.eventLoopDelays = [];
|
this.eventLoopDelays = [];
|
||||||
|
this.internalTaskSamples.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { timingSafeEqual } from "crypto";
|
import { timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
let warnedDisableAuthIgnored = false;
|
||||||
|
let loggedAuthDisabledByEnv = false;
|
||||||
|
let loggedAuthDisabledNoToken = false;
|
||||||
|
|
||||||
function safeCompare(a: string, b: string): boolean {
|
function safeCompare(a: string, b: string): boolean {
|
||||||
const bufA = Buffer.from(a);
|
const bufA = Buffer.from(a);
|
||||||
const bufB = Buffer.from(b);
|
const bufB = Buffer.from(b);
|
||||||
@@ -19,16 +23,25 @@ export function isAuthorizedAgentRequest(
|
|||||||
if (disableAuth) {
|
if (disableAuth) {
|
||||||
const nodeEnv = String(process.env.NODE_ENV || "development").toLowerCase();
|
const nodeEnv = String(process.env.NODE_ENV || "development").toLowerCase();
|
||||||
if (nodeEnv === "production") {
|
if (nodeEnv === "production") {
|
||||||
logger.warn("[auth] OVERLORD_DISABLE_AGENT_AUTH is ignored in production mode");
|
if (!warnedDisableAuthIgnored) {
|
||||||
|
warnedDisableAuthIgnored = true;
|
||||||
|
logger.warn("[auth] OVERLORD_DISABLE_AGENT_AUTH is ignored in production mode");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info("[auth] Agent auth explicitly disabled by OVERLORD_DISABLE_AGENT_AUTH=true (non-production mode)");
|
if (!loggedAuthDisabledByEnv) {
|
||||||
|
loggedAuthDisabledByEnv = true;
|
||||||
|
logger.info("[auth] Agent auth explicitly disabled by OVERLORD_DISABLE_AGENT_AUTH=true (non-production mode)");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = agentToken?.trim();
|
const token = agentToken?.trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.info("[auth] Agent auth disabled");
|
if (!loggedAuthDisabledNoToken) {
|
||||||
|
loggedAuthDisabledNoToken = true;
|
||||||
|
logger.info("[auth] Agent auth disabled");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +51,5 @@ export function isAuthorizedAgentRequest(
|
|||||||
(headerToken !== null && safeCompare(headerToken, token)) ||
|
(headerToken !== null && safeCompare(headerToken, token)) ||
|
||||||
(queryToken !== null && safeCompare(queryToken, token));
|
(queryToken !== null && safeCompare(queryToken, token));
|
||||||
|
|
||||||
if (!isAuthed) {
|
|
||||||
logger.info("[auth] Agent auth failed");
|
|
||||||
} else {
|
|
||||||
logger.info("[auth] Agent authenticated successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAuthed;
|
return isAuthed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { getHeartbeatBatchSize } from "./maintenance-loops";
|
||||||
|
|
||||||
|
describe("maintenance heartbeat batching", () => {
|
||||||
|
test("spreads large fleets across the heartbeat interval", () => {
|
||||||
|
expect(getHeartbeatBatchSize(1000, 30_000)).toBe(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps small fleets responsive", () => {
|
||||||
|
expect(getHeartbeatBatchSize(10, 30_000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty fleets", () => {
|
||||||
|
expect(getHeartbeatBatchSize(0, 30_000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
|
import { metrics } from "../metrics";
|
||||||
import { sendPingRequest } from "../wsHandlers";
|
import { sendPingRequest } from "../wsHandlers";
|
||||||
import type { ClientInfo } from "../types";
|
import type { ClientInfo } from "../types";
|
||||||
import { pruneStaleClients } from "./stale-prune";
|
import { pruneStaleClients } from "./stale-prune";
|
||||||
@@ -13,8 +14,30 @@ type StartMaintenanceParams = {
|
|||||||
disconnectTimeoutMs: number;
|
disconnectTimeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function nonNegativeIntEnv(name: string, fallback: number): number {
|
||||||
|
const raw = String(process.env[name] || "").trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_SWEEP_TICK_MS = Math.max(
|
||||||
|
250,
|
||||||
|
nonNegativeIntEnv("OVERLORD_HEARTBEAT_SWEEP_TICK_MS", 1_000),
|
||||||
|
);
|
||||||
|
const HEARTBEAT_BATCH_SIZE = nonNegativeIntEnv("OVERLORD_HEARTBEAT_BATCH_SIZE", 0);
|
||||||
|
|
||||||
|
export function getHeartbeatBatchSize(totalClients: number, heartbeatIntervalMs: number): number {
|
||||||
|
if (totalClients <= 0) return 0;
|
||||||
|
if (HEARTBEAT_BATCH_SIZE > 0) return Math.min(totalClients, HEARTBEAT_BATCH_SIZE);
|
||||||
|
const ticksPerSweep = Math.max(1, Math.floor(heartbeatIntervalMs / HEARTBEAT_SWEEP_TICK_MS));
|
||||||
|
return Math.max(1, Math.ceil(totalClients / ticksPerSweep));
|
||||||
|
}
|
||||||
|
|
||||||
export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
const startedAt = Date.now();
|
||||||
pruneStaleClients({
|
pruneStaleClients({
|
||||||
clients: params.getClients(),
|
clients: params.getClients(),
|
||||||
staleMs: params.staleMs,
|
staleMs: params.staleMs,
|
||||||
@@ -22,6 +45,7 @@ export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
|||||||
setOnlineState: params.setOnlineState,
|
setOnlineState: params.setOnlineState,
|
||||||
deleteClient: params.deleteClient,
|
deleteClient: params.deleteClient,
|
||||||
});
|
});
|
||||||
|
metrics.recordInternalTask("stale-prune", Date.now() - startedAt);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
const livenessTimeoutMs = Math.max(
|
const livenessTimeoutMs = Math.max(
|
||||||
@@ -29,10 +53,23 @@ export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
|||||||
60_000,
|
60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let heartbeatCursor = 0;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
const startedAt = Date.now();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, info] of params.getClients().entries()) {
|
const clients = Array.from(params.getClients().entries()).filter(
|
||||||
if (info.role !== "client") continue;
|
([, info]) => info.role === "client",
|
||||||
|
);
|
||||||
|
if (clients.length === 0) {
|
||||||
|
heartbeatCursor = 0;
|
||||||
|
metrics.recordInternalTask("heartbeat-sweep", Date.now() - startedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (heartbeatCursor >= clients.length) heartbeatCursor = 0;
|
||||||
|
|
||||||
|
const batchSize = getHeartbeatBatchSize(clients.length, params.heartbeatIntervalMs);
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const [id, info] = clients[(heartbeatCursor + i) % clients.length];
|
||||||
const lastActivity = info.lastSeen || 0;
|
const lastActivity = info.lastSeen || 0;
|
||||||
if (lastActivity && now - lastActivity > livenessTimeoutMs) {
|
if (lastActivity && now - lastActivity > livenessTimeoutMs) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -51,5 +88,7 @@ export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
|||||||
logger.debug(`[ping] heartbeat failed for ${id}`, err);
|
logger.debug(`[ping] heartbeat failed for ${id}`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, params.heartbeatIntervalMs);
|
heartbeatCursor = (heartbeatCursor + batchSize) % clients.length;
|
||||||
|
metrics.recordInternalTask("heartbeat-sweep", Date.now() - startedAt);
|
||||||
|
}, HEARTBEAT_SWEEP_TICK_MS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../../db";
|
} from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
|
import { getConfig } from "../../config";
|
||||||
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
|
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
|
||||||
import {
|
import {
|
||||||
canUserAccessClient,
|
canUserAccessClient,
|
||||||
@@ -176,6 +177,12 @@ export async function handleClientRoutes(
|
|||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
req.headers.get("x-overlord-thumbnail-source") === "dashboard" &&
|
||||||
|
getConfig().thumbnails.dashboardEnabled === false
|
||||||
|
) {
|
||||||
|
return Response.json({ ok: false, disabled: true }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
|
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
|
||||||
markThumbnailRequested(clientId);
|
markThumbnailRequested(clientId);
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ async function getGeoip() {
|
|||||||
}
|
}
|
||||||
import { logAudit, AuditAction } from "../../auditLog";
|
import { logAudit, AuditAction } from "../../auditLog";
|
||||||
import * as clientManager from "../../clientManager";
|
import * as clientManager from "../../clientManager";
|
||||||
import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag, computeClientSuspiciousFlags } from "../../db";
|
import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig } from "../../config";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
@@ -18,6 +18,7 @@ import * as sessionManager from "../../sessions/sessionManager";
|
|||||||
import type { SocketData } from "../../sessions/types";
|
import type { SocketData } from "../../sessions/types";
|
||||||
import type { ClientInfo } from "../../types";
|
import type { ClientInfo } from "../../types";
|
||||||
import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
|
import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
|
||||||
|
import { queueClientDbUpdate } from "../../client-db-sync";
|
||||||
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
|
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
|
||||||
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
||||||
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
||||||
@@ -42,19 +43,96 @@ type ClientLifecyclePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PendingOffline = {
|
type PendingOffline = {
|
||||||
timer: ReturnType<typeof setTimeout>;
|
dueAt: number;
|
||||||
|
payload: ClientLifecyclePayload;
|
||||||
|
disconnectReason: string | undefined;
|
||||||
|
disconnectDetail: string | undefined;
|
||||||
|
deps: WsLifecycleDeps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingOffline = new Map<string, PendingOffline>();
|
const pendingOffline = new Map<string, PendingOffline>();
|
||||||
|
let pendingOfflineFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const OFFLINE_BATCH_SIZE = Math.max(
|
||||||
|
1,
|
||||||
|
Number(process.env.OVERLORD_OFFLINE_BATCH_SIZE || 250),
|
||||||
|
);
|
||||||
|
const OFFLINE_BATCH_DELAY_MS = Math.max(
|
||||||
|
1,
|
||||||
|
Number(process.env.OVERLORD_OFFLINE_BATCH_DELAY_MS || 25),
|
||||||
|
);
|
||||||
|
|
||||||
function cancelPendingOffline(clientId: string): boolean {
|
function cancelPendingOffline(clientId: string): boolean {
|
||||||
const pending = pendingOffline.get(clientId);
|
const pending = pendingOffline.get(clientId);
|
||||||
if (!pending) return false;
|
if (!pending) return false;
|
||||||
clearTimeout(pending.timer);
|
|
||||||
pendingOffline.delete(clientId);
|
pendingOffline.delete(clientId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPendingOffline(
|
||||||
|
clientId: string,
|
||||||
|
pending: PendingOffline,
|
||||||
|
offlineUpdates: OfflineStateUpdate[],
|
||||||
|
): void {
|
||||||
|
if (clientManager.hasClient(clientId)) return;
|
||||||
|
pending.deps.notifyDashboardClientEvent("client_offline", pending.payload);
|
||||||
|
pending.deps.broadcastClientEvent("client_offline", pending.payload);
|
||||||
|
pending.deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected");
|
||||||
|
offlineUpdates.push({
|
||||||
|
id: clientId,
|
||||||
|
disconnectReason: pending.disconnectReason,
|
||||||
|
disconnectDetail: pending.disconnectDetail,
|
||||||
|
});
|
||||||
|
pending.deps.notifyDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingOfflineFlush(delayMs: number): void {
|
||||||
|
if (pendingOfflineFlushTimer) return;
|
||||||
|
pendingOfflineFlushTimer = setTimeout(() => {
|
||||||
|
pendingOfflineFlushTimer = null;
|
||||||
|
flushPendingOffline();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingOffline(): void {
|
||||||
|
if (pendingOffline.size === 0) return;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const now = Date.now();
|
||||||
|
let processed = 0;
|
||||||
|
let nextDueAt = Number.POSITIVE_INFINITY;
|
||||||
|
const offlineUpdates: OfflineStateUpdate[] = [];
|
||||||
|
|
||||||
|
for (const [clientId, pending] of pendingOffline) {
|
||||||
|
if (pending.dueAt > now) {
|
||||||
|
if (pending.dueAt < nextDueAt) nextDueAt = pending.dueAt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingOffline.delete(clientId);
|
||||||
|
applyPendingOffline(clientId, pending, offlineUpdates);
|
||||||
|
processed += 1;
|
||||||
|
|
||||||
|
if (processed >= OFFLINE_BATCH_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offlineUpdates.length > 0) {
|
||||||
|
const dbStartedAt = Date.now();
|
||||||
|
setOfflineStates(offlineUpdates);
|
||||||
|
metrics.recordInternalTask("offline-db-flush", Date.now() - dbStartedAt);
|
||||||
|
}
|
||||||
|
metrics.recordInternalTask("offline-flush", Date.now() - startedAt);
|
||||||
|
if (pendingOffline.size === 0) return;
|
||||||
|
if (processed >= OFFLINE_BATCH_SIZE) {
|
||||||
|
schedulePendingOfflineFlush(OFFLINE_BATCH_DELAY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(nextDueAt)) {
|
||||||
|
schedulePendingOfflineFlush(Math.max(1, nextDueAt - Date.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function schedulePendingOffline(
|
function schedulePendingOffline(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
payload: ClientLifecyclePayload,
|
payload: ClientLifecyclePayload,
|
||||||
@@ -65,24 +143,25 @@ function schedulePendingOffline(
|
|||||||
cancelPendingOffline(clientId);
|
cancelPendingOffline(clientId);
|
||||||
|
|
||||||
if (OFFLINE_GRACE_MS <= 0) {
|
if (OFFLINE_GRACE_MS <= 0) {
|
||||||
deps.notifyDashboardClientEvent("client_offline", payload);
|
pendingOffline.set(clientId, {
|
||||||
deps.broadcastClientEvent("client_offline", payload);
|
dueAt: Date.now(),
|
||||||
setOnlineState(clientId, false, disconnectReason, disconnectDetail);
|
payload,
|
||||||
deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected");
|
disconnectReason,
|
||||||
deps.notifyDashboard();
|
disconnectDetail,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
schedulePendingOfflineFlush(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
pendingOffline.set(clientId, {
|
||||||
pendingOffline.delete(clientId);
|
dueAt: Date.now() + OFFLINE_GRACE_MS,
|
||||||
deps.notifyDashboardClientEvent("client_offline", payload);
|
payload,
|
||||||
deps.broadcastClientEvent("client_offline", payload);
|
disconnectReason,
|
||||||
setOnlineState(clientId, false, disconnectReason, disconnectDetail);
|
disconnectDetail,
|
||||||
deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected");
|
deps,
|
||||||
deps.notifyDashboard();
|
});
|
||||||
}, OFFLINE_GRACE_MS);
|
schedulePendingOfflineFlush(OFFLINE_GRACE_MS);
|
||||||
|
|
||||||
pendingOffline.set(clientId, { timer });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingScript = {
|
type PendingScript = {
|
||||||
@@ -263,8 +342,6 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, ENROLLMENT_TIMEOUT_MS);
|
}, ENROLLMENT_TIMEOUT_MS);
|
||||||
enrollmentTimeouts.set(id, timeout);
|
enrollmentTimeouts.set(id, timeout);
|
||||||
|
|
||||||
logger.info(`[open] ${id} role=${role} — purgatory challenge sent`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(
|
export async function handleWebSocketMessage(
|
||||||
@@ -560,7 +637,7 @@ export async function handleWebSocketMessage(
|
|||||||
};
|
};
|
||||||
clientManager.addClient(resolvedId, infoObj);
|
clientManager.addClient(resolvedId, infoObj);
|
||||||
|
|
||||||
upsertClientRow({
|
queueClientDbUpdate({
|
||||||
id: resolvedId,
|
id: resolvedId,
|
||||||
publicKey,
|
publicKey,
|
||||||
keyFingerprint,
|
keyFingerprint,
|
||||||
@@ -644,7 +721,6 @@ export async function handleWebSocketMessage(
|
|||||||
break;
|
break;
|
||||||
case "pong":
|
case "pong":
|
||||||
handlePong(client, payload);
|
handlePong(client, payload);
|
||||||
deps.notifyDashboard();
|
|
||||||
break;
|
break;
|
||||||
case "frame":
|
case "frame":
|
||||||
if ((payload as any)?.header?.fps === 0) {
|
if ((payload as any)?.header?.fps === 0) {
|
||||||
@@ -963,7 +1039,6 @@ export function handleWebSocketClose(
|
|||||||
|
|
||||||
const currentClient = clientManager.getClient(clientId);
|
const currentClient = clientManager.getClient(clientId);
|
||||||
if (currentClient && currentClient.ws !== ws) {
|
if (currentClient && currentClient.ws !== ws) {
|
||||||
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,7 +1069,6 @@ export function handleWebSocketClose(
|
|||||||
deps.hvncStreamingState.delete(clientId);
|
deps.hvncStreamingState.delete(clientId);
|
||||||
deps.webcamStreamingState.delete(clientId);
|
deps.webcamStreamingState.delete(clientId);
|
||||||
stopRemoteDesktopRecording(clientId, "client disconnected");
|
stopRemoteDesktopRecording(clientId, "client disconnected");
|
||||||
logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`);
|
|
||||||
|
|
||||||
if (role === "client" && currentClient) {
|
if (role === "client" && currentClient) {
|
||||||
schedulePendingOffline(
|
schedulePendingOffline(
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ type WsUpgradeDeps = {
|
|||||||
isAuthorizedAgentRequest: (req: Request, url: URL) => boolean;
|
isAuthorizedAgentRequest: (req: Request, url: URL) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WS_RATE_WINDOW_MS = 10_000;
|
function positiveIntEnv(name: string, fallback: number): number {
|
||||||
const WS_RATE_MAX = 30;
|
const raw = String(process.env[name] || "").trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_RATE_WINDOW_MS = positiveIntEnv("OVERLORD_WS_UPGRADE_RATE_WINDOW_MS", 10_000);
|
||||||
|
const WS_RATE_MAX = positiveIntEnv("OVERLORD_WS_UPGRADE_RATE_MAX", 30);
|
||||||
const wsRateMap = new Map<string, { count: number; windowStart: number }>();
|
const wsRateMap = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -238,7 +246,6 @@ export async function handleWsUpgradeRoutes(
|
|||||||
|
|
||||||
const wsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/stream\/ws$/);
|
const wsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/stream\/ws$/);
|
||||||
if (wsMatch) {
|
if (wsMatch) {
|
||||||
logger.info(`[auth] Checking agent authorization for client connection`);
|
|
||||||
if (!deps.isAuthorizedAgentRequest(req, url)) {
|
if (!deps.isAuthorizedAgentRequest(req, url)) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
function positiveIntEnv(name: string, fallback: number): number {
|
||||||
export const STALE_MS = 90_000;
|
const raw = String(process.env[name] || "").trim();
|
||||||
export const DISCONNECT_TIMEOUT_MS = 10_000;
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEARTBEAT_INTERVAL_MS = positiveIntEnv("OVERLORD_HEARTBEAT_INTERVAL_MS", 15_000);
|
||||||
|
export const STALE_MS = positiveIntEnv("OVERLORD_STALE_MS", 90_000);
|
||||||
|
export const DISCONNECT_TIMEOUT_MS = positiveIntEnv("OVERLORD_DISCONNECT_TIMEOUT_MS", 10_000);
|
||||||
|
|
||||||
export const PRUNE_BATCH = Number(process.env.PRUNE_BATCH || 500);
|
export const PRUNE_BATCH = Number(process.env.PRUNE_BATCH || 500);
|
||||||
export const MAX_WS_MESSAGE_BYTES_VIEWER = Number(
|
export const MAX_WS_MESSAGE_BYTES_VIEWER = Number(
|
||||||
|
|||||||
@@ -488,12 +488,37 @@ export function getDashboardSessionCount(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dashboardBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
let dashboardBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const DASHBOARD_DEBOUNCE_MS = 150;
|
let dashboardClientEventTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let dashboardClientEventCount = 0;
|
||||||
|
const DASHBOARD_DEBOUNCE_MS = Math.max(
|
||||||
|
150,
|
||||||
|
Number(process.env.OVERLORD_DASHBOARD_DEBOUNCE_MS || 1000),
|
||||||
|
);
|
||||||
|
const DASHBOARD_CLIENT_EVENT_FLUSH_MS = Math.max(
|
||||||
|
250,
|
||||||
|
Number(process.env.OVERLORD_DASHBOARD_EVENT_FLUSH_MS || 1000),
|
||||||
|
);
|
||||||
|
const DASHBOARD_CLIENT_EVENT_INLINE_LIMIT = Math.max(
|
||||||
|
0,
|
||||||
|
Number(process.env.OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT || 20),
|
||||||
|
);
|
||||||
|
|
||||||
export function notifyDashboardClientEvent(
|
export function notifyDashboardClientEvent(
|
||||||
event: "client_online" | "client_offline" | "client_purgatory",
|
event: "client_online" | "client_offline" | "client_purgatory",
|
||||||
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
||||||
): void {
|
): void {
|
||||||
|
dashboardClientEventCount += 1;
|
||||||
|
if (dashboardClientEventCount > DASHBOARD_CLIENT_EVENT_INLINE_LIMIT) {
|
||||||
|
if (!dashboardClientEventTimer) {
|
||||||
|
dashboardClientEventTimer = setTimeout(() => {
|
||||||
|
dashboardClientEventTimer = null;
|
||||||
|
dashboardClientEventCount = 0;
|
||||||
|
notifyDashboardViewers();
|
||||||
|
}, DASHBOARD_CLIENT_EVENT_FLUSH_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const msg = JSON.stringify({
|
const msg = JSON.stringify({
|
||||||
type: "client_event",
|
type: "client_event",
|
||||||
event,
|
event,
|
||||||
@@ -512,6 +537,13 @@ export function notifyDashboardClientEvent(
|
|||||||
dashboardSessions.delete(id);
|
dashboardSessions.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dashboardClientEventTimer) {
|
||||||
|
dashboardClientEventTimer = setTimeout(() => {
|
||||||
|
dashboardClientEventTimer = null;
|
||||||
|
dashboardClientEventCount = 0;
|
||||||
|
}, DASHBOARD_CLIENT_EVENT_FLUSH_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyDashboardViewers(): void {
|
export function notifyDashboardViewers(): void {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { decodeMessage } from "./protocol";
|
import { decodeMessage } from "./protocol";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { handlePong, sendPingRequest } from "./wsHandlers";
|
import { handlePing, handlePong, sendPingRequest } from "./wsHandlers";
|
||||||
|
|
||||||
type MockWs = { sent: Uint8Array[]; send: (msg: Uint8Array) => void };
|
type MockWs = { sent: Uint8Array[]; send: (msg: Uint8Array) => void };
|
||||||
|
|
||||||
@@ -30,6 +30,29 @@ describe("wsHandlers ping/pong", () => {
|
|||||||
expect(ws.sent.length).toBe(1);
|
expect(ws.sent.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handlePing responds with pong without starting another server ping", () => {
|
||||||
|
const ws: MockWs = {
|
||||||
|
sent: [],
|
||||||
|
send(msg) {
|
||||||
|
this.sent.push(msg);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const info = {
|
||||||
|
id: "client-ping",
|
||||||
|
role: "client",
|
||||||
|
ws,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
handlePing(info, { type: "ping", ts: 9876 } as any, ws);
|
||||||
|
|
||||||
|
expect(ws.sent.length).toBe(1);
|
||||||
|
const payload = decodeMessage(ws.sent[0]) as any;
|
||||||
|
expect(payload.type).toBe("pong");
|
||||||
|
expect(payload.ts).toBe(9876);
|
||||||
|
expect(info.lastPingNonce).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("handlePong clears nonce and records ping", () => {
|
test("handlePong clears nonce and records ping", () => {
|
||||||
metrics.reset();
|
metrics.reset();
|
||||||
const ws: MockWs = {
|
const ws: MockWs = {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
notifyThumbnailGenerated,
|
notifyThumbnailGenerated,
|
||||||
requestThumbnailRegen,
|
requestThumbnailRegen,
|
||||||
} from "./thumbnails";
|
} from "./thumbnails";
|
||||||
import { upsertClientRow } from "./db";
|
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { markClientDbSynced, queueClientDbUpdate, shouldSyncClientToDb } from "./client-db-sync";
|
import { markClientDbSynced, queueClientDbUpdate, shouldSyncClientToDb } from "./client-db-sync";
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ export async function handleHello(
|
|||||||
info.lastSeen = Date.now();
|
info.lastSeen = Date.now();
|
||||||
info.online = true;
|
info.online = true;
|
||||||
|
|
||||||
upsertClientRow({
|
queueClientDbUpdate({
|
||||||
id: info.id,
|
id: info.id,
|
||||||
hwid: info.hwid,
|
hwid: info.hwid,
|
||||||
role: info.role,
|
role: info.role,
|
||||||
@@ -166,7 +165,6 @@ export function handlePing(info: ClientInfo, payload: Ping, ws: any) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() }));
|
ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() }));
|
||||||
sendPingRequest(info, ws, "client_ping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendPingRequest(info: ClientInfo, ws: any, reason: string) {
|
export function sendPingRequest(info: ClientInfo, ws: any, reason: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user