fixed dashboard cards, add fuse js, fix load times, fix csp in scripts, fix remote desktop not working first try.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"@solana/web3.js": "^1.98.4",
|
"@solana/web3.js": "^1.98.4",
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"ansi-to-html": "0.7.2",
|
"ansi-to-html": "0.7.2",
|
||||||
|
"fuse.js": "^7.4.1",
|
||||||
"geoip-lite": "latest",
|
"geoip-lite": "latest",
|
||||||
"gridstack": "^12.6.0",
|
"gridstack": "^12.6.0",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
@@ -311,6 +312,8 @@
|
|||||||
|
|
||||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"fuse.js": ["fuse.js@7.4.1", "", {}, "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw=="],
|
||||||
|
|
||||||
"geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="],
|
"geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="],
|
||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@solana/web3.js": "^1.98.4",
|
"@solana/web3.js": "^1.98.4",
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"ansi-to-html": "0.7.2",
|
"ansi-to-html": "0.7.2",
|
||||||
|
"fuse.js": "^7.4.1",
|
||||||
"geoip-lite": "latest",
|
"geoip-lite": "latest",
|
||||||
"gridstack": "^12.6.0",
|
"gridstack": "^12.6.0",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ function close() {
|
|||||||
function navigate(href, newTab = false) {
|
function navigate(href, newTab = false) {
|
||||||
pushRecent(href);
|
pushRecent(href);
|
||||||
if (newTab) window.open(href, "_blank", "noopener");
|
if (newTab) window.open(href, "_blank", "noopener");
|
||||||
|
else if (window.overlordSoftNavigate) window.overlordSoftNavigate(href);
|
||||||
else window.location.href = href;
|
else window.location.href = href;
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const DEFAULT_GRID_LAYOUT = {
|
|||||||
};
|
};
|
||||||
let statsGrid = null;
|
let statsGrid = null;
|
||||||
|
|
||||||
|
function teardownDashboardStats() {
|
||||||
|
if (metricsTimer) clearInterval(metricsTimer);
|
||||||
|
metricsTimer = null;
|
||||||
|
onlineChart?.destroy?.();
|
||||||
|
osChart?.destroy?.();
|
||||||
|
onlineChart = null;
|
||||||
|
osChart = null;
|
||||||
|
statsGrid?.destroy?.(false);
|
||||||
|
statsGrid = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
const palette = {
|
const palette = {
|
||||||
text: "#cbd5e1",
|
text: "#cbd5e1",
|
||||||
muted: "#94a3b8",
|
muted: "#94a3b8",
|
||||||
@@ -629,6 +641,8 @@ async function fetchDashboardMetrics() {
|
|||||||
export function initDashboardStats() {
|
export function initDashboardStats() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
window.removeEventListener("pagehide", teardownDashboardStats);
|
||||||
|
window.addEventListener("pagehide", teardownDashboardStats);
|
||||||
if (!makeCharts()) {
|
if (!makeCharts()) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (makeCharts()) applyHeightMode(getSavedHeight());
|
if (makeCharts()) applyHeightMode(getSavedHeight());
|
||||||
|
|||||||
@@ -14,6 +14,29 @@ let dashboardWsConnected = false;
|
|||||||
let wsReconnectTimer = null;
|
let wsReconnectTimer = null;
|
||||||
const manuallyDisconnecting = new Set();
|
const manuallyDisconnecting = new Set();
|
||||||
|
|
||||||
|
function stopDashboardRealtime() {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
clearTimeout(wsReconnectTimer);
|
||||||
|
wsReconnectTimer = null;
|
||||||
|
if (dashboardWs) {
|
||||||
|
try {
|
||||||
|
dashboardWs.onopen = null;
|
||||||
|
dashboardWs.onmessage = null;
|
||||||
|
dashboardWs.onerror = null;
|
||||||
|
dashboardWs.onclose = null;
|
||||||
|
dashboardWs.close(1000, "page hidden");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
dashboardWs = null;
|
||||||
|
dashboardWsConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDashboardPagehideCleanup() {
|
||||||
|
window.removeEventListener("pagehide", stopDashboardRealtime);
|
||||||
|
window.addEventListener("pagehide", stopDashboardRealtime);
|
||||||
|
}
|
||||||
|
|
||||||
function moveClientCardImmediately(msg) {
|
function moveClientCardImmediately(msg) {
|
||||||
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
|
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
|
||||||
if (!clientId) return;
|
if (!clientId) return;
|
||||||
@@ -243,6 +266,7 @@ function adjustPollingForWs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
||||||
|
bindDashboardPagehideCleanup();
|
||||||
connectDashboardWs();
|
connectDashboardWs();
|
||||||
const effectiveInterval = dashboardWsConnected
|
const effectiveInterval = dashboardWsConnected
|
||||||
? FALLBACK_POLL_MS
|
? FALLBACK_POLL_MS
|
||||||
|
|||||||
@@ -1828,6 +1828,35 @@ mark.search-current {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
background: rgba(2, 6, 23, 0.92);
|
||||||
|
border-bottom: 1px solid rgba(30, 41, 59, 0.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#top-nav:empty::before {
|
||||||
|
content: "Overlord";
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.nav-pre-sidebar #top-nav:empty::before {
|
||||||
|
align-items: flex-start;
|
||||||
|
height: auto;
|
||||||
|
padding: 18px 14px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before {
|
||||||
|
content: "O";
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#top-nav nav a {
|
#top-nav nav a {
|
||||||
@@ -4515,7 +4544,10 @@ body.sb-ready #top-nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats-content {
|
.dashboard-stats-content {
|
||||||
|
height: calc(var(--dashboard-stats-height) + 12px);
|
||||||
min-height: calc(var(--dashboard-stats-height) + 12px);
|
min-height: calc(var(--dashboard-stats-height) + 12px);
|
||||||
|
contain: layout paint;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats-shell.is-collapsed .dashboard-stats-content,
|
.dashboard-stats-shell.is-collapsed .dashboard-stats-content,
|
||||||
|
|||||||
@@ -12,13 +12,19 @@ import { createAdaptiveNavController } from "./nav/layout.js";
|
|||||||
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
|
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
|
||||||
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||||
import { init as initCommandPalette } from "./command-palette.js";
|
import { init as initCommandPalette } from "./command-palette.js";
|
||||||
|
import {
|
||||||
|
installPageResourceTracker,
|
||||||
|
runWithoutPageTracking,
|
||||||
|
setupSoftNavigation,
|
||||||
|
startPageTracking,
|
||||||
|
} from "./soft-nav.js";
|
||||||
|
|
||||||
const host = document.getElementById("top-nav");
|
const host = document.getElementById("top-nav");
|
||||||
if (host) {
|
if (host) {
|
||||||
const refs = mountNav(host);
|
const refs = mountNav(host);
|
||||||
initCommandPalette();
|
initCommandPalette();
|
||||||
import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => {
|
import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => {
|
||||||
showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host);
|
runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host));
|
||||||
});
|
});
|
||||||
const { applyAdaptiveNavLayout, navHide } = createAdaptiveNavController(host, refs);
|
const { applyAdaptiveNavLayout, navHide } = createAdaptiveNavController(host, refs);
|
||||||
|
|
||||||
@@ -44,13 +50,18 @@ if (host) {
|
|||||||
"/purgatory": "enrollment-link",
|
"/purgatory": "enrollment-link",
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPluginNavItems(activeMap).then(() => {
|
const applyActivePath = (nextPath = window.location.pathname) => {
|
||||||
const activeId = activeMap[path];
|
host.querySelectorAll(".nav-active").forEach((el) => el.classList.remove("nav-active"));
|
||||||
|
host.querySelectorAll(".sb-group-btn[aria-expanded='true']").forEach((btn) => btn.setAttribute("aria-expanded", "false"));
|
||||||
|
host.querySelectorAll(".sb-group-children.sb-group-open").forEach((children) => children.classList.remove("sb-group-open"));
|
||||||
|
host.querySelectorAll(".sb-chevron.sb-chevron-open").forEach((chevron) => chevron.classList.remove("sb-chevron-open"));
|
||||||
|
refs.accountSettingsBtn?.classList.remove("ring-1", "ring-sky-500/60", "bg-slate-700");
|
||||||
|
|
||||||
|
const activeId = activeMap[nextPath];
|
||||||
if (activeId) {
|
if (activeId) {
|
||||||
const el = document.getElementById(activeId);
|
const el = document.getElementById(activeId);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.classList.add("nav-active");
|
el.classList.add("nav-active");
|
||||||
// Also expand the parent sidebar group if applicable
|
|
||||||
const group = el.closest(".sb-group");
|
const group = el.closest(".sb-group");
|
||||||
if (group) {
|
if (group) {
|
||||||
const btn = group.querySelector(".sb-group-btn");
|
const btn = group.querySelector(".sb-group-btn");
|
||||||
@@ -62,6 +73,14 @@ if (host) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextPath === "/settings" && refs.accountSettingsBtn) {
|
||||||
|
refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPluginNavItems(activeMap).then(() => {
|
||||||
|
applyActivePath(path);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) {
|
if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) {
|
||||||
@@ -91,14 +110,14 @@ if (host) {
|
|||||||
if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) {
|
if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) {
|
||||||
refs.accountSettingsBtn.dataset.boundSettings = "true";
|
refs.accountSettingsBtn.dataset.boundSettings = "true";
|
||||||
refs.accountSettingsBtn.addEventListener("click", () => {
|
refs.accountSettingsBtn.addEventListener("click", () => {
|
||||||
window.location.href = "/settings";
|
if (window.overlordSoftNavigate) {
|
||||||
|
window.overlordSoftNavigate("/settings");
|
||||||
|
} else {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/settings" && refs.accountSettingsBtn) {
|
|
||||||
refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateToggle = () => {
|
const updateToggle = () => {
|
||||||
const enabled = getNotificationsEnabled();
|
const enabled = getNotificationsEnabled();
|
||||||
if (refs.notifyToggle) {
|
if (refs.notifyToggle) {
|
||||||
@@ -139,6 +158,32 @@ if (host) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prefetchedPages = new Set();
|
||||||
|
const prefetchPage = (href) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.href);
|
||||||
|
if (url.origin !== window.location.origin) return;
|
||||||
|
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||||
|
const key = `${url.pathname}${url.search}`;
|
||||||
|
if (prefetchedPages.has(key)) return;
|
||||||
|
prefetchedPages.add(key);
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "prefetch";
|
||||||
|
link.as = "document";
|
||||||
|
link.href = key;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
host.addEventListener("pointerenter", (event) => {
|
||||||
|
const link = event.target?.closest?.("a[href]");
|
||||||
|
if (link) prefetchPage(link.getAttribute("href"));
|
||||||
|
}, true);
|
||||||
|
host.addEventListener("focusin", (event) => {
|
||||||
|
const link = event.target?.closest?.("a[href]");
|
||||||
|
if (link) prefetchPage(link.getAttribute("href"));
|
||||||
|
});
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
@@ -178,19 +223,25 @@ if (host) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import("./chat-widget.js").then((chatWidget) => {
|
import("./chat-widget.js").then((chatWidget) => {
|
||||||
chatWidget.init();
|
runWithoutPageTracking(() => {
|
||||||
|
chatWidget.init();
|
||||||
|
|
||||||
if (chatWidget.isHidden() && refs.navUtility) {
|
if (chatWidget.isHidden() && refs.navUtility) {
|
||||||
const restoreBtn = document.createElement("button");
|
const restoreBtn = document.createElement("button");
|
||||||
restoreBtn.id = "chat-restore-btn";
|
restoreBtn.id = "chat-restore-btn";
|
||||||
restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors";
|
restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors";
|
||||||
restoreBtn.title = "Show team chat";
|
restoreBtn.title = "Show team chat";
|
||||||
restoreBtn.innerHTML = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
|
restoreBtn.innerHTML = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
|
||||||
restoreBtn.addEventListener("click", () => {
|
restoreBtn.addEventListener("click", () => {
|
||||||
chatWidget.show();
|
chatWidget.show();
|
||||||
restoreBtn.remove();
|
restoreBtn.remove();
|
||||||
});
|
});
|
||||||
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
|
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
installPageResourceTracker();
|
||||||
|
setupSoftNavigation(host, { onPathChange: applyActivePath });
|
||||||
|
startPageTracking();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
let clientOs = "";
|
let clientOs = "";
|
||||||
let clientIsAdmin = false;
|
let clientIsAdmin = false;
|
||||||
let firewallWarningAcked = false;
|
let firewallWarningAcked = false;
|
||||||
|
let firstFrameLogged = false;
|
||||||
|
|
||||||
|
function rdDebug(label, data = {}) {
|
||||||
|
try {
|
||||||
|
console.debug(`rd: ${label} ${JSON.stringify(data)}`);
|
||||||
|
} catch {
|
||||||
|
console.debug(`rd: ${label}`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetH264RuntimeState() {
|
function resetH264RuntimeState() {
|
||||||
h264TimestampUs = 0;
|
h264TimestampUs = 0;
|
||||||
@@ -730,6 +739,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
|
|
||||||
function handleStatus(msg) {
|
function handleStatus(msg) {
|
||||||
if (!msg || msg.type !== "status" || !msg.status) return;
|
if (!msg || msg.type !== "status" || !msg.status) return;
|
||||||
|
rdDebug("status", {
|
||||||
|
status: msg.status,
|
||||||
|
reason: msg.reason || "",
|
||||||
|
desiredStreaming,
|
||||||
|
streamState,
|
||||||
|
lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null,
|
||||||
|
sessionId: msg.sessionId || "",
|
||||||
|
});
|
||||||
if (msg.status === "offline") {
|
if (msg.status === "offline") {
|
||||||
scheduleOffline(msg.reason);
|
scheduleOffline(msg.reason);
|
||||||
return;
|
return;
|
||||||
@@ -880,10 +897,26 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ws.readyState !== WebSocket.OPEN) {
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
|
rdDebug("send skipped websocket not open", {
|
||||||
|
type,
|
||||||
|
readyState: ws.readyState,
|
||||||
|
streamState,
|
||||||
|
desiredStreaming,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = { type, ...payload };
|
const msg = { type, ...payload };
|
||||||
console.debug("rd: send", msg);
|
rdDebug("send", {
|
||||||
|
msg,
|
||||||
|
readyState: ws.readyState,
|
||||||
|
streamState,
|
||||||
|
desiredStreaming,
|
||||||
|
display: displaySelect?.value ?? "",
|
||||||
|
quality: qualitySlider?.value ?? "",
|
||||||
|
resolution: resolutionSelect?.value ?? "",
|
||||||
|
transport: getWebrtcMode(),
|
||||||
|
lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null,
|
||||||
|
});
|
||||||
ws.send(encodeMsgpack(msg));
|
ws.send(encodeMsgpack(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1054,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
const res = await fetch("/api/clients");
|
const res = await fetch("/api/clients");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const client = data.items.find((c) => c.id === activeClientId);
|
const client = data.items.find((c) => c.id === activeClientId);
|
||||||
|
rdDebug("client info", {
|
||||||
|
found: !!client,
|
||||||
|
monitors: client?.monitors,
|
||||||
|
monitorInfo: client?.monitorInfo,
|
||||||
|
os: client?.os || "",
|
||||||
|
isAdmin: !!client?.isAdmin,
|
||||||
|
});
|
||||||
if (client) {
|
if (client) {
|
||||||
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
|
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
|
||||||
clientOs = (client.os || "").toLowerCase();
|
clientOs = (client.os || "").toLowerCase();
|
||||||
@@ -1115,6 +1155,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
|
|
||||||
startBtn.addEventListener("click", function () {
|
startBtn.addEventListener("click", function () {
|
||||||
const mode = getWebrtcMode();
|
const mode = getWebrtcMode();
|
||||||
|
rdDebug("start click", {
|
||||||
|
mode,
|
||||||
|
wsReadyState: ws.readyState,
|
||||||
|
streamState,
|
||||||
|
desiredStreaming,
|
||||||
|
display: displaySelect?.value ?? "",
|
||||||
|
quality: qualitySlider?.value ?? "",
|
||||||
|
resolution: resolutionSelect?.value ?? "",
|
||||||
|
prefersH264,
|
||||||
|
duplication: !!duplicationCtrl?.checked,
|
||||||
|
clientOs,
|
||||||
|
clientIsAdmin,
|
||||||
|
});
|
||||||
if (needsWebrtcFirewallWarning(mode)) {
|
if (needsWebrtcFirewallWarning(mode)) {
|
||||||
if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) {
|
if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) {
|
||||||
return;
|
return;
|
||||||
@@ -1132,6 +1185,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
pushResolution();
|
pushResolution();
|
||||||
desiredStreaming = true;
|
desiredStreaming = true;
|
||||||
lastFrameAt = 0;
|
lastFrameAt = 0;
|
||||||
|
firstFrameLogged = false;
|
||||||
resetH264SessionState();
|
resetH264SessionState();
|
||||||
setStreamState("starting", "Starting stream");
|
setStreamState("starting", "Starting stream");
|
||||||
if (mode === "relayed") {
|
if (mode === "relayed") {
|
||||||
@@ -1267,6 +1321,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
function markFrameReceived() {
|
function markFrameReceived() {
|
||||||
lastFrameAt = performance.now();
|
lastFrameAt = performance.now();
|
||||||
clearOfflineTimer();
|
clearOfflineTimer();
|
||||||
|
if (!firstFrameLogged) {
|
||||||
|
firstFrameLogged = true;
|
||||||
|
rdDebug("first frame received", {
|
||||||
|
streamState,
|
||||||
|
desiredStreaming,
|
||||||
|
canvas: { width: canvas.width, height: canvas.height },
|
||||||
|
transport: getWebrtcMode(),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (streamState !== "streaming" && desiredStreaming) {
|
if (streamState !== "streaming" && desiredStreaming) {
|
||||||
setStreamState("streaming", "Streaming");
|
setStreamState("streaming", "Streaming");
|
||||||
}
|
}
|
||||||
@@ -1700,6 +1763,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("open", function () {
|
ws.addEventListener("open", function () {
|
||||||
|
rdDebug("ws open", {
|
||||||
|
url: ws.url,
|
||||||
|
readyState: ws.readyState,
|
||||||
|
clientId,
|
||||||
|
});
|
||||||
if (qualitySlider) {
|
if (qualitySlider) {
|
||||||
pushQuality(qualitySlider.value);
|
pushQuality(qualitySlider.value);
|
||||||
}
|
}
|
||||||
@@ -1718,7 +1786,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("close", function () {
|
ws.addEventListener("close", function (event) {
|
||||||
|
rdDebug("ws close", {
|
||||||
|
code: event.code,
|
||||||
|
reason: event.reason,
|
||||||
|
wasClean: event.wasClean,
|
||||||
|
streamState,
|
||||||
|
desiredStreaming,
|
||||||
|
});
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
@@ -1729,7 +1804,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
setStreamState("disconnected", "Disconnected");
|
setStreamState("disconnected", "Disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("error", function () {
|
ws.addEventListener("error", function (event) {
|
||||||
|
console.warn("rd: ws error", event);
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
stopAllWebrtc();
|
stopAllWebrtc();
|
||||||
setStreamState("error", "WebSocket error");
|
setStreamState("error", "WebSocket error");
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
let installedTracker = false;
|
||||||
|
let pageTrackingEnabled = false;
|
||||||
|
let navSeq = 0;
|
||||||
|
|
||||||
|
const tracked = {
|
||||||
|
events: new Set(),
|
||||||
|
intervals: new Set(),
|
||||||
|
sockets: new Set(),
|
||||||
|
workers: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const original = {};
|
||||||
|
const loadedClassicScripts = new Set();
|
||||||
|
const persistentIds = new Set([
|
||||||
|
"top-nav",
|
||||||
|
"sb-mobile-bar",
|
||||||
|
"sb-backdrop",
|
||||||
|
"nav-reveal-btn",
|
||||||
|
"chat-bubble",
|
||||||
|
"chat-panel",
|
||||||
|
"cert-trust-banner",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeUrl(value, base = window.location.href) {
|
||||||
|
try {
|
||||||
|
return new URL(value, base).href;
|
||||||
|
} catch {
|
||||||
|
return value || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptPath(value) {
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.href).pathname;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipScript(script) {
|
||||||
|
const path = scriptPath(script.getAttribute("src") || "");
|
||||||
|
return path === "/assets/nav.js" || path === "/assets/nav-prelude.js";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPersistentNode(node) {
|
||||||
|
return node instanceof Element && persistentIds.has(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTrackedResources() {
|
||||||
|
pageTrackingEnabled = false;
|
||||||
|
window.dispatchEvent(new Event("pagehide"));
|
||||||
|
|
||||||
|
for (const id of tracked.intervals) original.clearInterval(id);
|
||||||
|
for (const entry of tracked.events) {
|
||||||
|
original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options);
|
||||||
|
}
|
||||||
|
for (const socket of tracked.sockets) {
|
||||||
|
try {
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close(1000, "soft navigation");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const worker of tracked.workers) {
|
||||||
|
try {
|
||||||
|
worker.terminate();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked.events.clear();
|
||||||
|
tracked.intervals.clear();
|
||||||
|
tracked.sockets.clear();
|
||||||
|
tracked.workers.clear();
|
||||||
|
|
||||||
|
document.querySelectorAll("script[data-soft-nav-script]").forEach((script) => script.remove());
|
||||||
|
document.getElementById("cmdp-root")?.remove();
|
||||||
|
pageTrackingEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWithoutPageTracking(fn) {
|
||||||
|
const previous = pageTrackingEnabled;
|
||||||
|
pageTrackingEnabled = false;
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
if (result && typeof result.then === "function") {
|
||||||
|
return result.finally(() => {
|
||||||
|
pageTrackingEnabled = previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pageTrackingEnabled = previous;
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
pageTrackingEnabled = previous;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPageTracking() {
|
||||||
|
pageTrackingEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installPageResourceTracker() {
|
||||||
|
if (installedTracker) return;
|
||||||
|
installedTracker = true;
|
||||||
|
|
||||||
|
document.querySelectorAll("script[src]").forEach((script) => {
|
||||||
|
const src = script.getAttribute("src");
|
||||||
|
if (src && script.type !== "module") loadedClassicScripts.add(normalizeUrl(src));
|
||||||
|
});
|
||||||
|
|
||||||
|
original.addEventListener = EventTarget.prototype.addEventListener;
|
||||||
|
original.removeEventListener = EventTarget.prototype.removeEventListener;
|
||||||
|
original.setInterval = window.setInterval;
|
||||||
|
original.clearInterval = window.clearInterval;
|
||||||
|
original.WebSocket = window.WebSocket;
|
||||||
|
original.Worker = window.Worker;
|
||||||
|
|
||||||
|
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
||||||
|
if (pageTrackingEnabled && listener) {
|
||||||
|
tracked.events.add({ target: this, type, listener, options });
|
||||||
|
}
|
||||||
|
return original.addEventListener.call(this, type, listener, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
EventTarget.prototype.removeEventListener = function (type, listener, options) {
|
||||||
|
for (const entry of tracked.events) {
|
||||||
|
if (entry.target === this && entry.type === type && entry.listener === listener) {
|
||||||
|
tracked.events.delete(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return original.removeEventListener.call(this, type, listener, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setInterval = function (...args) {
|
||||||
|
const id = original.setInterval.apply(window, args);
|
||||||
|
if (pageTrackingEnabled) tracked.intervals.add(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearInterval = function (id) {
|
||||||
|
tracked.intervals.delete(id);
|
||||||
|
return original.clearInterval.call(window, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof original.WebSocket === "function") {
|
||||||
|
window.WebSocket = function (...args) {
|
||||||
|
const socket = new original.WebSocket(...args);
|
||||||
|
if (pageTrackingEnabled) tracked.sockets.add(socket);
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
window.WebSocket.prototype = original.WebSocket.prototype;
|
||||||
|
Object.defineProperty(window.WebSocket, "CONNECTING", { value: original.WebSocket.CONNECTING });
|
||||||
|
Object.defineProperty(window.WebSocket, "OPEN", { value: original.WebSocket.OPEN });
|
||||||
|
Object.defineProperty(window.WebSocket, "CLOSING", { value: original.WebSocket.CLOSING });
|
||||||
|
Object.defineProperty(window.WebSocket, "CLOSED", { value: original.WebSocket.CLOSED });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof original.Worker === "function") {
|
||||||
|
window.Worker = function (...args) {
|
||||||
|
const worker = new original.Worker(...args);
|
||||||
|
if (pageTrackingEnabled) tracked.workers.add(worker);
|
||||||
|
return worker;
|
||||||
|
};
|
||||||
|
window.Worker.prototype = original.Worker.prototype;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHead(nextDoc, baseUrl) {
|
||||||
|
document.title = nextDoc.title || document.title;
|
||||||
|
|
||||||
|
const currentHtmlClasses = Array.from(document.documentElement.classList).filter((name) => (
|
||||||
|
name.startsWith("nav-pre") || name === "dark"
|
||||||
|
));
|
||||||
|
document.documentElement.className = nextDoc.documentElement.className || "";
|
||||||
|
currentHtmlClasses.forEach((name) => document.documentElement.classList.add(name));
|
||||||
|
|
||||||
|
nextDoc.head.querySelectorAll("link").forEach((link) => {
|
||||||
|
const href = link.getAttribute("href");
|
||||||
|
const rel = link.getAttribute("rel") || "";
|
||||||
|
if (!href) return;
|
||||||
|
const normalized = normalizeUrl(href, baseUrl);
|
||||||
|
const exists = Array.from(document.head.querySelectorAll("link")).some((existing) => (
|
||||||
|
(existing.getAttribute("rel") || "") === rel
|
||||||
|
&& normalizeUrl(existing.getAttribute("href") || "") === normalized
|
||||||
|
));
|
||||||
|
if (!exists) {
|
||||||
|
const clone = link.cloneNode(true);
|
||||||
|
clone.setAttribute("href", normalized);
|
||||||
|
document.head.appendChild(clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextDoc.head.querySelectorAll("style").forEach((style) => {
|
||||||
|
const text = style.textContent || "";
|
||||||
|
const exists = Array.from(document.head.querySelectorAll("style[data-soft-nav-style]"))
|
||||||
|
.some((existing) => existing.textContent === text);
|
||||||
|
if (!exists) {
|
||||||
|
const clone = style.cloneNode(true);
|
||||||
|
clone.dataset.softNavStyle = "true";
|
||||||
|
document.head.appendChild(clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBody(nextDoc) {
|
||||||
|
const preserved = Array.from(document.body.children).filter(isPersistentNode);
|
||||||
|
const nav = document.getElementById("top-nav");
|
||||||
|
const anchor = document.getElementById("cert-trust-banner")
|
||||||
|
|| document.getElementById("sb-mobile-bar")
|
||||||
|
|| nav;
|
||||||
|
const preservedClasses = Array.from(document.body.classList).filter((name) => (
|
||||||
|
name.startsWith("sb-") || name === "nav-hidden"
|
||||||
|
));
|
||||||
|
|
||||||
|
Array.from(document.body.children).forEach((node) => {
|
||||||
|
if (!preserved.includes(node)) node.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.className = nextDoc.body.className || "";
|
||||||
|
preservedClasses.forEach((name) => document.body.classList.add(name));
|
||||||
|
|
||||||
|
let insertAfter = anchor;
|
||||||
|
Array.from(nextDoc.body.children).forEach((node) => {
|
||||||
|
if (node.matches?.("script") || node.id === "top-nav") return;
|
||||||
|
const clone = document.importNode(node, true);
|
||||||
|
if (insertAfter?.parentNode) {
|
||||||
|
insertAfter.parentNode.insertBefore(clone, insertAfter.nextSibling);
|
||||||
|
insertAfter = clone;
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
insertAfter = clone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendScript(script, baseUrl) {
|
||||||
|
if (shouldSkipScript(script)) return Promise.resolve();
|
||||||
|
|
||||||
|
const src = script.getAttribute("src");
|
||||||
|
const type = script.getAttribute("type") || "";
|
||||||
|
if (src && type !== "module") {
|
||||||
|
const normalized = normalizeUrl(src, baseUrl);
|
||||||
|
if (loadedClassicScripts.has(normalized)) return Promise.resolve();
|
||||||
|
loadedClassicScripts.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const next = document.createElement("script");
|
||||||
|
for (const { name, value } of script.attributes) {
|
||||||
|
if (name !== "src") next.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
next.dataset.softNavScript = "true";
|
||||||
|
next.onload = () => resolve();
|
||||||
|
next.onerror = () => reject(new Error(`Failed to load ${src || "inline script"}`));
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
const url = new URL(src, baseUrl);
|
||||||
|
if (type === "module" && url.origin === window.location.origin) {
|
||||||
|
url.searchParams.set("softNav", String(navSeq));
|
||||||
|
}
|
||||||
|
next.src = url.href;
|
||||||
|
} else {
|
||||||
|
next.textContent = script.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(next);
|
||||||
|
if (!src) resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPageScripts(nextDoc, baseUrl) {
|
||||||
|
const scripts = Array.from(nextDoc.querySelectorAll("script"));
|
||||||
|
for (const script of scripts) {
|
||||||
|
await appendScript(script, baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshGlobalEnhancements() {
|
||||||
|
if (typeof window.addRippleEffect === "function") {
|
||||||
|
document
|
||||||
|
.querySelectorAll("button:not(.no-ripple), .button:not(.no-ripple)")
|
||||||
|
.forEach((button) => window.addRippleEffect(button));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSoftNavigationCandidate(url, link) {
|
||||||
|
if (url.origin !== window.location.origin) return false;
|
||||||
|
if (link?.target && link.target !== "_self") return false;
|
||||||
|
if (link?.hasAttribute("download")) return false;
|
||||||
|
if (url.pathname.startsWith("/api/")) return false;
|
||||||
|
if (url.pathname.includes(".")) return false;
|
||||||
|
if (url.pathname === window.location.pathname && url.search === window.location.search) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSoftNavigation(host, { onPathChange } = {}) {
|
||||||
|
if (!host || host.dataset.softNavReady === "true") return;
|
||||||
|
host.dataset.softNavReady = "true";
|
||||||
|
|
||||||
|
async function navigateTo(href, { replace = false } = {}) {
|
||||||
|
const url = new URL(href, window.location.href);
|
||||||
|
if (!isSoftNavigationCandidate(url)) {
|
||||||
|
window.location.href = url.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navSeq += 1;
|
||||||
|
document.body.classList.add("soft-nav-loading");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.href, { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error(`Navigation failed: ${res.status}`);
|
||||||
|
const html = await res.text();
|
||||||
|
const nextDoc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
if (!nextDoc.getElementById("top-nav")) {
|
||||||
|
window.location.href = url.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTrackedResources();
|
||||||
|
mergeHead(nextDoc, url.href);
|
||||||
|
replaceBody(nextDoc);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
if (replace) history.replaceState({}, "", url.href);
|
||||||
|
else history.pushState({}, "", url.href);
|
||||||
|
onPathChange?.(url.pathname);
|
||||||
|
await runPageScripts(nextDoc, url.href);
|
||||||
|
refreshGlobalEnhancements();
|
||||||
|
window.dispatchEvent(new CustomEvent("overlord:soft-nav", { detail: { path: url.pathname } }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Soft navigation failed:", err);
|
||||||
|
window.location.href = url.href;
|
||||||
|
} finally {
|
||||||
|
document.body.classList.remove("soft-nav-loading");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host.addEventListener("click", (event) => {
|
||||||
|
const link = event.target?.closest?.("a[href]");
|
||||||
|
if (!link) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
||||||
|
|
||||||
|
const url = new URL(link.getAttribute("href"), window.location.href);
|
||||||
|
if (!isSoftNavigationCandidate(url, link)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
navigateTo(url.href);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
navigateTo(window.location.href, { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.overlordSoftNavigate = navigateTo;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
|
import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
|
||||||
|
|
||||||
const createdClientIds: string[] = [];
|
const createdClientIds: string[] = [];
|
||||||
|
|
||||||
@@ -110,4 +110,33 @@ describe("client list ordering", () => {
|
|||||||
cleanupCreatedClients();
|
cleanupCreatedClients();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
test("search tolerates typos across client metadata", () => {
|
||||||
|
try {
|
||||||
|
const prefix = `fuse-${Date.now().toString(36)}`;
|
||||||
|
const id = `${prefix}-finance-terminal`;
|
||||||
|
|
||||||
|
createTempClient(id, {
|
||||||
|
online: true,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
host: "finance-terminal",
|
||||||
|
});
|
||||||
|
setClientTag(id, "Payroll Workstation", "Quarterly reporting machine");
|
||||||
|
|
||||||
|
const result = listClients({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
search: "payrol workstaton",
|
||||||
|
sort: "last_seen_desc",
|
||||||
|
statusFilter: "all",
|
||||||
|
osFilter: "all",
|
||||||
|
countryFilter: "all",
|
||||||
|
enrollmentFilter: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items.some((item) => item.id === id)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
cleanupCreatedClients();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
|
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
import { getThumbnailSummaries } from "../thumbnails";
|
import { getThumbnailSummaries } from "../thumbnails";
|
||||||
import { db, dbPath } from "./connection";
|
import { db, dbPath } from "./connection";
|
||||||
import "./schema";
|
import "./schema";
|
||||||
@@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags(
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLIENT_SEARCH_KEYS = [
|
||||||
|
{ name: "nickname", weight: 0.32 },
|
||||||
|
{ name: "host", weight: 0.28 },
|
||||||
|
{ name: "user", weight: 0.18 },
|
||||||
|
{ name: "id", weight: 0.18 },
|
||||||
|
{ name: "customTag", weight: 0.2 },
|
||||||
|
{ name: "customTagNote", weight: 0.1 },
|
||||||
|
{ name: "groupName", weight: 0.14 },
|
||||||
|
{ name: "os", weight: 0.12 },
|
||||||
|
{ name: "ip", weight: 0.1 },
|
||||||
|
{ name: "hwid", weight: 0.08 },
|
||||||
|
{ name: "country", weight: 0.06 },
|
||||||
|
{ name: "version", weight: 0.06 },
|
||||||
|
{ name: "buildTag", weight: 0.06 },
|
||||||
|
{ name: "cpu", weight: 0.05 },
|
||||||
|
{ name: "gpu", weight: 0.05 },
|
||||||
|
{ name: "ram", weight: 0.04 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CLIENT_SEARCH_SQL_COLUMNS = [
|
||||||
|
"COALESCE(c.nickname,'')",
|
||||||
|
"COALESCE(c.host,'')",
|
||||||
|
"COALESCE(c.user,'')",
|
||||||
|
"c.id",
|
||||||
|
"COALESCE(c.custom_tag,'')",
|
||||||
|
"COALESCE(c.custom_tag_note,'')",
|
||||||
|
"COALESCE(g.name,'')",
|
||||||
|
"COALESCE(c.os,'')",
|
||||||
|
"COALESCE(c.ip,'')",
|
||||||
|
"COALESCE(c.hwid,'')",
|
||||||
|
"COALESCE(c.country,'')",
|
||||||
|
"COALESCE(c.version,'')",
|
||||||
|
"COALESCE(c.build_tag,'')",
|
||||||
|
"COALESCE(c.cpu,'')",
|
||||||
|
"COALESCE(c.gpu,'')",
|
||||||
|
"COALESCE(c.ram,'')",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function escapeLikeTerm(term: string): string {
|
||||||
|
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
if (!query) return { sql: "", params: [] };
|
||||||
|
|
||||||
|
const terms = Array.from(new Set([
|
||||||
|
query,
|
||||||
|
...query.split(/[\s,;:|/\\]+/),
|
||||||
|
].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8);
|
||||||
|
|
||||||
|
if (!terms.length) return { sql: "", params: [] };
|
||||||
|
|
||||||
|
const fieldClause = CLIENT_SEARCH_SQL_COLUMNS
|
||||||
|
.map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`)
|
||||||
|
.join(" OR ");
|
||||||
|
const sql = terms.map(() => `(${fieldClause})`).join(" OR ");
|
||||||
|
const params = terms.flatMap((term) => {
|
||||||
|
const like = `%${escapeLikeTerm(term)}%`;
|
||||||
|
return CLIENT_SEARCH_SQL_COLUMNS.map(() => like);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sql: `(${sql})`, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientSearchFtsQuery(search: string): string {
|
||||||
|
const terms = Array.from(new Set(
|
||||||
|
search
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,;:|/\\]+/)
|
||||||
|
.map((term) => term.replace(/["*]/g, "").trim())
|
||||||
|
.filter((term) => term.length >= 2),
|
||||||
|
)).slice(0, 8);
|
||||||
|
|
||||||
|
return terms.map((term) => `"${term}"*`).join(" OR ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null {
|
||||||
|
const ftsQuery = buildClientSearchFtsQuery(search);
|
||||||
|
if (!ftsQuery) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return db
|
||||||
|
.query<{ id: string }>(
|
||||||
|
`SELECT id
|
||||||
|
FROM client_search_fts
|
||||||
|
WHERE client_search_fts MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(ftsQuery, limit)
|
||||||
|
.map((row) => row.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzySearchClientRows(rows: any[], search: string): any[] {
|
||||||
|
const query = search.trim();
|
||||||
|
if (!query) return rows;
|
||||||
|
|
||||||
|
const fuse = new Fuse(rows, {
|
||||||
|
keys: CLIENT_SEARCH_KEYS as any,
|
||||||
|
threshold: 0.36,
|
||||||
|
distance: 120,
|
||||||
|
ignoreLocation: true,
|
||||||
|
shouldSort: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fuse.search(query).map((result) => result.item);
|
||||||
|
}
|
||||||
|
|
||||||
export function listClients(filters: ListFilters): ListResult {
|
export function listClients(filters: ListFilters): ListResult {
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
const where: string[] = [];
|
const where: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
if (search) {
|
|
||||||
where.push(
|
|
||||||
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
|
|
||||||
);
|
|
||||||
const needle = `%${search}%`;
|
|
||||||
params.push(needle, needle, needle, needle, needle, needle, needle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter === "online") {
|
if (statusFilter === "online") {
|
||||||
where.push("c.online=1");
|
where.push("c.online=1");
|
||||||
} else if (statusFilter === "offline") {
|
} else if (statusFilter === "offline") {
|
||||||
@@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
const needsGroupJoinForFilter =
|
const needsGroupJoinForFilter =
|
||||||
!!search ||
|
|
||||||
sort === "group_asc" ||
|
sort === "group_asc" ||
|
||||||
sort === "group_desc" ||
|
sort === "group_desc" ||
|
||||||
(groupFilter !== undefined && groupFilter !== "all");
|
(groupFilter !== undefined && groupFilter !== "all");
|
||||||
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
|
||||||
|
|
||||||
const orderBy = (() => {
|
const orderBy = (() => {
|
||||||
const online = "c.online DESC";
|
const online = "c.online DESC";
|
||||||
@@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const totalRow = db
|
|
||||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
|
|
||||||
.get(...params) ?? { c: 0 };
|
|
||||||
const onlineRow = db
|
|
||||||
.query<{ c: number }>(
|
|
||||||
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
|
||||||
)
|
|
||||||
.get(...params) ?? { c: 0 };
|
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const clientFields =
|
const clientFields =
|
||||||
"c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
|
"c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
|
||||||
|
|
||||||
const rows = needsGroupJoinForFilter
|
let rows: any[] | undefined;
|
||||||
? db
|
let totalCount = 0;
|
||||||
.query<any>(
|
let onlineCount = 0;
|
||||||
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
|
||||||
FROM clients c
|
if (search) {
|
||||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
const searchWhere = [...where];
|
||||||
${whereSql}
|
const searchParams = [...params];
|
||||||
${orderBy}
|
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
|
||||||
LIMIT ? OFFSET ?`,
|
const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit);
|
||||||
)
|
|
||||||
.all(...params, pageSize, offset)
|
if (ftsCandidateIds) {
|
||||||
: db
|
if (!ftsCandidateIds.length) {
|
||||||
.query<any>(
|
rows = [];
|
||||||
`WITH page_clients AS (
|
totalCount = 0;
|
||||||
SELECT ${clientFields}
|
onlineCount = 0;
|
||||||
|
} else {
|
||||||
|
searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`);
|
||||||
|
searchParams.push(...ftsCandidateIds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const searchCandidate = buildFuzzyCandidateWhere(search);
|
||||||
|
if (searchCandidate.sql) {
|
||||||
|
searchWhere.push(searchCandidate.sql);
|
||||||
|
searchParams.push(...searchCandidate.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows === undefined) {
|
||||||
|
const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : "";
|
||||||
|
const candidates = db
|
||||||
|
.query<any>(
|
||||||
|
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||||
FROM clients c
|
FROM clients c
|
||||||
|
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||||
|
${searchWhereSql}
|
||||||
|
${orderBy}`,
|
||||||
|
)
|
||||||
|
.all(...searchParams);
|
||||||
|
const matches = fuzzySearchClientRows(candidates, search);
|
||||||
|
totalCount = matches.length;
|
||||||
|
onlineCount = matches.filter((row: any) => row.online === 1).length;
|
||||||
|
rows = matches.slice(offset, offset + pageSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
||||||
|
const totalRow = db
|
||||||
|
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
|
||||||
|
.get(...params) ?? { c: 0 };
|
||||||
|
const onlineRow = db
|
||||||
|
.query<{ c: number }>(
|
||||||
|
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
||||||
|
)
|
||||||
|
.get(...params) ?? { c: 0 };
|
||||||
|
totalCount = totalRow.c;
|
||||||
|
onlineCount = onlineRow.c;
|
||||||
|
|
||||||
|
rows = needsGroupJoinForFilter
|
||||||
|
? db
|
||||||
|
.query<any>(
|
||||||
|
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||||
|
FROM clients c
|
||||||
|
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||||
${whereSql}
|
${whereSql}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?`,
|
||||||
)
|
)
|
||||||
SELECT pc.*, g.name as groupName, g.color as groupColor
|
.all(...params, pageSize, offset)
|
||||||
FROM page_clients pc
|
: db
|
||||||
LEFT JOIN client_groups g ON g.id = pc.groupId`,
|
.query<any>(
|
||||||
)
|
`WITH page_clients AS (
|
||||||
.all(...params, pageSize, offset);
|
SELECT ${clientFields}
|
||||||
|
FROM clients c
|
||||||
|
${whereSql}
|
||||||
|
${orderBy}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
)
|
||||||
|
SELECT pc.*, g.name as groupName, g.color as groupColor
|
||||||
|
FROM page_clients pc
|
||||||
|
LEFT JOIN client_groups g ON g.id = pc.groupId`,
|
||||||
|
)
|
||||||
|
.all(...params, pageSize, offset);
|
||||||
|
}
|
||||||
|
|
||||||
const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS;
|
const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS);
|
||||||
const suspiciousSets = shouldScanSuspicious
|
const suspiciousSets = shouldScanSuspicious
|
||||||
? getFloodSetsWithCache()
|
? getFloodSetsWithCache()
|
||||||
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
|
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
|
||||||
|
rows = rows || [];
|
||||||
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
||||||
|
|
||||||
const items = rows.map((c: any) => {
|
const items = rows.map((c: any) => {
|
||||||
@@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
|
return { page, pageSize, total: totalCount, online: onlineCount, items };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClientMetricsSummary = {
|
export type ClientMetricsSummary = {
|
||||||
@@ -998,7 +1152,56 @@ export type ClientMetricsSummary = {
|
|||||||
byCountryOnline: Record<string, number>;
|
byCountryOnline: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000;
|
||||||
|
let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null;
|
||||||
|
const userClientMetricsSummaryCache = new Map<number, { expiresAt: number; summary: ClientMetricsSummary }>();
|
||||||
|
|
||||||
|
function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary {
|
||||||
|
return {
|
||||||
|
total: summary.total,
|
||||||
|
online: summary.online,
|
||||||
|
byOS: { ...summary.byOS },
|
||||||
|
byCountry: { ...summary.byCountry },
|
||||||
|
byOSOnline: { ...summary.byOSOnline },
|
||||||
|
byCountryOnline: { ...summary.byCountryOnline },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClientMetricsSummary(
|
||||||
|
counts: { total: number | null; online: number | null },
|
||||||
|
osRows: { key: string; total: number; online: number | null }[],
|
||||||
|
countryRows: { key: string; total: number; online: number | null }[],
|
||||||
|
): ClientMetricsSummary {
|
||||||
|
const byOS: Record<string, number> = {};
|
||||||
|
const byOSOnline: Record<string, number> = {};
|
||||||
|
for (const row of osRows) {
|
||||||
|
byOS[row.key] = Number(row.total) || 0;
|
||||||
|
byOSOnline[row.key] = Number(row.online) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCountry: Record<string, number> = {};
|
||||||
|
const byCountryOnline: Record<string, number> = {};
|
||||||
|
for (const row of countryRows) {
|
||||||
|
byCountry[row.key] = Number(row.total) || 0;
|
||||||
|
byCountryOnline[row.key] = Number(row.online) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: Number(counts.total) || 0,
|
||||||
|
online: Number(counts.online) || 0,
|
||||||
|
byOS,
|
||||||
|
byCountry,
|
||||||
|
byOSOnline,
|
||||||
|
byCountryOnline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getClientMetricsSummary(): ClientMetricsSummary {
|
export function getClientMetricsSummary(): ClientMetricsSummary {
|
||||||
|
const now = Date.now();
|
||||||
|
if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) {
|
||||||
|
return cloneClientMetricsSummary(clientMetricsSummaryCache.summary);
|
||||||
|
}
|
||||||
|
|
||||||
const counts = db
|
const counts = db
|
||||||
.query<{ total: number; online: number }>(
|
.query<{ total: number; online: number }>(
|
||||||
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`,
|
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`,
|
||||||
@@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary {
|
|||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const byOS: Record<string, number> = {};
|
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||||
const byOSOnline: Record<string, number> = {};
|
clientMetricsSummaryCache = {
|
||||||
for (const row of osRows) {
|
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||||
byOS[row.key] = Number(row.total) || 0;
|
summary,
|
||||||
byOSOnline[row.key] = Number(row.online) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const byCountry: Record<string, number> = {};
|
|
||||||
const byCountryOnline: Record<string, number> = {};
|
|
||||||
for (const row of countryRows) {
|
|
||||||
byCountry[row.key] = Number(row.total) || 0;
|
|
||||||
byCountryOnline[row.key] = Number(row.online) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: Number(counts.total) || 0,
|
|
||||||
online: Number(counts.online) || 0,
|
|
||||||
byOS,
|
|
||||||
byCountry,
|
|
||||||
byOSOnline,
|
|
||||||
byCountryOnline,
|
|
||||||
};
|
};
|
||||||
|
return cloneClientMetricsSummary(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
|
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = userClientMetricsSummaryCache.get(userId);
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cloneClientMetricsSummary(cached.summary);
|
||||||
|
}
|
||||||
|
|
||||||
const filter = `WHERE built_by_user_id = ?`;
|
const filter = `WHERE built_by_user_id = ?`;
|
||||||
|
|
||||||
const counts = db
|
const counts = db
|
||||||
@@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
|
|||||||
)
|
)
|
||||||
.all(userId);
|
.all(userId);
|
||||||
|
|
||||||
const byOS: Record<string, number> = {};
|
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||||
const byOSOnline: Record<string, number> = {};
|
userClientMetricsSummaryCache.set(userId, {
|
||||||
for (const row of osRows) {
|
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||||
byOS[row.key] = Number(row.total) || 0;
|
summary,
|
||||||
byOSOnline[row.key] = Number(row.online) || 0;
|
});
|
||||||
}
|
return cloneClientMetricsSummary(summary);
|
||||||
|
|
||||||
const byCountry: Record<string, number> = {};
|
|
||||||
const byCountryOnline: Record<string, number> = {};
|
|
||||||
for (const row of countryRows) {
|
|
||||||
byCountry[row.key] = Number(row.total) || 0;
|
|
||||||
byCountryOnline[row.key] = Number(row.online) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: Number(counts.total) || 0,
|
|
||||||
online: Number(counts.online) || 0,
|
|
||||||
byOS,
|
|
||||||
byCountry,
|
|
||||||
byOSOnline,
|
|
||||||
byCountryOnline,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOnlineClientCountForUser(userId: number): number {
|
export function getOnlineClientCountForUser(userId: number): number {
|
||||||
|
|||||||
@@ -148,6 +148,82 @@ try {
|
|||||||
} catch {}
|
} catch {}
|
||||||
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
|
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.run(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5(
|
||||||
|
id UNINDEXED,
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
nickname,
|
||||||
|
custom_tag,
|
||||||
|
custom_tag_note,
|
||||||
|
os,
|
||||||
|
ip,
|
||||||
|
hwid,
|
||||||
|
country,
|
||||||
|
version,
|
||||||
|
build_tag,
|
||||||
|
cpu,
|
||||||
|
gpu,
|
||||||
|
ram,
|
||||||
|
tokenize = 'unicode61'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const counts = db
|
||||||
|
.query<{ clients: number; indexed: number }>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM clients) as clients,
|
||||||
|
(SELECT COUNT(*) FROM client_search_fts) as indexed`,
|
||||||
|
)
|
||||||
|
.get() ?? { clients: 0, indexed: 0 };
|
||||||
|
|
||||||
|
if (counts.clients !== counts.indexed) {
|
||||||
|
db.run(`DELETE FROM client_search_fts`);
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO client_search_fts(
|
||||||
|
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||||
|
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||||
|
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||||
|
FROM clients
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN
|
||||||
|
INSERT INTO client_search_fts(
|
||||||
|
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||||
|
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
|
||||||
|
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN
|
||||||
|
DELETE FROM client_search_fts WHERE rowid = old.rowid;
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN
|
||||||
|
DELETE FROM client_search_fts WHERE rowid = old.rowid;
|
||||||
|
INSERT INTO client_search_fts(
|
||||||
|
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||||
|
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
|
||||||
|
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => {
|
|||||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||||
expect(csp).toContain("default-src 'self'");
|
expect(csp).toContain("default-src 'self'");
|
||||||
expect(csp).toContain("script-src 'self'");
|
expect(csp).toContain("script-src 'self'");
|
||||||
|
expect(csp).toContain("font-src 'self' data:");
|
||||||
expect(csp).toContain("frame-ancestors 'none'");
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SECURITY_HEADERS = {
|
export const SECURITY_HEADERS = {
|
||||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"X-XSS-Protection": "1; mode=block",
|
"X-XSS-Protection": "1; mode=block",
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ function broadcastFrameToViewers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||||
|
const rdDebugFrameLogAt = new Map<string, number>();
|
||||||
export const rdStreamingState = new Map<string, {
|
export const rdStreamingState = new Map<string, {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
display: number;
|
display: number;
|
||||||
@@ -223,9 +224,12 @@ function sendConsoleCommand(target: ClientInfo | undefined, commandType: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
|
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
|
||||||
if (!target) return false;
|
if (!target) {
|
||||||
|
logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
logger.debug(`[rd] send command ${commandType} -> ${target.id}`);
|
logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`);
|
||||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
|
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
|
||||||
metrics.recordCommand(commandType);
|
metrics.recordCommand(commandType);
|
||||||
return true;
|
return true;
|
||||||
@@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case "desktop_start":
|
case "desktop_start":
|
||||||
|
logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`);
|
||||||
if (!state.isStreaming) {
|
if (!state.isStreaming) {
|
||||||
if (target.os === "darwin" && target.permissions) {
|
if (target.os === "darwin" && target.permissions) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
@@ -363,14 +368,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
sendDesktopCommand(target, "desktop_start", {});
|
sendDesktopCommand(target, "desktop_start", {});
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
|
logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "desktop_stop": {
|
case "desktop_stop": {
|
||||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||||
.filter(s => s.id !== ws.data.sessionId);
|
.filter(s => s.id !== ws.data.sessionId);
|
||||||
|
logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`);
|
||||||
if (otherViewers.length === 0) {
|
if (otherViewers.length === 0) {
|
||||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||||
sendDesktopCommand(target, "desktop_stop", {});
|
sendDesktopCommand(target, "desktop_stop", {});
|
||||||
@@ -648,6 +654,12 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
const frameFps = Number(header?.fps) || 0;
|
const frameFps = Number(header?.fps) || 0;
|
||||||
if (frameFps > 0) state.lastFps = frameFps;
|
if (frameFps > 0) state.lastFps = frameFps;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
|
const now = Date.now();
|
||||||
|
const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0;
|
||||||
|
if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) {
|
||||||
|
rdDebugFrameLogAt.set(clientId, now);
|
||||||
|
logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`);
|
||||||
|
}
|
||||||
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,7 +672,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
|
|||||||
}
|
}
|
||||||
recordRemoteDesktopFrame(clientId, header, bytes);
|
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||||
const buf = buildViewerFrameBuffer(bytes, header);
|
const buf = buildViewerFrameBuffer(bytes, header);
|
||||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
const sessions = sessionManager.getRdSessionsForClient(clientId);
|
||||||
|
const sent = broadcastFrameToViewers(sessions, buf, header);
|
||||||
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||||
|
|||||||
Reference in New Issue
Block a user