Merge branch 'main' into dev
This commit is contained in:
@@ -9,7 +9,10 @@
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"adm-zip": "^0.5.12",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"cytoscape": "^3.34.0",
|
||||
"fuse.js": "^7.4.1",
|
||||
"geoip-lite": "latest",
|
||||
"gridstack": "^12.6.0",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -262,6 +265,8 @@
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
@@ -310,12 +315,16 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"gridstack": ["gridstack@12.6.0", "", {}, "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overlord-server",
|
||||
"version": "2.3.4",
|
||||
"version": "2.3.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,7 +20,10 @@
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"adm-zip": "^0.5.12",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"cytoscape": "^3.34.0",
|
||||
"fuse.js": "^7.4.1",
|
||||
"geoip-lite": "latest",
|
||||
"gridstack": "^12.6.0",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
const container = document.getElementById("client-graph");
|
||||
const searchInput = document.getElementById("graph-search");
|
||||
const statusSelect = document.getElementById("graph-status");
|
||||
const layoutSelect = document.getElementById("graph-layout");
|
||||
const refreshBtn = document.getElementById("graph-refresh");
|
||||
const fitBtn = document.getElementById("graph-fit");
|
||||
const summaryEl = document.getElementById("graph-summary");
|
||||
const detailTitle = document.getElementById("graph-detail-title");
|
||||
const detailMeta = document.getElementById("graph-detail-meta");
|
||||
const detailActions = document.getElementById("graph-detail-actions");
|
||||
|
||||
let cy = null;
|
||||
let currentGraph = null;
|
||||
let searchTimer = null;
|
||||
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
|
||||
|
||||
const typeColors = {
|
||||
client: "#38bdf8",
|
||||
group: "#a78bfa",
|
||||
build: "#f59e0b",
|
||||
os: "#22c55e",
|
||||
country: "#f472b6",
|
||||
subnet: "#60a5fa",
|
||||
user: "#f97316",
|
||||
status: "#64748b",
|
||||
};
|
||||
|
||||
function flagEmoji(country) {
|
||||
const cc = String(country || "").trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(cc) || cc === "ZZ") return "";
|
||||
const base = 0x1f1e6;
|
||||
return String.fromCodePoint(
|
||||
base + cc.charCodeAt(0) - 65,
|
||||
base + cc.charCodeAt(1) - 65,
|
||||
);
|
||||
}
|
||||
|
||||
function osSymbol(osRaw) {
|
||||
const os = String(osRaw || "").toLowerCase();
|
||||
if (os.includes("win")) return "⊞";
|
||||
if (os.includes("mac") || os.includes("darwin")) return "⌘";
|
||||
if (os.includes("android")) return "▣";
|
||||
if (os.includes("ubuntu")) return "◌";
|
||||
if (os.includes("debian")) return "◆";
|
||||
if (os.includes("fedora")) return "●";
|
||||
if (os.includes("freebsd")) return "◇";
|
||||
if (os.includes("linux")) return "⬢";
|
||||
return "◯";
|
||||
}
|
||||
|
||||
function nodeDisplayLabel(data) {
|
||||
const label = String(data.label || data.id || "");
|
||||
if (data.type === "country") {
|
||||
const flag = flagEmoji(label);
|
||||
return flag ? `${flag} ${label} ${flag}` : label;
|
||||
}
|
||||
if (data.type === "os") return `${osSymbol(label)}\n${label}`;
|
||||
if (data.type === "status") return `${data.online ? "●" : "○"}\n${label}`;
|
||||
if (data.type === "group") return `◫\n${label}`;
|
||||
if (data.type === "build") return `⬡\n${label}`;
|
||||
if (data.type === "subnet") return `⌁\n${label}`;
|
||||
if (data.type === "user") return `@\n${label}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
if (!summaryEl) return;
|
||||
const total = summary.totalClients && summary.totalClients !== summary.clients
|
||||
? ` of ${summary.totalClients}`
|
||||
: "";
|
||||
summaryEl.textContent = `${summary.clients}${total} clients, ${summary.online} online, ${summary.relationships} relationships`;
|
||||
}
|
||||
|
||||
function graphElements(graph) {
|
||||
return [
|
||||
...graph.nodes.map((node) => ({
|
||||
group: "nodes",
|
||||
data: {
|
||||
...node.data,
|
||||
color: node.data.color || typeColors[node.data.type] || "#94a3b8",
|
||||
displayLabel: nodeDisplayLabel(node.data),
|
||||
},
|
||||
})),
|
||||
...graph.edges.map((edge) => ({ group: "edges", data: edge.data })),
|
||||
];
|
||||
}
|
||||
|
||||
function shouldAnimateLayout(graph = currentGraph) {
|
||||
const total = Number(graph?.summary?.totalClients || graph?.summary?.clients || 0);
|
||||
return total < LARGE_GRAPH_ANIMATION_CLIENTS;
|
||||
}
|
||||
|
||||
function layoutOptions(name, graph = currentGraph) {
|
||||
const animate = shouldAnimateLayout(graph);
|
||||
if (name === "grid") {
|
||||
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
|
||||
}
|
||||
if (name === "circle") {
|
||||
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
|
||||
}
|
||||
if (name === "breadthfirst") {
|
||||
return { name, directed: false, animate, animationDuration: animate ? 260 : 0, padding: 36, spacingFactor: 1.15 };
|
||||
}
|
||||
return {
|
||||
name: "cose",
|
||||
animate,
|
||||
animationDuration: animate ? 360 : 0,
|
||||
padding: 40,
|
||||
idealEdgeLength: 110,
|
||||
nodeRepulsion: 5200,
|
||||
gravity: 0.28,
|
||||
numIter: 900,
|
||||
};
|
||||
}
|
||||
|
||||
function showSelection(node) {
|
||||
if (!detailTitle || !detailMeta || !detailActions) return;
|
||||
if (!node) {
|
||||
detailTitle.textContent = "Nothing selected";
|
||||
detailMeta.innerHTML = "";
|
||||
detailActions.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = node.data();
|
||||
const meta = data.meta || {};
|
||||
const countryFlag = flagEmoji(meta.country || (data.type === "country" ? data.label : ""));
|
||||
detailTitle.textContent = data.type === "country" && countryFlag
|
||||
? `${countryFlag} ${data.label || data.id} ${countryFlag}`
|
||||
: data.label || data.id;
|
||||
const rows = [
|
||||
["Type", data.type],
|
||||
["Count", data.type === "client" ? "" : data.count || 1],
|
||||
["Online", data.type === "client" ? (data.online ? "Yes" : "No") : data.onlineCount || 0],
|
||||
["Client ID", data.clientId || meta.id || ""],
|
||||
["Host", meta.host || ""],
|
||||
["User", meta.user || ""],
|
||||
["OS", meta.os || ""],
|
||||
["IP", meta.ip || ""],
|
||||
["Country", meta.country ? `${countryFlag ? `${countryFlag} ` : ""}${meta.country}${countryFlag ? ` ${countryFlag}` : ""}` : ""],
|
||||
["Group", meta.groupName || ""],
|
||||
["Build", meta.buildTag || ""],
|
||||
].filter(([, value]) => value !== "" && value !== undefined && value !== null);
|
||||
|
||||
detailMeta.innerHTML = rows.map(([label, value]) => `
|
||||
<div class="graph-meta-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
if (data.clientId) {
|
||||
const id = encodeURIComponent(data.clientId);
|
||||
const online = data.online === true;
|
||||
const disabled = online ? "" : " disabled aria-disabled=\"true\" title=\"Client is offline\"";
|
||||
const disabledNote = online
|
||||
? ""
|
||||
: `<div class="graph-action-note"><i class="fa-solid fa-plug-circle-xmark"></i><span>Client is offline</span></div>`;
|
||||
detailActions.innerHTML = `
|
||||
${disabledNote}
|
||||
<button class="graph-action graph-action--remote" data-graph-action="/remotedesktop?clientId=${id}"${disabled}>
|
||||
<i class="fa-solid fa-desktop"></i><span>Remote Desktop</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--console" data-graph-action="/${id}/console"${disabled}>
|
||||
<i class="fa-solid fa-terminal"></i><span>Console</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--files" data-graph-action="/${id}/files"${disabled}>
|
||||
<i class="fa-solid fa-folder-tree"></i><span>File Browser</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--copy" data-graph-copy="${escapeHtml(data.clientId)}">
|
||||
<i class="fa-solid fa-copy"></i><span>Copy ID</span>
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
detailActions.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function bindInspectorActions() {
|
||||
detailActions?.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button) return;
|
||||
if (button.disabled || button.getAttribute("aria-disabled") === "true") return;
|
||||
const href = button.dataset.graphAction;
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
const copyValue = button.dataset.graphCopy;
|
||||
if (copyValue) {
|
||||
await navigator.clipboard?.writeText(copyValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectNode(node) {
|
||||
if (!cy) return;
|
||||
cy.elements().removeClass("graph-dim graph-focus");
|
||||
if (!node) {
|
||||
showSelection(null);
|
||||
return;
|
||||
}
|
||||
const neighborhood = node.closedNeighborhood();
|
||||
cy.elements().difference(neighborhood).addClass("graph-dim");
|
||||
neighborhood.addClass("graph-focus");
|
||||
showSelection(node);
|
||||
}
|
||||
|
||||
function renderGraph(graph) {
|
||||
currentGraph = graph;
|
||||
setSummary(graph.summary);
|
||||
|
||||
if (!window.cytoscape) {
|
||||
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cy) {
|
||||
cy = window.cytoscape({
|
||||
container,
|
||||
elements: graphElements(graph),
|
||||
minZoom: 0.18,
|
||||
maxZoom: 5,
|
||||
wheelSensitivity: 1.8,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(displayLabel)",
|
||||
"background-color": "data(color)",
|
||||
"border-color": "#020617",
|
||||
"border-width": 2,
|
||||
color: "#dbeafe",
|
||||
"font-family": "Inter, Segoe UI, sans-serif",
|
||||
"font-size": 11,
|
||||
"font-weight": 600,
|
||||
"text-outline-color": "#020617",
|
||||
"text-outline-width": 3,
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"line-height": 1.15,
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": 88,
|
||||
width: "mapData(weight, 1, 12, 28, 74)",
|
||||
height: "mapData(weight, 1, 12, 28, 74)",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[type = "client"]',
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
"border-color": "data(color)",
|
||||
"border-width": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[online = true]',
|
||||
style: {
|
||||
"border-color": "#22c55e",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 1.35,
|
||||
"line-color": "rgba(148, 163, 184, 0.42)",
|
||||
"target-arrow-shape": "none",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".graph-dim",
|
||||
style: {
|
||||
opacity: 0.12,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".graph-focus",
|
||||
style: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => selectNode(event.target));
|
||||
cy.on("tap", (event) => {
|
||||
if (event.target === cy) selectNode(null);
|
||||
});
|
||||
} else {
|
||||
cy.elements().remove();
|
||||
cy.add(graphElements(graph));
|
||||
}
|
||||
|
||||
cy.layout(layoutOptions(layoutSelect?.value || "cose", graph)).run();
|
||||
setTimeout(() => cy?.fit(undefined, 32), 420);
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
if (!container) return;
|
||||
if (summaryEl) summaryEl.textContent = "Loading...";
|
||||
const params = new URLSearchParams({
|
||||
limit: "350",
|
||||
q: searchInput?.value || "",
|
||||
status: statusSelect?.value || "all",
|
||||
});
|
||||
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
|
||||
return;
|
||||
}
|
||||
renderGraph(await res.json());
|
||||
}
|
||||
|
||||
function debounceLoad() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(loadGraph, 220);
|
||||
}
|
||||
|
||||
layoutSelect?.addEventListener("change", () => {
|
||||
if (cy) cy.layout(layoutOptions(layoutSelect.value, currentGraph)).run();
|
||||
});
|
||||
searchInput?.addEventListener("input", debounceLoad);
|
||||
statusSelect?.addEventListener("change", loadGraph);
|
||||
refreshBtn?.addEventListener("click", loadGraph);
|
||||
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
|
||||
window.addEventListener("resize", () => {
|
||||
cy?.resize();
|
||||
cy?.fit(undefined, 32);
|
||||
});
|
||||
|
||||
bindInspectorActions();
|
||||
loadGraph();
|
||||
@@ -268,6 +268,7 @@ function close() {
|
||||
function navigate(href, newTab = false) {
|
||||
pushRecent(href);
|
||||
if (newTab) window.open(href, "_blank", "noopener");
|
||||
else if (window.overlordSoftNavigate) window.overlordSoftNavigate(href);
|
||||
else window.location.href = href;
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -5,12 +5,34 @@ let osChart = null;
|
||||
const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed";
|
||||
const STATS_HEIGHT_KEY = "overlord_dashboard_stats_height";
|
||||
const STATS_ORDER_KEY = "overlord_dashboard_stats_order";
|
||||
const STATS_LAYOUT_KEY = "overlord_dashboard_stats_gridstack_layout";
|
||||
const STATS_HIDDEN_KEY = "overlord_dashboard_stats_hidden";
|
||||
const PULSE_ORDER_KEY = "overlord_dashboard_pulse_order";
|
||||
const DEFAULT_STATS_HEIGHT = 142;
|
||||
const MIN_STATS_HEIGHT = 112;
|
||||
const MAX_STATS_HEIGHT = 280;
|
||||
const DEFAULT_CARD_ORDER = ["online", "sessions", "trend", "fleet", "pulse"];
|
||||
const DEFAULT_PULSE_ORDER = ["api", "memory", "ping"];
|
||||
const DEFAULT_GRID_LAYOUT = {
|
||||
online: { x: 0, y: 0, w: 2, h: 2 },
|
||||
sessions: { x: 2, y: 0, w: 2, h: 2 },
|
||||
trend: { x: 4, y: 0, w: 3, h: 2 },
|
||||
fleet: { x: 7, y: 0, w: 3, h: 2 },
|
||||
pulse: { x: 10, y: 0, w: 2, h: 2 },
|
||||
};
|
||||
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 = {
|
||||
text: "#cbd5e1",
|
||||
@@ -291,6 +313,239 @@ function applyHeightMode(height) {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatsCellHeight() {
|
||||
return Math.max(48, Math.round(getSavedHeight() / 2));
|
||||
}
|
||||
|
||||
function resizeCharts() {
|
||||
requestAnimationFrame(() => {
|
||||
onlineChart?.resize();
|
||||
osChart?.resize();
|
||||
});
|
||||
}
|
||||
|
||||
function syncStatsWidgetHeights() {
|
||||
if (!statsGrid?.engine?.nodes) return;
|
||||
const cellHeight = Number(statsGrid.opts?.cellHeight) || getStatsCellHeight();
|
||||
for (const node of statsGrid.engine.nodes) {
|
||||
const content = node.el?.querySelector?.(".grid-stack-item-content");
|
||||
if (!content) continue;
|
||||
const estimated = Math.max(64, (Number(node.h) || 2) * cellHeight - 12);
|
||||
content.style.setProperty("--dashboard-stats-height", `${estimated}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function readSavedGridLayout() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STATS_LAYOUT_KEY) || "[]");
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item) => DEFAULT_CARD_ORDER.includes(item?.id))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
x: Math.max(0, Number(item.x) || 0),
|
||||
y: Math.max(0, Number(item.y) || 0),
|
||||
w: Math.max(1, Number(item.w) || DEFAULT_GRID_LAYOUT[item.id]?.w || 2),
|
||||
h: Math.max(1, Number(item.h) || DEFAULT_GRID_LAYOUT[item.id]?.h || 2),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readHiddenStatsCards() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STATS_HIDDEN_KEY) || "[]");
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id) => DEFAULT_CARD_ORDER.includes(id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHiddenStatsCards(hidden) {
|
||||
const cleaned = Array.from(hidden).filter((id) => DEFAULT_CARD_ORDER.includes(id));
|
||||
localStorage.setItem(STATS_HIDDEN_KEY, JSON.stringify(cleaned));
|
||||
}
|
||||
|
||||
function isStatsGridManaged(el) {
|
||||
return !!statsGrid?.engine?.nodes?.some((node) => node.el === el);
|
||||
}
|
||||
|
||||
function saveStatsGridLayout() {
|
||||
if (!statsGrid?.engine?.nodes) return;
|
||||
const layoutMap = new Map(
|
||||
DEFAULT_CARD_ORDER.map((id) => [id, { id, ...DEFAULT_GRID_LAYOUT[id] }]),
|
||||
);
|
||||
|
||||
for (const item of readSavedGridLayout()) {
|
||||
layoutMap.set(item.id, item);
|
||||
}
|
||||
|
||||
for (const node of statsGrid.engine.nodes) {
|
||||
const id = node.el?.dataset?.dashboardCard || node.id;
|
||||
if (!DEFAULT_CARD_ORDER.includes(id)) continue;
|
||||
layoutMap.set(id, { id, x: node.x, y: node.y, w: node.w, h: node.h });
|
||||
}
|
||||
|
||||
const layout = DEFAULT_CARD_ORDER.map((id) => layoutMap.get(id));
|
||||
localStorage.setItem(STATS_LAYOUT_KEY, JSON.stringify(layout));
|
||||
}
|
||||
|
||||
function applyGridLayout(layout) {
|
||||
if (!statsGrid) return;
|
||||
statsGrid.batchUpdate();
|
||||
for (const item of layout) {
|
||||
const el = document.querySelector(`[data-dashboard-card="${item.id}"]`);
|
||||
if (el) statsGrid.update(el, { x: item.x, y: item.y, w: item.w, h: item.h });
|
||||
}
|
||||
statsGrid.batchUpdate(false);
|
||||
syncStatsWidgetHeights();
|
||||
resizeCharts();
|
||||
}
|
||||
|
||||
function setStatsCardHidden(id, hidden, persist = true) {
|
||||
if (!DEFAULT_CARD_ORDER.includes(id)) return;
|
||||
const el = document.querySelector(`[data-dashboard-card="${id}"]`);
|
||||
if (!el) return;
|
||||
|
||||
if (hidden) {
|
||||
if (statsGrid && isStatsGridManaged(el)) statsGrid.removeWidget(el, false);
|
||||
el.classList.add("dashboard-card-hidden");
|
||||
} else {
|
||||
el.classList.remove("dashboard-card-hidden");
|
||||
if (statsGrid && !isStatsGridManaged(el)) {
|
||||
statsGrid.makeWidget(el);
|
||||
const layout = readSavedGridLayout().find((item) => item.id === id);
|
||||
statsGrid.update(el, { ...(layout || DEFAULT_GRID_LAYOUT[id]) });
|
||||
}
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
const nextHidden = new Set(readHiddenStatsCards());
|
||||
if (hidden) nextHidden.add(id);
|
||||
else nextHidden.delete(id);
|
||||
saveHiddenStatsCards(nextHidden);
|
||||
saveStatsGridLayout();
|
||||
}
|
||||
|
||||
syncStatsWidgetHeights();
|
||||
resizeCharts();
|
||||
}
|
||||
|
||||
function applyHiddenStatsCards() {
|
||||
const hidden = new Set(readHiddenStatsCards());
|
||||
for (const id of DEFAULT_CARD_ORDER) {
|
||||
setStatsCardHidden(id, hidden.has(id), false);
|
||||
}
|
||||
}
|
||||
|
||||
function syncStatsCardMenu() {
|
||||
const panel = $("dashboard-card-menu-panel");
|
||||
if (!panel) return;
|
||||
const hidden = new Set(readHiddenStatsCards());
|
||||
panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => {
|
||||
const id = input.dataset.dashboardCardToggle;
|
||||
input.checked = !hidden.has(id);
|
||||
});
|
||||
}
|
||||
|
||||
function initStatsCardMenu() {
|
||||
const menu = $("dashboard-card-menu");
|
||||
const panel = $("dashboard-card-menu-panel");
|
||||
if (!panel || panel.dataset.bound === "true") return;
|
||||
|
||||
panel.dataset.bound = "true";
|
||||
syncStatsCardMenu();
|
||||
panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
setStatsCardHidden(input.dataset.dashboardCardToggle, !input.checked);
|
||||
});
|
||||
});
|
||||
|
||||
menu?.addEventListener("toggle", () => {
|
||||
if (menu.open) syncStatsCardMenu();
|
||||
});
|
||||
}
|
||||
|
||||
function applySavedGridLayout() {
|
||||
const saved = readSavedGridLayout();
|
||||
if (saved.length) {
|
||||
applyGridLayout(saved);
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve older drag-only ordering by converting it into default-size widgets.
|
||||
const order = readSavedOrder();
|
||||
if (!order.length || order.join("|") === DEFAULT_CARD_ORDER.join("|")) return;
|
||||
const migrated = order.map((id, index) => ({
|
||||
id,
|
||||
...(DEFAULT_GRID_LAYOUT[id] || { w: 2, h: 2 }),
|
||||
x: index === 0 ? 0 : undefined,
|
||||
y: index === 0 ? 0 : undefined,
|
||||
}));
|
||||
let x = 0;
|
||||
for (const item of migrated) {
|
||||
const width = item.w || 2;
|
||||
if (x + width > 12) x = 0;
|
||||
item.x = x;
|
||||
item.y = 0;
|
||||
x += width;
|
||||
}
|
||||
applyGridLayout(migrated);
|
||||
}
|
||||
|
||||
function resetStatsGridLayout() {
|
||||
localStorage.removeItem(STATS_LAYOUT_KEY);
|
||||
localStorage.removeItem(STATS_HIDDEN_KEY);
|
||||
for (const id of DEFAULT_CARD_ORDER) {
|
||||
setStatsCardHidden(id, false, false);
|
||||
}
|
||||
syncStatsCardMenu();
|
||||
if (!statsGrid) {
|
||||
const content = $("dashboard-stats-content");
|
||||
if (content) applySavedOrder(content);
|
||||
return;
|
||||
}
|
||||
applyGridLayout(DEFAULT_CARD_ORDER.map((id) => ({ id, ...DEFAULT_GRID_LAYOUT[id] })));
|
||||
}
|
||||
|
||||
function initStatsGrid(content) {
|
||||
if (!content || content.dataset.gridstackBound === "true") return;
|
||||
const api = window.GridStack?.GridStack || window.GridStack;
|
||||
if (typeof api?.init !== "function") {
|
||||
applySavedOrder(content);
|
||||
applyHiddenStatsCards();
|
||||
return;
|
||||
}
|
||||
|
||||
content.dataset.gridstackBound = "true";
|
||||
statsGrid = api.init({
|
||||
column: 12,
|
||||
cellHeight: getStatsCellHeight(),
|
||||
margin: 6,
|
||||
animate: true,
|
||||
float: false,
|
||||
handle: ".grid-stack-item-content",
|
||||
draggable: {
|
||||
handle: ".grid-stack-item-content",
|
||||
appendTo: "body",
|
||||
scroll: true,
|
||||
},
|
||||
resizable: { handles: "se" },
|
||||
}, content);
|
||||
|
||||
applySavedGridLayout();
|
||||
applyHiddenStatsCards();
|
||||
statsGrid.on("change dragstop resizestop", () => {
|
||||
syncStatsWidgetHeights();
|
||||
saveStatsGridLayout();
|
||||
resizeCharts();
|
||||
});
|
||||
syncStatsWidgetHeights();
|
||||
resizeCharts();
|
||||
}
|
||||
|
||||
function updateOnlineChart(history, snapshot) {
|
||||
if (!onlineChart) return;
|
||||
const rows = aggregateOnlineHistory(history, snapshot);
|
||||
@@ -386,6 +641,8 @@ async function fetchDashboardMetrics() {
|
||||
export function initDashboardStats() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
window.removeEventListener("pagehide", teardownDashboardStats);
|
||||
window.addEventListener("pagehide", teardownDashboardStats);
|
||||
if (!makeCharts()) {
|
||||
setTimeout(() => {
|
||||
if (makeCharts()) applyHeightMode(getSavedHeight());
|
||||
@@ -403,18 +660,15 @@ function initStatsToggle() {
|
||||
const resizer = $("dashboard-stats-resizer");
|
||||
if (!shell || !button) return;
|
||||
|
||||
const resizeCharts = () => {
|
||||
requestAnimationFrame(() => {
|
||||
onlineChart?.resize();
|
||||
osChart?.resize();
|
||||
});
|
||||
};
|
||||
|
||||
const setHeight = (value, persist = true) => {
|
||||
const height = normalizeHeight(value);
|
||||
shell.style.setProperty("--dashboard-stats-height", `${height}px`);
|
||||
resizer?.setAttribute("aria-valuenow", String(height));
|
||||
if (persist) localStorage.setItem(STATS_HEIGHT_KEY, String(height));
|
||||
if (statsGrid) {
|
||||
statsGrid.cellHeight(getStatsCellHeight());
|
||||
syncStatsWidgetHeights();
|
||||
}
|
||||
applyHeightMode(height);
|
||||
resizeCharts();
|
||||
};
|
||||
@@ -429,7 +683,8 @@ function initStatsToggle() {
|
||||
localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false");
|
||||
};
|
||||
|
||||
initCardReorder();
|
||||
initStatsGrid($("dashboard-stats-content"));
|
||||
initStatsCardMenu();
|
||||
initPulseReorder();
|
||||
setHeight(getSavedHeight(), false);
|
||||
setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true");
|
||||
@@ -483,10 +738,9 @@ function initStatsToggle() {
|
||||
resetButton?.addEventListener("click", () => {
|
||||
setHeight(DEFAULT_STATS_HEIGHT);
|
||||
setCollapsed(false);
|
||||
resetStatsGridLayout();
|
||||
localStorage.removeItem(STATS_ORDER_KEY);
|
||||
localStorage.removeItem(PULSE_ORDER_KEY);
|
||||
const content = $("dashboard-stats-content");
|
||||
if (content) applySavedOrder(content);
|
||||
const pulseList = document.querySelector(".dashboard-health-list");
|
||||
if (pulseList) applyPulseOrder(pulseList);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,29 @@ let dashboardWsConnected = false;
|
||||
let wsReconnectTimer = null;
|
||||
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) {
|
||||
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
|
||||
if (!clientId) return;
|
||||
@@ -243,6 +266,7 @@ function adjustPollingForWs() {
|
||||
}
|
||||
|
||||
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
||||
bindDashboardPagehideCleanup();
|
||||
connectDashboardWs();
|
||||
const effectiveInterval = dashboardWsConnected
|
||||
? FALLBACK_POLL_MS
|
||||
|
||||
@@ -1135,6 +1135,311 @@ article.card[data-bookmarked="true"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(30, 41, 59, 0.86);
|
||||
background: rgba(2, 6, 23, 0.78);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.graph-title > i {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 145, 178, 0.12);
|
||||
color: #67e8f9;
|
||||
border: 1px solid rgba(103, 232, 249, 0.18);
|
||||
}
|
||||
|
||||
.graph-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.graph-title p {
|
||||
margin: 2px 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.graph-field {
|
||||
height: 38px;
|
||||
min-width: min(280px, 44vw);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.95);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #94a3b8;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.graph-field:focus-within {
|
||||
border-color: rgba(56, 189, 248, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.graph-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.graph-select {
|
||||
height: 38px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.95);
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
padding: 0 10px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.graph-workspace {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.graph-canvas {
|
||||
min-height: 560px;
|
||||
height: calc(100vh - 170px);
|
||||
border: 1px solid rgba(30, 41, 59, 0.9);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(rgba(15, 23, 42, 0.86), rgba(2, 6, 23, 0.94)),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px);
|
||||
background-size: auto, 34px 34px, 34px 34px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
min-height: 0;
|
||||
height: calc(100vh - 170px);
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 8px;
|
||||
background: #141c2b;
|
||||
box-shadow:
|
||||
0 14px 38px rgba(0, 0, 0, 0.26),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
padding: 8px;
|
||||
scrollbar-color: rgba(100, 116, 139, 0.7) transparent;
|
||||
}
|
||||
|
||||
.graph-inspector-kicker {
|
||||
color: #94a3b8;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
|
||||
.graph-inspector h2 {
|
||||
margin: 4px 8px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.graph-detail-meta {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.13);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
|
||||
}
|
||||
|
||||
.graph-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
border-radius: 5px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.graph-meta-row span {
|
||||
color: #7c8aa0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.graph-meta-row strong {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.graph-meta-row:hover {
|
||||
background: rgba(71, 85, 105, 0.32);
|
||||
}
|
||||
|
||||
.graph-detail-actions {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.graph-action-note {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fca5a5;
|
||||
background: rgba(127, 29, 29, 0.18);
|
||||
border: 1px solid rgba(248, 113, 113, 0.14);
|
||||
border-radius: 5px;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.graph-action {
|
||||
min-height: 34px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 9px;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
transition: background 0.1s ease, color 0.1s ease;
|
||||
}
|
||||
|
||||
.graph-action:hover:not(:disabled) {
|
||||
background: rgba(71, 85, 105, 0.55);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.graph-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.38;
|
||||
filter: grayscale(0.55);
|
||||
}
|
||||
|
||||
.graph-action--remote {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.graph-action--remote i {
|
||||
color: #c084fc;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-action--console {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.graph-action--console i {
|
||||
color: #34d399;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-action--files {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.graph-action--files i {
|
||||
color: #60a5fa;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-action--copy {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.graph-action--copy i {
|
||||
color: #94a3b8;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.graph-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-field {
|
||||
min-width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.graph-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graph-canvas,
|
||||
.graph-inspector {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.graph-canvas {
|
||||
min-height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes radioactiveShake {
|
||||
0%,
|
||||
100% {
|
||||
@@ -1828,6 +2133,35 @@ mark.search-current {
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -4547,11 +4881,59 @@ body.sb-ready #top-nav {
|
||||
border-color: rgba(71, 85, 105, 0.8);
|
||||
}
|
||||
|
||||
.dashboard-stats-content {
|
||||
.dashboard-card-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-card-menu > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-card-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-card-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 45;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(260px, 1.45fr) minmax(260px, 1.35fr) minmax(220px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 196px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.82);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
box-shadow: 0 18px 38px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.dashboard-card-menu-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px 7px;
|
||||
border-radius: 6px;
|
||||
color: #cbd5e1;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-card-menu-panel label:hover {
|
||||
background: rgba(51, 65, 85, 0.62);
|
||||
}
|
||||
|
||||
.dashboard-card-menu-panel input {
|
||||
accent-color: #38bdf8;
|
||||
}
|
||||
|
||||
.dashboard-stats-content {
|
||||
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,
|
||||
@@ -4594,6 +4976,7 @@ body.sb-ready #top-nav {
|
||||
.dashboard-health-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(51, 65, 85, 0.68);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
@@ -4621,11 +5004,44 @@ body.sb-ready #top-nav {
|
||||
border-color: rgba(56, 189, 248, 0.62);
|
||||
}
|
||||
|
||||
.dashboard-stats-content .grid-stack-item-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.dashboard-stats-content .grid-stack-item.dashboard-card-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-stats-content .grid-stack-item.ui-draggable-dragging .grid-stack-item-content,
|
||||
.dashboard-stats-content .grid-stack-item.ui-resizable-resizing .grid-stack-item-content {
|
||||
border-color: rgba(56, 189, 248, 0.62);
|
||||
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.dashboard-stats-content .grid-stack-placeholder > .placeholder-content,
|
||||
.dashboard-stats-content .grid-stack-placeholder > .grid-stack-item-content {
|
||||
border: 1px dashed rgba(56, 189, 248, 0.58);
|
||||
border-radius: 10px;
|
||||
background: rgba(8, 47, 73, 0.22);
|
||||
}
|
||||
|
||||
.dashboard-stats-content .ui-resizable-se {
|
||||
right: 8px !important;
|
||||
bottom: 8px !important;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.dashboard-stats-content .grid-stack-item:hover .ui-resizable-se {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dashboard-stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: var(--dashboard-stats-height);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -4684,7 +5100,6 @@ body.sb-ready #top-nav {
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
height: var(--dashboard-stats-height);
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
|
||||
@@ -4734,6 +5149,12 @@ body.sb-ready #top-nav {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-chart-wrap canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-health-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -4822,37 +5243,7 @@ body.sb-ready #top-nav {
|
||||
box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, minmax(260px, 1.35fr));
|
||||
}
|
||||
|
||||
.dashboard-health-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.dashboard-stat-value {
|
||||
font-size: clamp(1.65rem, calc(var(--dashboard-stats-height) / 3.4), 3rem);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
let clientsChart = null;
|
||||
let commandsChart = null;
|
||||
let bandwidthChart = null;
|
||||
let httpRequestsChart = null;
|
||||
let httpChart = null;
|
||||
let memoryChart = null;
|
||||
let eventLoopChart = null;
|
||||
let sessionsChart = null;
|
||||
let osChart = null;
|
||||
let countryMap = null;
|
||||
let countriesLayer = null;
|
||||
let countryCounts = {};
|
||||
@@ -7,6 +14,8 @@ let maxCountryCount = 0;
|
||||
let latestByCountry = {};
|
||||
|
||||
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
|
||||
const MAX_CHART_POINTS = 240;
|
||||
const METRICS_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
if (typeof Chart !== "undefined") {
|
||||
Chart.defaults.color = "#cbd5e1";
|
||||
@@ -14,9 +23,69 @@ if (typeof Chart !== "undefined") {
|
||||
Chart.defaults.font.family = "Inter, system-ui, sans-serif";
|
||||
}
|
||||
|
||||
function chartTooltip() {
|
||||
return {
|
||||
backgroundColor: "#0f172a",
|
||||
titleColor: "#e2e8f0",
|
||||
bodyColor: "#cbd5e1",
|
||||
borderColor: "#334155",
|
||||
borderWidth: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function lineChartOptions(extra = {}) {
|
||||
const extraPlugins = extra.plugins || {};
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: "#94a3b8" },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: "#94a3b8",
|
||||
maxTicksLimit: 6,
|
||||
autoSkip: true,
|
||||
maxRotation: 0,
|
||||
},
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
...(extra.scales || {}),
|
||||
},
|
||||
...extra,
|
||||
plugins: {
|
||||
legend:
|
||||
extraPlugins.legend ?? { display: true, labels: { boxWidth: 10, boxHeight: 10 } },
|
||||
tooltip: chartTooltip(),
|
||||
...extraPlugins,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeLineChart(canvas, datasets, options = {}) {
|
||||
if (!canvas) return null;
|
||||
return new Chart(canvas, {
|
||||
type: "line",
|
||||
data: { labels: [], datasets },
|
||||
options: lineChartOptions(options),
|
||||
});
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
const clientsCtx = document.getElementById("clients-chart");
|
||||
const commandsCtx = document.getElementById("commands-chart");
|
||||
const bandwidthCtx = document.getElementById("bandwidth-chart");
|
||||
const httpRequestsCtx = document.getElementById("http-requests-chart");
|
||||
const httpCtx = document.getElementById("http-chart");
|
||||
const memoryCtx = document.getElementById("memory-chart");
|
||||
const eventLoopCtx = document.getElementById("event-loop-chart");
|
||||
const sessionsCtx = document.getElementById("sessions-chart");
|
||||
const osCtx = document.getElementById("os-chart");
|
||||
|
||||
clientsChart = new Chart(clientsCtx, {
|
||||
type: "line",
|
||||
@@ -119,13 +188,283 @@ function initCharts() {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bandwidthChart = makeLineChart(
|
||||
bandwidthCtx,
|
||||
[
|
||||
{
|
||||
label: "Sent/s",
|
||||
data: [],
|
||||
borderColor: "rgb(251, 146, 60)",
|
||||
backgroundColor: "rgba(251, 146, 60, 0.12)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "Received/s",
|
||||
data: [],
|
||||
borderColor: "rgb(56, 189, 248)",
|
||||
backgroundColor: "rgba(56, 189, 248, 0.1)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: "#94a3b8",
|
||||
callback: (value) => formatBytes(Number(value)),
|
||||
},
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
httpRequestsChart = makeLineChart(
|
||||
httpRequestsCtx,
|
||||
[
|
||||
{
|
||||
label: "requests/min",
|
||||
data: [],
|
||||
borderColor: "rgb(52, 211, 153)",
|
||||
backgroundColor: "rgba(52, 211, 153, 0.12)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "errors/min",
|
||||
data: [],
|
||||
borderColor: "rgb(248, 113, 113)",
|
||||
backgroundColor: "rgba(248, 113, 113, 0.14)",
|
||||
fill: false,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderDash: [5, 4],
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: "#94a3b8", precision: 0 },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
httpChart = makeLineChart(
|
||||
httpCtx,
|
||||
[
|
||||
{
|
||||
label: "p99 ms",
|
||||
data: [],
|
||||
borderColor: "rgb(248, 113, 113)",
|
||||
backgroundColor: "rgba(248, 113, 113, 0.08)",
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderDash: [4, 4],
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "p95 ms",
|
||||
data: [],
|
||||
borderColor: "rgb(244, 63, 94)",
|
||||
backgroundColor: "rgba(244, 63, 94, 0.1)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "avg ms",
|
||||
data: [],
|
||||
borderColor: "rgb(251, 191, 36)",
|
||||
backgroundColor: "rgba(251, 191, 36, 0.08)",
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
memoryChart = makeLineChart(
|
||||
memoryCtx,
|
||||
[
|
||||
{
|
||||
label: "Heap",
|
||||
data: [],
|
||||
borderColor: "rgb(34, 211, 238)",
|
||||
backgroundColor: "rgba(34, 211, 238, 0.1)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
yAxisID: "y",
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "RSS",
|
||||
data: [],
|
||||
borderColor: "rgb(129, 140, 248)",
|
||||
backgroundColor: "rgba(129, 140, 248, 0.08)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
yAxisID: "y",
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "System %",
|
||||
data: [],
|
||||
borderColor: "rgb(52, 211, 153)",
|
||||
backgroundColor: "rgba(52, 211, 153, 0.08)",
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
yAxisID: "y1",
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
position: "left",
|
||||
ticks: { color: "#94a3b8", callback: (value) => formatBytes(Number(value)) },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
position: "right",
|
||||
ticks: { color: "#86efac", callback: (value) => `${Math.round(Number(value))}%` },
|
||||
grid: { drawOnChartArea: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
eventLoopChart = makeLineChart(
|
||||
eventLoopCtx,
|
||||
[
|
||||
{
|
||||
label: "p95 lag",
|
||||
data: [],
|
||||
borderColor: "rgb(251, 191, 36)",
|
||||
backgroundColor: "rgba(251, 191, 36, 0.12)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "avg lag",
|
||||
data: [],
|
||||
borderColor: "rgb(96, 165, 250)",
|
||||
backgroundColor: "rgba(96, 165, 250, 0.08)",
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
sessionsChart = sessionsCtx ? new Chart(sessionsCtx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: ["Console", "Remote Desktop", "Files", "Processes"],
|
||||
datasets: [{
|
||||
data: [0, 0, 0, 0],
|
||||
backgroundColor: ["#34d399", "#c084fc", "#60a5fa", "#fb923c"],
|
||||
borderColor: "#0f172a",
|
||||
borderWidth: 2,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: "64%",
|
||||
plugins: {
|
||||
legend: { position: "right", labels: { boxWidth: 10, boxHeight: 10 } },
|
||||
tooltip: chartTooltip(),
|
||||
},
|
||||
},
|
||||
}) : null;
|
||||
|
||||
osChart = osCtx ? new Chart(osCtx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: "Clients",
|
||||
data: [],
|
||||
backgroundColor: "rgba(56, 189, 248, 0.5)",
|
||||
borderColor: "rgb(56, 189, 248)",
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: chartTooltip(),
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: "#94a3b8", precision: 0 },
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: "#94a3b8" },
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}) : null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
if (bytes < 1) return `${bytes.toFixed(2)} B`;
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
@@ -269,6 +608,10 @@ function updateMetrics(data, debug) {
|
||||
if (httpLatencyP95El) {
|
||||
httpLatencyP95El.textContent = formatMs(data.http.latencyP95 || 0);
|
||||
}
|
||||
const httpLatencyP99El = document.getElementById("http-latency-p99");
|
||||
if (httpLatencyP99El) {
|
||||
httpLatencyP99El.textContent = formatMs(data.http.latencyP99 || 0);
|
||||
}
|
||||
const eventLoopAvgEl = document.getElementById("event-loop-avg");
|
||||
if (eventLoopAvgEl) {
|
||||
eventLoopAvgEl.textContent = formatMs(data.eventLoop.avg || 0);
|
||||
@@ -314,6 +657,57 @@ function updateMetrics(data, debug) {
|
||||
osList.innerHTML = '<div class="text-slate-500">No clients</div>';
|
||||
}
|
||||
|
||||
if (sessionsChart) {
|
||||
sessionsChart.data.datasets[0].data = [
|
||||
data.sessions.console || 0,
|
||||
data.sessions.remoteDesktop || 0,
|
||||
data.sessions.fileBrowser || 0,
|
||||
data.sessions.process || 0,
|
||||
];
|
||||
sessionsChart.update("none");
|
||||
}
|
||||
|
||||
if (osChart) {
|
||||
const osEntries = Object.entries(data.clients.byOS || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
osChart.data.labels = osEntries.length ? osEntries.map(([label]) => label) : ["No clients"];
|
||||
osChart.data.datasets[0].data = osEntries.length ? osEntries.map(([, count]) => count) : [0];
|
||||
osChart.update("none");
|
||||
}
|
||||
|
||||
const httpRoutesList = document.getElementById("http-routes");
|
||||
if (httpRoutesList) {
|
||||
const routes = Array.isArray(data.http.routes) ? data.http.routes : [];
|
||||
if (routes.length > 0) {
|
||||
httpRoutesList.innerHTML = routes
|
||||
.map((route) => {
|
||||
const errorClass = route.errorsLastMinute > 0 ? "text-red-300" : "text-slate-400";
|
||||
return `
|
||||
<div class="bg-slate-800/50 rounded p-3 min-w-0">
|
||||
<div class="text-xs text-slate-400 mb-2 truncate" title="${escapeHtml(route.route)}">
|
||||
${escapeHtml(route.route)}
|
||||
</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(route.latencyP95 || 0)}</span>
|
||||
<span class="text-slate-500">avg</span>
|
||||
<span class="font-semibold text-right">${formatMs(route.latencyAvg || 0)}</span>
|
||||
<span class="text-slate-500">req/min</span>
|
||||
<span class="font-semibold text-right">${Number(route.countLastMinute || 0).toLocaleString()}</span>
|
||||
<span class="text-slate-500">errors</span>
|
||||
<span class="font-semibold text-right ${errorClass}">${Number(route.errorsLastMinute || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
} else {
|
||||
httpRoutesList.innerHTML =
|
||||
'<div class="text-slate-500 col-span-full text-center py-4">No HTTP route samples yet</div>';
|
||||
}
|
||||
}
|
||||
|
||||
const commandTypesList = document.getElementById("command-types");
|
||||
if (Object.keys(data.commands.byType).length > 0) {
|
||||
const topCommands = Object.entries(data.commands.byType)
|
||||
@@ -462,7 +856,11 @@ async function initCountryMap() {
|
||||
}
|
||||
|
||||
function updateCharts(history, snapshot) {
|
||||
if (history.length === 0) {
|
||||
const points = Array.isArray(history) && history.length > 0
|
||||
? history.slice(-MAX_CHART_POINTS)
|
||||
: [];
|
||||
|
||||
if (points.length === 0) {
|
||||
const now = new Date();
|
||||
const label = formatTime(now.getTime());
|
||||
|
||||
@@ -473,26 +871,100 @@ function updateCharts(history, snapshot) {
|
||||
commandsChart.data.labels = [label];
|
||||
commandsChart.data.datasets[0].data = [snapshot.commands.lastMinute];
|
||||
commandsChart.update("none");
|
||||
|
||||
if (bandwidthChart) {
|
||||
bandwidthChart.data.labels = [label];
|
||||
bandwidthChart.data.datasets[0].data = [snapshot.bandwidth.sentPerSecond || 0];
|
||||
bandwidthChart.data.datasets[1].data = [snapshot.bandwidth.receivedPerSecond || 0];
|
||||
bandwidthChart.update("none");
|
||||
}
|
||||
|
||||
if (httpChart) {
|
||||
httpChart.data.labels = [label];
|
||||
httpChart.data.datasets[0].data = [snapshot.http.latencyP99 || 0];
|
||||
httpChart.data.datasets[1].data = [snapshot.http.latencyP95 || 0];
|
||||
httpChart.data.datasets[2].data = [snapshot.http.latencyAvg || 0];
|
||||
httpChart.update("none");
|
||||
}
|
||||
|
||||
if (httpRequestsChart) {
|
||||
httpRequestsChart.data.labels = [label];
|
||||
httpRequestsChart.data.datasets[0].data = [snapshot.http.lastMinute || 0];
|
||||
httpRequestsChart.data.datasets[1].data = [snapshot.http.lastMinuteErrors || 0];
|
||||
httpRequestsChart.update("none");
|
||||
}
|
||||
|
||||
if (memoryChart) {
|
||||
memoryChart.data.labels = [label];
|
||||
memoryChart.data.datasets[0].data = [snapshot.server.memoryUsage.heapUsed || 0];
|
||||
memoryChart.data.datasets[1].data = [snapshot.server.memoryUsage.rss || 0];
|
||||
memoryChart.data.datasets[2].data = [snapshot.server.systemMemory.usedPercent || 0];
|
||||
memoryChart.update("none");
|
||||
}
|
||||
|
||||
if (eventLoopChart) {
|
||||
eventLoopChart.data.labels = [label];
|
||||
eventLoopChart.data.datasets[0].data = [snapshot.eventLoop.p95 || 0];
|
||||
eventLoopChart.data.datasets[1].data = [snapshot.eventLoop.avg || 0];
|
||||
eventLoopChart.update("none");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = history.map((h) => formatTime(h.timestamp));
|
||||
const clientsData = history.map((h) => h.clientsOnline);
|
||||
const labels = points.map((h) => formatTime(h.timestamp));
|
||||
const clientsData = points.map((h) => h.clientsOnline || 0);
|
||||
|
||||
clientsChart.data.labels = labels;
|
||||
clientsChart.data.datasets[0].data = clientsData;
|
||||
clientsChart.update("none");
|
||||
|
||||
const commandsData = history.map((h) => h.commandsPerMinute);
|
||||
const commandsData = points.map((h) => h.commandsPerMinute || 0);
|
||||
|
||||
commandsChart.data.labels = labels;
|
||||
commandsChart.data.datasets[0].data = commandsData;
|
||||
commandsChart.update("none");
|
||||
|
||||
if (bandwidthChart) {
|
||||
bandwidthChart.data.labels = labels;
|
||||
bandwidthChart.data.datasets[0].data = points.map((h) => h.bandwidthSent || 0);
|
||||
bandwidthChart.data.datasets[1].data = points.map((h) => h.bandwidthReceived || 0);
|
||||
bandwidthChart.update("none");
|
||||
}
|
||||
|
||||
if (httpChart) {
|
||||
httpChart.data.labels = labels;
|
||||
httpChart.data.datasets[0].data = points.map((h) => h.httpLatencyP99 || 0);
|
||||
httpChart.data.datasets[1].data = points.map((h) => h.httpLatencyP95 || 0);
|
||||
httpChart.data.datasets[2].data = points.map((h) => h.httpLatencyAvg || 0);
|
||||
httpChart.update("none");
|
||||
}
|
||||
|
||||
if (httpRequestsChart) {
|
||||
httpRequestsChart.data.labels = labels;
|
||||
httpRequestsChart.data.datasets[0].data = points.map((h) => h.httpRequestsPerMinute || 0);
|
||||
httpRequestsChart.data.datasets[1].data = points.map((h) => h.httpErrorsPerMinute || 0);
|
||||
httpRequestsChart.update("none");
|
||||
}
|
||||
|
||||
if (memoryChart) {
|
||||
memoryChart.data.labels = labels;
|
||||
memoryChart.data.datasets[0].data = points.map((h) => h.heapUsed || 0);
|
||||
memoryChart.data.datasets[1].data = points.map((h) => h.rss || 0);
|
||||
memoryChart.data.datasets[2].data = points.map((h) => h.systemMemoryUsedPercent || 0);
|
||||
memoryChart.update("none");
|
||||
}
|
||||
|
||||
if (eventLoopChart) {
|
||||
eventLoopChart.data.labels = labels;
|
||||
eventLoopChart.data.datasets[0].data = points.map((h) => h.eventLoopP95 || 0);
|
||||
eventLoopChart.data.datasets[1].data = points.map((h) => h.eventLoopAvg || 0);
|
||||
eventLoopChart.update("none");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetrics() {
|
||||
try {
|
||||
const response = await fetch("/api/metrics", {
|
||||
const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
@@ -589,7 +1061,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
await fetchMetrics();
|
||||
|
||||
setInterval(fetchMetrics, 2000);
|
||||
setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
||||
|
||||
@@ -12,13 +12,19 @@ import { createAdaptiveNavController } from "./nav/layout.js";
|
||||
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
|
||||
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||
import { init as initCommandPalette } from "./command-palette.js";
|
||||
import {
|
||||
installPageResourceTracker,
|
||||
runWithoutPageTracking,
|
||||
setupSoftNavigation,
|
||||
startPageTracking,
|
||||
} from "./soft-nav.js";
|
||||
|
||||
const host = document.getElementById("top-nav");
|
||||
if (host) {
|
||||
const refs = mountNav(host);
|
||||
initCommandPalette();
|
||||
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);
|
||||
|
||||
@@ -30,6 +36,7 @@ if (host) {
|
||||
const activeMap = {
|
||||
"/": "nav-clients",
|
||||
"/metrics": "metrics-link",
|
||||
"/graph": "graph-link",
|
||||
"/screenshots": "screenshots-link",
|
||||
"/logs": "logs-link",
|
||||
"/scripts": "scripts-link",
|
||||
@@ -44,13 +51,18 @@ if (host) {
|
||||
"/purgatory": "enrollment-link",
|
||||
};
|
||||
|
||||
loadPluginNavItems(activeMap).then(() => {
|
||||
const activeId = activeMap[path];
|
||||
const applyActivePath = (nextPath = window.location.pathname) => {
|
||||
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) {
|
||||
const el = document.getElementById(activeId);
|
||||
if (el) {
|
||||
el.classList.add("nav-active");
|
||||
// Also expand the parent sidebar group if applicable
|
||||
const group = el.closest(".sb-group");
|
||||
if (group) {
|
||||
const btn = group.querySelector(".sb-group-btn");
|
||||
@@ -62,6 +74,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) {
|
||||
@@ -91,14 +111,14 @@ if (host) {
|
||||
if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) {
|
||||
refs.accountSettingsBtn.dataset.boundSettings = "true";
|
||||
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 enabled = getNotificationsEnabled();
|
||||
if (refs.notifyToggle) {
|
||||
@@ -139,6 +159,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() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
@@ -178,19 +224,25 @@ if (host) {
|
||||
}
|
||||
|
||||
import("./chat-widget.js").then((chatWidget) => {
|
||||
chatWidget.init();
|
||||
runWithoutPageTracking(() => {
|
||||
chatWidget.init();
|
||||
|
||||
if (chatWidget.isHidden() && refs.navUtility) {
|
||||
const restoreBtn = document.createElement("button");
|
||||
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.title = "Show team chat";
|
||||
restoreBtn.innerHTML = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
|
||||
restoreBtn.addEventListener("click", () => {
|
||||
chatWidget.show();
|
||||
restoreBtn.remove();
|
||||
});
|
||||
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
|
||||
}
|
||||
if (chatWidget.isHidden() && refs.navUtility) {
|
||||
const restoreBtn = document.createElement("button");
|
||||
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.title = "Show team chat";
|
||||
restoreBtn.innerHTML = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
|
||||
restoreBtn.addEventListener("click", () => {
|
||||
chatWidget.show();
|
||||
restoreBtn.remove();
|
||||
});
|
||||
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
installPageResourceTracker();
|
||||
setupSoftNavigation(host, { onPathChange: applyActivePath });
|
||||
startPageTracking();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const NAV_GROUPS = [
|
||||
iconColor: "text-emerald-400",
|
||||
children: [
|
||||
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
||||
{ href: "/graph", label: "Graph", icon: "fa-diagram-project", iconColor: "text-cyan-400", linkId: "graph-link" },
|
||||
{ href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -111,6 +111,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
let clientOs = "";
|
||||
let clientIsAdmin = 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() {
|
||||
h264TimestampUs = 0;
|
||||
@@ -730,6 +739,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
|
||||
function handleStatus(msg) {
|
||||
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") {
|
||||
scheduleOffline(msg.reason);
|
||||
return;
|
||||
@@ -880,10 +897,26 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
return;
|
||||
}
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
rdDebug("send skipped websocket not open", {
|
||||
type,
|
||||
readyState: ws.readyState,
|
||||
streamState,
|
||||
desiredStreaming,
|
||||
});
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1021,6 +1054,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
const res = await fetch("/api/clients");
|
||||
const data = await res.json();
|
||||
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) {
|
||||
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
|
||||
clientOs = (client.os || "").toLowerCase();
|
||||
@@ -1115,6 +1155,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
|
||||
startBtn.addEventListener("click", function () {
|
||||
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 (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) {
|
||||
return;
|
||||
@@ -1132,6 +1185,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
pushResolution();
|
||||
desiredStreaming = true;
|
||||
lastFrameAt = 0;
|
||||
firstFrameLogged = false;
|
||||
resetH264SessionState();
|
||||
setStreamState("starting", "Starting stream");
|
||||
if (mode === "relayed") {
|
||||
@@ -1267,6 +1321,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
function markFrameReceived() {
|
||||
lastFrameAt = performance.now();
|
||||
clearOfflineTimer();
|
||||
if (!firstFrameLogged) {
|
||||
firstFrameLogged = true;
|
||||
rdDebug("first frame received", {
|
||||
streamState,
|
||||
desiredStreaming,
|
||||
canvas: { width: canvas.width, height: canvas.height },
|
||||
transport: getWebrtcMode(),
|
||||
});
|
||||
}
|
||||
if (streamState !== "streaming" && desiredStreaming) {
|
||||
setStreamState("streaming", "Streaming");
|
||||
}
|
||||
@@ -1700,6 +1763,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
});
|
||||
|
||||
ws.addEventListener("open", function () {
|
||||
rdDebug("ws open", {
|
||||
url: ws.url,
|
||||
readyState: ws.readyState,
|
||||
clientId,
|
||||
});
|
||||
if (qualitySlider) {
|
||||
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;
|
||||
disconnectAudio();
|
||||
destroyVideoDecoder();
|
||||
@@ -1729,7 +1804,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
setStreamState("disconnected", "Disconnected");
|
||||
});
|
||||
|
||||
ws.addEventListener("error", function () {
|
||||
ws.addEventListener("error", function (event) {
|
||||
console.warn("rd: ws error", event);
|
||||
destroyVideoDecoder();
|
||||
stopAllWebrtc();
|
||||
setStreamState("error", "WebSocket error");
|
||||
|
||||
@@ -124,7 +124,7 @@ function formatDate(timestamp) {
|
||||
}
|
||||
|
||||
function canManageClientBans(role) {
|
||||
return role === "admin" || role === "operator";
|
||||
return userHas("network:manage-bans");
|
||||
}
|
||||
|
||||
function isAdmin(role) {
|
||||
@@ -135,6 +135,12 @@ function userHas(perm) {
|
||||
return !!currentUser?.permissions?.includes(perm);
|
||||
}
|
||||
|
||||
function requireUiPermission(perm, message = "You do not have permission for this setting.") {
|
||||
if (userHas(perm)) return true;
|
||||
showMessage(message, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyPermissionVisibility() {
|
||||
document.querySelectorAll("[data-permission]").forEach((el) => {
|
||||
const perm = el.dataset.permission;
|
||||
@@ -164,6 +170,91 @@ function applyPermissionVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPermissionsOverview() {
|
||||
const container = document.getElementById("settings-permissions-summary");
|
||||
if (!container || !currentUser) return;
|
||||
|
||||
const granted = new Set(currentUser.permissions || []);
|
||||
let catalog = [];
|
||||
try {
|
||||
const res = await fetch("/api/permissions", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
catalog = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const byId = new Map(catalog.map((perm) => [perm.id, perm.description]));
|
||||
const groups = [
|
||||
{
|
||||
title: "User Administration",
|
||||
icon: "fa-users-gear",
|
||||
perms: ["users:manage", "audit:view"],
|
||||
},
|
||||
{
|
||||
title: "Client Operations",
|
||||
icon: "fa-desktop",
|
||||
perms: [
|
||||
"clients:control",
|
||||
"clients:build",
|
||||
"clients:metadata",
|
||||
"clients:disconnect",
|
||||
"clients:reconnect",
|
||||
"clients:uninstall",
|
||||
"clients:elevate",
|
||||
"clients:winre",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "System Settings",
|
||||
icon: "fa-sliders",
|
||||
perms: [
|
||||
"system:security",
|
||||
"system:tls",
|
||||
"system:registration",
|
||||
"system:notifications",
|
||||
"system:chat",
|
||||
"system:appearance",
|
||||
"system:thumbnails",
|
||||
"system:build-limits",
|
||||
"system:export-import",
|
||||
"system:health",
|
||||
"system:health:manage",
|
||||
"system:profiler",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
container.innerHTML = groups
|
||||
.map((group) => {
|
||||
const rows = group.perms
|
||||
.map((perm) => {
|
||||
const has = granted.has(perm);
|
||||
return `
|
||||
<div class="flex items-start justify-between gap-3 rounded bg-slate-950/70 border border-slate-800 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-xs ${has ? "text-slate-100" : "text-slate-500"}">${escapeHtml(perm)}</div>
|
||||
<div class="text-[11px] text-slate-500 leading-snug">${escapeHtml(byId.get(perm) || "")}</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs ${has ? "text-emerald-300" : "text-slate-600"}">
|
||||
<i class="fa-solid ${has ? "fa-circle-check" : "fa-circle-minus"}"></i>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
return `
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid ${group.icon} text-violet-400"></i>${escapeHtml(group.title)}
|
||||
</h3>
|
||||
<div class="space-y-2">${rows}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getPasswordRequirementsText() {
|
||||
const minLength = Number(securityConfig?.passwordMinLength) || 6;
|
||||
const requirements = [`min ${minLength} chars`];
|
||||
@@ -256,7 +347,7 @@ function applySecurityForm() {
|
||||
async function loadSecurityPolicy() {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (!isAdmin(currentUser.role)) {
|
||||
if (!userHas("system:security")) {
|
||||
securityPermissionNote.classList.remove("hidden");
|
||||
setSecurityFormDisabled(true);
|
||||
securityConfig = {
|
||||
@@ -301,7 +392,7 @@ function applyTlsForm() {
|
||||
async function loadTlsSettings() {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (!isAdmin(currentUser.role)) {
|
||||
if (!userHas("system:tls")) {
|
||||
tlsPermissionNote.classList.remove("hidden");
|
||||
setTlsFormDisabled(true);
|
||||
tlsConfig = {
|
||||
@@ -559,8 +650,7 @@ function savePrefs(event) {
|
||||
|
||||
async function saveSecurityPolicy(event) {
|
||||
event.preventDefault();
|
||||
if (!isAdmin(currentUser?.role)) {
|
||||
showMessage("Admin role required.", "error");
|
||||
if (!requireUiPermission("system:security", "Security policy permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -598,8 +688,7 @@ async function saveSecurityPolicy(event) {
|
||||
|
||||
async function saveTlsSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!isAdmin(currentUser?.role)) {
|
||||
showMessage("Admin role required.", "error");
|
||||
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -633,8 +722,7 @@ async function saveTlsSettings(event) {
|
||||
}
|
||||
|
||||
async function runCertbotAutoSetup() {
|
||||
if (!isAdmin(currentUser?.role)) {
|
||||
showMessage("Admin role required.", "error");
|
||||
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1079,7 +1167,7 @@ function initSettingsSidebar() {
|
||||
async function loadAppearanceSettings() {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (!isAdmin(currentUser.role)) {
|
||||
if (!userHas("system:appearance")) {
|
||||
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
|
||||
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
|
||||
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
|
||||
@@ -1104,8 +1192,7 @@ async function loadAppearanceSettings() {
|
||||
|
||||
async function saveAppearanceSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!isAdmin(currentUser?.role)) {
|
||||
showMessage("Admin role required.", "error");
|
||||
if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1136,7 +1223,7 @@ const chatSettingsForm = document.getElementById("chat-settings-form");
|
||||
const chatRetentionDaysInput = document.getElementById("chat-retention-days");
|
||||
|
||||
async function loadChatSettings() {
|
||||
if (!isAdmin(currentUser?.role)) return;
|
||||
if (!userHas("system:chat")) return;
|
||||
if (chatSettingsSection) chatSettingsSection.classList.remove("hidden");
|
||||
try {
|
||||
const res = await fetch("/api/settings/chat", { credentials: "include" });
|
||||
@@ -1151,8 +1238,7 @@ async function loadChatSettings() {
|
||||
|
||||
async function saveChatSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!isAdmin(currentUser?.role)) {
|
||||
showMessage("Admin role required.", "error");
|
||||
if (!requireUiPermission("system:chat", "Team chat settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1198,6 +1284,10 @@ function showExportImportMessage(text, type = "ok") {
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/export", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
@@ -1227,6 +1317,11 @@ async function exportSettings() {
|
||||
}
|
||||
|
||||
async function importSettings(event) {
|
||||
if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -1486,6 +1581,8 @@ async function handleRemoveInactiveSessions() {
|
||||
// ── Registration Settings ──────────────────────────────────────────────────
|
||||
|
||||
async function loadRegistrationSettings() {
|
||||
if (!userHas("system:registration")) return;
|
||||
|
||||
try {
|
||||
const [regRes, groupsRes] = await Promise.all([
|
||||
fetch("/api/settings/registration", { credentials: "include" }),
|
||||
@@ -1522,8 +1619,8 @@ async function loadRegistrationSettings() {
|
||||
}
|
||||
|
||||
updateRegSubsections(reg.mode || "off");
|
||||
if (reg.mode === "key") loadRegistrationKeys();
|
||||
if (reg.mode === "approval") loadPendingRegistrations();
|
||||
if (reg.mode === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||
if (reg.mode === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||
} catch (e) {
|
||||
console.error("Failed to load registration settings", e);
|
||||
}
|
||||
@@ -1532,8 +1629,8 @@ async function loadRegistrationSettings() {
|
||||
function updateRegSubsections(mode) {
|
||||
const keySection = document.getElementById("reg-key-section");
|
||||
const pendingSection = document.getElementById("reg-pending-section");
|
||||
if (keySection) keySection.classList.toggle("hidden", mode !== "key");
|
||||
if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval");
|
||||
if (keySection) keySection.classList.toggle("hidden", mode !== "key" || !userHas("users:manage"));
|
||||
if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval" || !userHas("users:manage"));
|
||||
}
|
||||
|
||||
function showRegMsg(text, type) {
|
||||
@@ -1547,6 +1644,10 @@ function showRegMsg(text, type) {
|
||||
|
||||
async function saveRegistrationSettings(e) {
|
||||
e.preventDefault();
|
||||
if (!requireUiPermission("system:registration", "Registration settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = document.getElementById("reg-mode")?.value;
|
||||
const defaultRole = document.getElementById("reg-default-role")?.value;
|
||||
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
|
||||
@@ -1568,14 +1669,15 @@ async function saveRegistrationSettings(e) {
|
||||
}
|
||||
showRegMsg("Registration settings saved.", "success");
|
||||
updateRegSubsections(mode);
|
||||
if (mode === "key") loadRegistrationKeys();
|
||||
if (mode === "approval") loadPendingRegistrations();
|
||||
if (mode === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||
if (mode === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||
} catch {
|
||||
showRegMsg("Network error.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegistrationKeys() {
|
||||
if (!userHas("users:manage")) return;
|
||||
const tbody = document.getElementById("reg-keys-tbody");
|
||||
if (!tbody) return;
|
||||
try {
|
||||
@@ -1608,6 +1710,10 @@ async function loadRegistrationKeys() {
|
||||
}
|
||||
|
||||
async function generateRegistrationKeys() {
|
||||
if (!requireUiPermission("users:manage", "User management permission required to generate registration keys.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Number(document.getElementById("reg-key-count")?.value) || 1;
|
||||
const label = document.getElementById("reg-key-label")?.value || undefined;
|
||||
const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined;
|
||||
@@ -1631,6 +1737,10 @@ async function generateRegistrationKeys() {
|
||||
}
|
||||
|
||||
async function deleteRegistrationKey(keyId) {
|
||||
if (!requireUiPermission("users:manage", "User management permission required to delete registration keys.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/registration/keys/${keyId}`, {
|
||||
method: "DELETE",
|
||||
@@ -1641,6 +1751,7 @@ async function deleteRegistrationKey(keyId) {
|
||||
}
|
||||
|
||||
async function loadPendingRegistrations() {
|
||||
if (!userHas("users:manage")) return;
|
||||
const tbody = document.getElementById("reg-pending-tbody");
|
||||
const emptyEl = document.getElementById("reg-pending-empty");
|
||||
if (!tbody) return;
|
||||
@@ -1673,6 +1784,10 @@ async function loadPendingRegistrations() {
|
||||
}
|
||||
|
||||
async function handlePendingAction(id, action) {
|
||||
if (!requireUiPermission("users:manage", "User management permission required to approve registrations.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/registration/pending/${id}/${action}`, {
|
||||
method: "POST",
|
||||
@@ -1697,8 +1812,8 @@ function initRegistrationHandlers() {
|
||||
const modeEl = document.getElementById("reg-mode");
|
||||
if (modeEl) modeEl.addEventListener("change", () => {
|
||||
updateRegSubsections(modeEl.value);
|
||||
if (modeEl.value === "key") loadRegistrationKeys();
|
||||
if (modeEl.value === "approval") loadPendingRegistrations();
|
||||
if (modeEl.value === "key" && userHas("users:manage")) loadRegistrationKeys();
|
||||
if (modeEl.value === "approval" && userHas("users:manage")) loadPendingRegistrations();
|
||||
});
|
||||
|
||||
const genBtn = document.getElementById("reg-key-generate-btn");
|
||||
@@ -1726,6 +1841,8 @@ function initRegistrationHandlers() {
|
||||
// ── Build Rate Limit Settings ──────────────────────────────────────────────
|
||||
|
||||
async function loadBuildRateLimitSettings() {
|
||||
if (!userHas("system:build-limits")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
@@ -1753,6 +1870,10 @@ function showBrlMsg(text, type) {
|
||||
|
||||
async function saveBuildRateLimitSettings(e) {
|
||||
e.preventDefault();
|
||||
if (!requireUiPermission("system:build-limits", "Build rate limit settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5;
|
||||
const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1;
|
||||
const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3;
|
||||
@@ -1783,6 +1904,8 @@ function initBuildRateLimitHandlers() {
|
||||
// ── Thumbnail Settings ────────────────────────────────────────────────────
|
||||
|
||||
async function loadThumbnailSettings() {
|
||||
if (!userHas("system:thumbnails")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/thumbnails", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
@@ -1808,6 +1931,10 @@ function showThumbnailsMsg(text, type) {
|
||||
|
||||
async function saveThumbnailSettings(e) {
|
||||
e.preventDefault();
|
||||
if (!requireUiPermission("system:thumbnails", "Thumbnail settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked;
|
||||
const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked;
|
||||
try {
|
||||
@@ -1858,6 +1985,8 @@ function formatUptime(totalSeconds) {
|
||||
let latestServerCpuProfile = null;
|
||||
|
||||
async function loadHealthStats() {
|
||||
if (!userHas("system:health")) return;
|
||||
|
||||
const loading = document.getElementById("health-loading");
|
||||
const content = document.getElementById("health-content");
|
||||
if (loading) { loading.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...'; loading.classList.remove("hidden"); }
|
||||
@@ -2060,6 +2189,10 @@ function renderServerProfileResults(data) {
|
||||
}
|
||||
|
||||
async function runServerProfile() {
|
||||
if (!requireUiPermission("system:profiler", "Server profiler permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("server-profile-btn");
|
||||
const durationEl = document.getElementById("server-profile-duration");
|
||||
const durationMs = Number(durationEl?.value || 5000);
|
||||
@@ -2113,6 +2246,10 @@ function downloadServerCpuProfile() {
|
||||
}
|
||||
|
||||
async function runGC() {
|
||||
if (!requireUiPermission("system:health:manage", "Server health maintenance permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gcBtn = document.getElementById("health-gc-btn");
|
||||
const msgEl = document.getElementById("health-gc-message");
|
||||
if (gcBtn) gcBtn.disabled = true;
|
||||
@@ -2139,6 +2276,14 @@ function initHealthHandlers() {
|
||||
const gcBtn = document.getElementById("health-gc-btn");
|
||||
const profileBtn = document.getElementById("server-profile-btn");
|
||||
const downloadProfileBtn = document.getElementById("server-profile-download-btn");
|
||||
if (gcBtn && !userHas("system:health:manage")) {
|
||||
gcBtn.disabled = true;
|
||||
gcBtn.title = "Requires system:health:manage";
|
||||
}
|
||||
if (profileBtn && !userHas("system:profiler")) {
|
||||
profileBtn.disabled = true;
|
||||
profileBtn.title = "Requires system:profiler";
|
||||
}
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats);
|
||||
if (gcBtn) gcBtn.addEventListener("click", runGC);
|
||||
if (profileBtn) profileBtn.addEventListener("click", runServerProfile);
|
||||
@@ -2165,14 +2310,24 @@ async function init() {
|
||||
await loadChatSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
if (userHas("system:configure")) {
|
||||
if (userHas("users:manage")) {
|
||||
await renderPermissionsOverview();
|
||||
}
|
||||
|
||||
if (userHas("system:registration")) {
|
||||
await loadRegistrationSettings();
|
||||
await loadBuildRateLimitSettings();
|
||||
await loadThumbnailSettings();
|
||||
await loadHealthStats();
|
||||
initRegistrationHandlers();
|
||||
}
|
||||
if (userHas("system:build-limits")) {
|
||||
await loadBuildRateLimitSettings();
|
||||
initBuildRateLimitHandlers();
|
||||
}
|
||||
if (userHas("system:thumbnails")) {
|
||||
await loadThumbnailSettings();
|
||||
initThumbnailHandlers();
|
||||
}
|
||||
if (userHas("system:health")) {
|
||||
await loadHealthStats();
|
||||
initHealthHandlers();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="/assets/nav-prelude.js"></script>
|
||||
<link rel="modulepreload" href="/assets/nav.js" />
|
||||
<title>Overlord Graph</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/600.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/700.css" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
|
||||
<script defer type="module" src="/assets/client-graph.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
<main class="graph-shell">
|
||||
<section class="graph-toolbar" aria-label="Client graph controls">
|
||||
<div class="graph-title">
|
||||
<i class="fa-solid fa-diagram-project"></i>
|
||||
<div>
|
||||
<h1>Client Graph</h1>
|
||||
<p id="graph-summary">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-controls">
|
||||
<label class="graph-field">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<input id="graph-search" type="search" autocomplete="off" placeholder="Search clients" />
|
||||
</label>
|
||||
<select id="graph-status" class="graph-select" aria-label="Status filter">
|
||||
<option value="all">All status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
<select id="graph-layout" class="graph-select" aria-label="Graph layout">
|
||||
<option value="cose">Force</option>
|
||||
<option value="breadthfirst">Tree</option>
|
||||
<option value="circle">Circle</option>
|
||||
<option value="grid">Grid</option>
|
||||
</select>
|
||||
<button id="graph-refresh" class="button ghost" type="button" title="Refresh graph" aria-label="Refresh graph">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<button id="graph-fit" class="button ghost" type="button" title="Fit graph" aria-label="Fit graph">
|
||||
<i class="fa-solid fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graph-workspace">
|
||||
<div id="client-graph" class="graph-canvas" role="application" aria-label="Client relationship graph"></div>
|
||||
<aside class="graph-inspector" aria-label="Selected graph item">
|
||||
<div class="graph-inspector-kicker">Selection</div>
|
||||
<h2 id="graph-detail-title">Nothing selected</h2>
|
||||
<div id="graph-detail-meta" class="graph-detail-meta"></div>
|
||||
<div id="graph-detail-actions" class="graph-detail-actions"></div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,11 +12,13 @@
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/vendor/flag-icons/css/flag-icons.min.css" />
|
||||
<link rel="stylesheet" href="/vendor/gridstack/gridstack.min.css" />
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer src="/vendor/animejs/anime.min.js"></script>
|
||||
<script defer src="/vendor/chart.js/chart.umd.js"></script>
|
||||
<script defer src="/vendor/gridstack/gridstack-all.js"></script>
|
||||
<link rel="preload" href="/api/clients?page=1&pageSize=12&q=&sort=stable&status=all&os=all&country=all&group=all" as="fetch" crossorigin="use-credentials" />
|
||||
<link rel="preload" href="/api/auth/me" as="fetch" crossorigin="use-credentials" />
|
||||
<link rel="preload" href="/api/groups" as="fetch" crossorigin="use-credentials" />
|
||||
@@ -34,6 +36,18 @@
|
||||
<span id="server-version-text" class="dashboard-version-text">Server version: loading...</span>
|
||||
</div>
|
||||
<div class="dashboard-stats-controls" aria-label="Status overview controls">
|
||||
<details id="dashboard-card-menu" class="dashboard-card-menu">
|
||||
<summary class="dashboard-stats-toggle" title="Dashboard cards" aria-label="Dashboard cards">
|
||||
<i class="fa-solid fa-table-cells-large"></i>
|
||||
</summary>
|
||||
<div id="dashboard-card-menu-panel" class="dashboard-card-menu-panel">
|
||||
<label><input type="checkbox" data-dashboard-card-toggle="online" checked /> Online</label>
|
||||
<label><input type="checkbox" data-dashboard-card-toggle="sessions" checked /> Sessions</label>
|
||||
<label><input type="checkbox" data-dashboard-card-toggle="trend" checked /> Clients Online</label>
|
||||
<label><input type="checkbox" data-dashboard-card-toggle="fleet" checked /> Operating Systems</label>
|
||||
<label><input type="checkbox" data-dashboard-card-toggle="pulse" checked /> Server Pulse</label>
|
||||
</div>
|
||||
</details>
|
||||
<button id="dashboard-stats-reset" class="dashboard-stats-toggle" type="button" title="Reset status overview">
|
||||
<i class="fa-solid fa-arrow-rotate-left"></i>
|
||||
</button>
|
||||
@@ -42,79 +56,89 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-stats-content" class="dashboard-stats-content">
|
||||
<div class="dashboard-stat-card dashboard-stat-card--online" data-dashboard-card="online" draggable="true">
|
||||
<div class="dashboard-stat-topline">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
<div id="dash-online-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-total-count">0 total</span>
|
||||
<span id="dash-online-ratio">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-stat-card dashboard-stat-card--sessions" data-dashboard-card="sessions" draggable="true">
|
||||
<div class="dashboard-stat-topline">
|
||||
<i class="fa-solid fa-wave-square"></i>
|
||||
<span>Sessions</span>
|
||||
</div>
|
||||
<div id="dash-session-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-command-minute">0 cmd/min</span>
|
||||
<span id="dash-http-errors">0 errors</span>
|
||||
<div id="dashboard-stats-content" class="dashboard-stats-content grid-stack">
|
||||
<div class="grid-stack-item" data-dashboard-card="online" gs-id="online" gs-x="0" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
|
||||
<div class="grid-stack-item-content dashboard-stat-card dashboard-stat-card--online">
|
||||
<div class="dashboard-stat-topline">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
<div id="dash-online-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-total-count">0 total</span>
|
||||
<span id="dash-online-ratio">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-chart-card" data-dashboard-card="trend" draggable="true">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Past Week</span>
|
||||
<strong>Clients Online</strong>
|
||||
<div class="grid-stack-item" data-dashboard-card="sessions" gs-id="sessions" gs-x="2" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
|
||||
<div class="grid-stack-item-content dashboard-stat-card dashboard-stat-card--sessions">
|
||||
<div class="dashboard-stat-topline">
|
||||
<i class="fa-solid fa-wave-square"></i>
|
||||
<span>Sessions</span>
|
||||
</div>
|
||||
<div id="dash-session-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-command-minute">0 cmd/min</span>
|
||||
<span id="dash-http-errors">0 errors</span>
|
||||
</div>
|
||||
</div>
|
||||
<span id="dash-trend-status" class="dashboard-chart-pill">Live</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap">
|
||||
<canvas id="dash-online-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-chart-card" data-dashboard-card="fleet" draggable="true">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Fleet Mix</span>
|
||||
<strong>Operating Systems</strong>
|
||||
<div class="grid-stack-item" data-dashboard-card="trend" gs-id="trend" gs-x="4" gs-y="0" gs-w="3" gs-h="2" gs-min-w="2" gs-min-h="1">
|
||||
<div class="grid-stack-item-content dashboard-chart-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Past Week</span>
|
||||
<strong>Clients Online</strong>
|
||||
</div>
|
||||
<span id="dash-trend-status" class="dashboard-chart-pill">Live</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap">
|
||||
<canvas id="dash-online-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<span id="dash-os-leader" class="dashboard-chart-pill">None</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap dashboard-chart-wrap--donut">
|
||||
<canvas id="dash-os-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-health-card" data-dashboard-card="pulse" draggable="true">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Status</span>
|
||||
<strong>Server Pulse</strong>
|
||||
<div class="grid-stack-item" data-dashboard-card="fleet" gs-id="fleet" gs-x="7" gs-y="0" gs-w="3" gs-h="2" gs-min-w="2" gs-min-h="1">
|
||||
<div class="grid-stack-item-content dashboard-chart-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Fleet Mix</span>
|
||||
<strong>Operating Systems</strong>
|
||||
</div>
|
||||
<span id="dash-os-leader" class="dashboard-chart-pill">None</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap dashboard-chart-wrap--donut">
|
||||
<canvas id="dash-os-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<span id="dash-last-refresh" class="dashboard-chart-pill">Waiting</span>
|
||||
</div>
|
||||
<div class="dashboard-health-list">
|
||||
<div class="dashboard-health-row" data-pulse-row="api" draggable="true">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>API</span>
|
||||
<strong id="dash-api-status">Live</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row" data-pulse-row="memory" draggable="true">
|
||||
<span id="dash-memory-dot" class="dashboard-live-dot is-live"></span>
|
||||
<span>Memory</span>
|
||||
<strong id="dash-memory-status">0%</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row" data-pulse-row="ping" draggable="true">
|
||||
<span id="dash-ping-dot" class="dashboard-live-dot is-muted"></span>
|
||||
<span>Ping Avg</span>
|
||||
<strong id="dash-ping-status">-</strong>
|
||||
<div class="grid-stack-item" data-dashboard-card="pulse" gs-id="pulse" gs-x="10" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
|
||||
<div class="grid-stack-item-content dashboard-health-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Status</span>
|
||||
<strong>Server Pulse</strong>
|
||||
</div>
|
||||
<span id="dash-last-refresh" class="dashboard-chart-pill">Waiting</span>
|
||||
</div>
|
||||
<div class="dashboard-health-list">
|
||||
<div class="dashboard-health-row" data-pulse-row="api" draggable="true">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>API</span>
|
||||
<strong id="dash-api-status">Live</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row" data-pulse-row="memory" draggable="true">
|
||||
<span id="dash-memory-dot" class="dashboard-live-dot is-live"></span>
|
||||
<span>Memory</span>
|
||||
<strong id="dash-memory-status">0%</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row" data-pulse-row="ping" draggable="true">
|
||||
<span id="dash-ping-dot" class="dashboard-live-dot is-muted"></span>
|
||||
<span>Ping Avg</span>
|
||||
<strong id="dash-ping-status">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="dashboard-stats-resizer"
|
||||
|
||||
@@ -145,6 +145,101 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operational Pressure Charts -->
|
||||
<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">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-network-wired text-orange-400"></i>
|
||||
Network Throughput
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="bandwidth-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-arrow-right-arrow-left text-emerald-400"></i>
|
||||
HTTP Requests
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="http-requests-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-gauge-high text-rose-400"></i>
|
||||
HTTP Latency
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="http-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-memory text-cyan-400"></i>
|
||||
Runtime Memory
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="memory-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-stopwatch text-amber-400"></i>
|
||||
Event Loop Lag
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="event-loop-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slow HTTP Routes -->
|
||||
<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-route text-rose-400"></i>
|
||||
Slowest HTTP Routes
|
||||
</h3>
|
||||
<div class="text-xs text-slate-500">Sorted by p95 latency, last minute</div>
|
||||
</div>
|
||||
<div
|
||||
id="http-routes"
|
||||
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 HTTP route samples yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mix Charts -->
|
||||
<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">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-plug text-emerald-400"></i>
|
||||
Session Mix
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="sessions-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fa-brands fa-windows text-cyan-400"></i>
|
||||
Operating Systems
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="os-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Server Stats -->
|
||||
@@ -181,36 +276,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ping Stats -->
|
||||
<!-- Clients by OS -->
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-signal text-blue-400"></i>
|
||||
Ping Statistics
|
||||
<i class="fa-brands fa-windows text-cyan-400"></i>
|
||||
Clients by OS
|
||||
</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Average:</span>
|
||||
<span id="ping-avg">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Min / Max:</span>
|
||||
<span
|
||||
><span id="ping-min">-</span> /
|
||||
<span id="ping-max">-</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Samples:</span>
|
||||
<span id="ping-count">0</span>
|
||||
</div>
|
||||
<div id="clients-by-os" class="space-y-2 text-sm">
|
||||
<div class="text-slate-500">No data</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTTP & Event Loop -->
|
||||
<!-- HTTP, Ping & Event Loop -->
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-wave-square text-amber-400"></i>
|
||||
HTTP & Event Loop
|
||||
HTTP, Ping & Event Loop
|
||||
</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
@@ -222,10 +303,11 @@
|
||||
<span id="http-errors-minute">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Latency avg/p95:</span>
|
||||
<span class="text-slate-400">Latency avg/p95/p99:</span>
|
||||
<span>
|
||||
<span id="http-latency-avg">0</span> /
|
||||
<span id="http-latency-p95">0</span>
|
||||
<span id="http-latency-p95">0</span> /
|
||||
<span id="http-latency-p99">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -239,17 +321,21 @@
|
||||
<span class="text-slate-400">Loop lag max:</span>
|
||||
<span id="event-loop-max">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Distribution -->
|
||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<i class="fa-brands fa-windows text-cyan-400"></i>
|
||||
Clients by OS
|
||||
</h3>
|
||||
<div id="clients-by-os" class="space-y-2 text-sm">
|
||||
<div class="text-slate-500">No data</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Ping avg:</span>
|
||||
<span id="ping-avg">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Ping min/max:</span>
|
||||
<span>
|
||||
<span id="ping-min">-</span> /
|
||||
<span id="ping-max">-</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-400">Ping samples:</span>
|
||||
<span id="ping-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,15 +90,16 @@
|
||||
<a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
|
||||
|
||||
<span class="settings-nav-group-label">Admin</span>
|
||||
<a data-target="section-security" class="settings-nav-link" data-permission="system:configure" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||
<a data-target="section-tls" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||
<a data-target="registration-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:configure" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||
<a data-target="section-health" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
|
||||
<a data-target="section-permissions-overview" class="settings-nav-link" data-permission="users:manage" style="--accent: 167 139 250"><i class="fa-solid fa-user-shield w-4 text-violet-400"></i><span>Access Overview</span></a>
|
||||
<a data-target="section-security" class="settings-nav-link" data-permission="system:security" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||
<a data-target="section-tls" class="settings-nav-link" data-permission="system:tls" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:chat" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||
<a data-target="registration-section" class="settings-nav-link" data-permission="system:registration" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:build-limits" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:export-import" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||
<a data-target="section-health" class="settings-nav-link" data-permission="system:health" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -552,8 +553,30 @@
|
||||
|
||||
<!-- ========== ADMIN ========== -->
|
||||
|
||||
<!-- Security Policy -->
|
||||
<section id="section-security" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<!-- Access Overview -->
|
||||
<section id="section-permissions-overview" data-permission="users:manage" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30">
|
||||
<i class="fa-solid fa-user-shield text-violet-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Access Overview</h2>
|
||||
<p class="text-sm text-slate-400">Review your effective permissions and jump to user access management.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-permissions-summary" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"></div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="/users" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-700 hover:bg-violet-600 text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Manage Users
|
||||
</a>
|
||||
<span class="inline-flex items-center gap-2 max-w-full px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-300 text-sm">
|
||||
<i class="fa-solid fa-desktop text-sky-400"></i>
|
||||
<span class="min-w-0 leading-snug">Client access rules are edited from a selected user in Manage Users.</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-security" data-permission="system:security" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
|
||||
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
|
||||
@@ -566,7 +589,7 @@
|
||||
|
||||
<form id="security-form" class="space-y-4">
|
||||
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||
Only admins can update security policy settings.
|
||||
Your permissions do not allow security policy changes.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
@@ -675,7 +698,7 @@
|
||||
</section>
|
||||
|
||||
<!-- TLS Certificates -->
|
||||
<section id="section-tls" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-tls" data-permission="system:tls" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
||||
<i class="fa-solid fa-certificate text-cyan-400"></i>
|
||||
@@ -688,7 +711,7 @@
|
||||
|
||||
<form id="tls-form" class="space-y-4">
|
||||
<div id="tls-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||
Only admins can update TLS settings.
|
||||
Your permissions do not allow TLS setting changes.
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
@@ -777,7 +800,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Team Chat -->
|
||||
<section id="chat-settings-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="chat-settings-section" data-permission="system:chat" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||
<i class="fa-solid fa-comments text-sky-400"></i>
|
||||
@@ -813,7 +836,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Registration Settings (admin only) -->
|
||||
<section id="registration-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="registration-section" data-permission="system:registration" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||
<i class="fa-solid fa-user-plus text-emerald-400"></i>
|
||||
@@ -920,8 +943,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Screenshot Thumbnails (admin only) -->
|
||||
<section id="thumbnails-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<!-- Screenshot Thumbnails -->
|
||||
<section id="thumbnails-section" data-permission="system:thumbnails" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||
<i class="fa-solid fa-images text-sky-400"></i>
|
||||
@@ -961,7 +984,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Build Rate Limit (admin only) -->
|
||||
<section id="build-rate-limit-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="build-rate-limit-section" data-permission="system:build-limits" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||
<i class="fa-solid fa-gauge-high text-orange-400"></i>
|
||||
@@ -995,7 +1018,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<section id="section-appearance" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="section-appearance" data-permission="system:appearance" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
|
||||
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
|
||||
@@ -1008,7 +1031,7 @@
|
||||
|
||||
<form id="appearance-form" class="space-y-4">
|
||||
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||
Only admins can update custom CSS.
|
||||
Your permissions do not allow custom CSS changes.
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
@@ -1033,7 +1056,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Export & Import -->
|
||||
<section id="export-import-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<section id="export-import-section" data-permission="system:export-import" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<i class="fa-solid fa-file-export text-amber-400"></i>
|
||||
@@ -1081,7 +1104,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Server Health -->
|
||||
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:configure">
|
||||
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:health">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
|
||||
|
||||
@@ -186,6 +186,27 @@ copyFile(
|
||||
path.join(VENDOR, "tabulator", "tabulator_midnight.min.css"),
|
||||
);
|
||||
|
||||
/* ── GridStack ───────────────────────────────────────────────────── */
|
||||
|
||||
console.log("Copying GridStack ...");
|
||||
const gridstackRoot = path.join(NM, "gridstack", "dist");
|
||||
copyFile(
|
||||
path.join(gridstackRoot, "gridstack-all.js"),
|
||||
path.join(VENDOR, "gridstack", "gridstack-all.js"),
|
||||
);
|
||||
copyFile(
|
||||
path.join(gridstackRoot, "gridstack.min.css"),
|
||||
path.join(VENDOR, "gridstack", "gridstack.min.css"),
|
||||
);
|
||||
|
||||
/* ── Cytoscape.js ────────────────────────────────────────────────── */
|
||||
|
||||
console.log("Copying Cytoscape.js ...");
|
||||
copyFile(
|
||||
path.join(NM, "cytoscape", "dist", "cytoscape.min.js"),
|
||||
path.join(VENDOR, "cytoscape", "cytoscape.min.js"),
|
||||
);
|
||||
|
||||
/* ── highlight.js (bundle core + languages) ──────────────────────── */
|
||||
|
||||
console.log("Bundling highlight.js ...");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
|
||||
import { db } from "./db/connection";
|
||||
import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
|
||||
|
||||
const createdClientIds: string[] = [];
|
||||
|
||||
@@ -110,4 +111,105 @@ describe("client list ordering", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("client search index", () => {
|
||||
function getSearchIndexRow(id: string) {
|
||||
return db
|
||||
.query<{ id: string; host: string | null; customTag: string | null; customTagNote: string | null }>(
|
||||
`SELECT id, host, custom_tag as customTag, custom_tag_note as customTagNote
|
||||
FROM client_search_fts
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
test("keeps the FTS search index in sync when client metadata changes", () => {
|
||||
try {
|
||||
const id = `fts-sync-${Date.now().toString(36)}`;
|
||||
|
||||
createTempClient(id, {
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
host: "dispatch-terminal-old",
|
||||
});
|
||||
|
||||
expect(getSearchIndexRow(id)?.host).toBe("dispatch-terminal-old");
|
||||
|
||||
createTempClient(id, {
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
host: "dispatch-terminal-new",
|
||||
});
|
||||
setClientTag(id, "Priority Ops", "Shift handoff workstation");
|
||||
|
||||
const updatedRow = getSearchIndexRow(id);
|
||||
expect(updatedRow?.host).toBe("dispatch-terminal-new");
|
||||
expect(updatedRow?.customTag).toBe("Priority Ops");
|
||||
expect(updatedRow?.customTagNote).toBe("Shift handoff workstation");
|
||||
|
||||
deleteClientRow(id);
|
||||
expect(getSearchIndexRow(id)).toBeNull();
|
||||
} finally {
|
||||
cleanupCreatedClients();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses indexed candidates before fuzzy matching so exact token searches stay fast", () => {
|
||||
try {
|
||||
const prefix = `fts-candidate-${Date.now().toString(36)}`;
|
||||
const id = `${prefix}-ops-node`;
|
||||
|
||||
createTempClient(id, {
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
host: "northbridge-ops-node",
|
||||
});
|
||||
setClientTag(id, "Incident Desk", "CPU spike triage host");
|
||||
|
||||
const result = listClients({
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
search: "northbridge",
|
||||
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 Fuse from "fuse.js";
|
||||
import { getThumbnailSummaries } from "../thumbnails";
|
||||
import { db, dbPath } from "./connection";
|
||||
import "./schema";
|
||||
@@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags(
|
||||
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 {
|
||||
const {
|
||||
page,
|
||||
@@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
const where: string[] = [];
|
||||
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") {
|
||||
where.push("c.online=1");
|
||||
} else if (statusFilter === "offline") {
|
||||
@@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const needsGroupJoinForFilter =
|
||||
!!search ||
|
||||
sort === "group_asc" ||
|
||||
sort === "group_desc" ||
|
||||
(groupFilter !== undefined && groupFilter !== "all");
|
||||
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
||||
|
||||
const orderBy = (() => {
|
||||
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 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";
|
||||
|
||||
const 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}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset)
|
||||
: db
|
||||
.query<any>(
|
||||
`WITH page_clients AS (
|
||||
SELECT ${clientFields}
|
||||
let rows: any[] | undefined;
|
||||
let totalCount = 0;
|
||||
let onlineCount = 0;
|
||||
|
||||
if (search) {
|
||||
const searchWhere = [...where];
|
||||
const searchParams = [...params];
|
||||
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
|
||||
const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit);
|
||||
|
||||
if (ftsCandidateIds) {
|
||||
if (!ftsCandidateIds.length) {
|
||||
rows = [];
|
||||
totalCount = 0;
|
||||
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
|
||||
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}
|
||||
${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);
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset)
|
||||
: db
|
||||
.query<any>(
|
||||
`WITH page_clients AS (
|
||||
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
|
||||
? getFloodSetsWithCache()
|
||||
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
|
||||
rows = rows || [];
|
||||
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
||||
|
||||
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 = {
|
||||
@@ -998,7 +1152,56 @@ export type ClientMetricsSummary = {
|
||||
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 {
|
||||
const now = Date.now();
|
||||
if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) {
|
||||
return cloneClientMetricsSummary(clientMetricsSummaryCache.summary);
|
||||
}
|
||||
|
||||
const counts = db
|
||||
.query<{ total: number; online: number }>(
|
||||
`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();
|
||||
|
||||
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,
|
||||
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||
clientMetricsSummaryCache = {
|
||||
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||
summary,
|
||||
};
|
||||
return cloneClientMetricsSummary(summary);
|
||||
}
|
||||
|
||||
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 counts = db
|
||||
@@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
|
||||
)
|
||||
.all(userId);
|
||||
|
||||
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,
|
||||
};
|
||||
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||
userClientMetricsSummaryCache.set(userId, {
|
||||
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||
summary,
|
||||
});
|
||||
return cloneClientMetricsSummary(summary);
|
||||
}
|
||||
|
||||
export function getOnlineClientCountForUser(userId: number): number {
|
||||
|
||||
@@ -148,6 +148,82 @@ try {
|
||||
} catch {}
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
|
||||
@@ -125,6 +125,31 @@ describe("MetricsCollector", () => {
|
||||
const snap = metrics.getSnapshot();
|
||||
expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0);
|
||||
expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90);
|
||||
expect(snap.http.latencyP99).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
|
||||
test("tracks slow HTTP routes", () => {
|
||||
metrics.recordHttpRequest(15, 200, "GET /api/metrics");
|
||||
metrics.recordHttpRequest(120, 200, "GET /api/clients/:id/rd/ws");
|
||||
metrics.recordHttpRequest(200, 500, "GET /api/clients/:id/rd/ws");
|
||||
|
||||
const snap = metrics.getSnapshot();
|
||||
expect(snap.http.routes[0].route).toBe("GET /api/clients/:id/rd/ws");
|
||||
expect(snap.http.routes[0].countLastMinute).toBe(2);
|
||||
expect(snap.http.routes[0].errorsLastMinute).toBe(1);
|
||||
expect(snap.http.routes[0].latencyP95).toBeGreaterThanOrEqual(120);
|
||||
});
|
||||
|
||||
test("does not count the metrics dashboard poll as HTTP pressure", () => {
|
||||
metrics.recordHttpRequest(100, 200, "GET /api/metrics");
|
||||
metrics.recordHttpRequest(20, 200, "GET /api/clients");
|
||||
|
||||
const snap = metrics.getSnapshot();
|
||||
expect(snap.http.total).toBe(1);
|
||||
expect(snap.http.lastMinute).toBe(1);
|
||||
expect(snap.http.routes.map((route) => route.route)).toEqual([
|
||||
"GET /api/clients",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +202,26 @@ describe("MetricsCollector", () => {
|
||||
expect(history[0].timestamp).toBe(snap.timestamp);
|
||||
});
|
||||
|
||||
test("history captures pressure metrics for charting", () => {
|
||||
metrics.recordHttpRequest(25, 200);
|
||||
metrics.recordHttpRequest(80, 500);
|
||||
metrics.recordBytesSent(1024);
|
||||
metrics.recordBytesReceived(2048);
|
||||
|
||||
const snap = metrics.getSnapshot();
|
||||
metrics.recordHistoryEntry(snap);
|
||||
|
||||
const [entry] = metrics.getHistory();
|
||||
expect(entry.httpRequestsPerMinute).toBeGreaterThanOrEqual(2);
|
||||
expect(entry.httpErrorsPerMinute).toBeGreaterThanOrEqual(1);
|
||||
expect(entry.httpLatencyAvg).toBeGreaterThan(0);
|
||||
expect(entry.httpLatencyP95).toBeGreaterThan(0);
|
||||
expect(entry.heapUsed).toBeGreaterThan(0);
|
||||
expect(entry.rss).toBeGreaterThan(0);
|
||||
expect(entry.systemMemoryUsedPercent).toBeGreaterThanOrEqual(0);
|
||||
expect(entry.activeSessions).toBe(0);
|
||||
});
|
||||
|
||||
test("history is capped at maxHistoryPoints", () => {
|
||||
const weekOfFiveSecondSamples = 7 * 24 * 60 * 12;
|
||||
for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) {
|
||||
|
||||
+148
-20
@@ -59,6 +59,8 @@ export interface MetricsSnapshot {
|
||||
lastMinuteErrors: number;
|
||||
latencyAvg: number;
|
||||
latencyP95: number;
|
||||
latencyP99: number;
|
||||
routes: HttpRouteStats[];
|
||||
};
|
||||
eventLoop: {
|
||||
avg: number;
|
||||
@@ -67,12 +69,41 @@ export interface MetricsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
export interface HttpRouteStats {
|
||||
route: string;
|
||||
countLastMinute: number;
|
||||
errorsLastMinute: number;
|
||||
latencyAvg: number;
|
||||
latencyP95: number;
|
||||
latencyP99: number;
|
||||
latencyMax: number;
|
||||
lastDuration: number;
|
||||
lastStatus: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistory {
|
||||
timestamp: number;
|
||||
clientsOnline: number;
|
||||
commandsPerMinute: number;
|
||||
bandwidthSent: number;
|
||||
bandwidthReceived: number;
|
||||
httpRequestsPerMinute?: number;
|
||||
httpErrorsPerMinute?: number;
|
||||
httpLatencyAvg?: number;
|
||||
httpLatencyP95?: number;
|
||||
httpLatencyP99?: number;
|
||||
eventLoopAvg?: number;
|
||||
eventLoopP95?: number;
|
||||
heapUsed?: number;
|
||||
rss?: number;
|
||||
systemMemoryUsedPercent?: number;
|
||||
activeSessions?: number;
|
||||
}
|
||||
|
||||
interface TimedHttpSample {
|
||||
ts: number;
|
||||
duration: number;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
class MetricsCollector {
|
||||
@@ -103,8 +134,11 @@ class MetricsCollector {
|
||||
private httpTotal: number = 0;
|
||||
private httpTimestamps: number[] = [];
|
||||
private httpErrorTimestamps: number[] = [];
|
||||
private httpLatencies: number[] = [];
|
||||
private maxHttpLatencyHistory: number = 1000;
|
||||
private httpSamples: TimedHttpSample[] = [];
|
||||
private httpRouteSamples: Map<string, TimedHttpSample[]> = new Map();
|
||||
private maxHttpLatencyHistory: number = 2000;
|
||||
private maxHttpRouteSamples: number = 300;
|
||||
private ignoredHttpMetricRoutes: Set<string> = new Set(["GET /api/metrics"]);
|
||||
|
||||
private eventLoopDelays: number[] = [];
|
||||
private maxEventLoopHistory: number = 300;
|
||||
@@ -236,7 +270,74 @@ class MetricsCollector {
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
recordHttpRequest(durationMs: number, statusCode: number) {
|
||||
private percentile(samples: number[], percentile: number): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const index = Math.max(0, Math.ceil(samples.length * percentile) - 1);
|
||||
return samples[index] ?? 0;
|
||||
}
|
||||
|
||||
private average(samples: number[]): number {
|
||||
return samples.length
|
||||
? samples.reduce((sum, value) => sum + value, 0) / samples.length
|
||||
: 0;
|
||||
}
|
||||
|
||||
private pruneHttpSamples(list: TimedHttpSample[], minTs: number): void {
|
||||
let removeCount = 0;
|
||||
while (removeCount < list.length && list[removeCount].ts <= minTs) {
|
||||
removeCount += 1;
|
||||
}
|
||||
if (removeCount > 0) {
|
||||
list.splice(0, removeCount);
|
||||
}
|
||||
}
|
||||
|
||||
private countHttpErrors(samples: TimedHttpSample[]): number {
|
||||
let count = 0;
|
||||
for (const sample of samples) {
|
||||
if (sample.statusCode >= 400) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private getTopHttpRoutes(minTs: number): HttpRouteStats[] {
|
||||
const routes: HttpRouteStats[] = [];
|
||||
for (const [route, samples] of this.httpRouteSamples.entries()) {
|
||||
this.pruneHttpSamples(samples, minTs);
|
||||
if (samples.length === 0) {
|
||||
this.httpRouteSamples.delete(route);
|
||||
continue;
|
||||
}
|
||||
|
||||
const durations = samples
|
||||
.map((sample) => sample.duration)
|
||||
.sort((a, b) => a - b);
|
||||
const last = samples[samples.length - 1];
|
||||
routes.push({
|
||||
route,
|
||||
countLastMinute: samples.length,
|
||||
errorsLastMinute: this.countHttpErrors(samples),
|
||||
latencyAvg: this.average(durations),
|
||||
latencyP95: this.percentile(durations, 0.95),
|
||||
latencyP99: this.percentile(durations, 0.99),
|
||||
latencyMax: durations[durations.length - 1] ?? 0,
|
||||
lastDuration: last?.duration ?? 0,
|
||||
lastStatus: last?.statusCode ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return routes
|
||||
.sort((a, b) => {
|
||||
if (b.latencyP95 !== a.latencyP95) return b.latencyP95 - a.latencyP95;
|
||||
if (b.latencyAvg !== a.latencyAvg) return b.latencyAvg - a.latencyAvg;
|
||||
return b.countLastMinute - a.countLastMinute;
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") {
|
||||
if (this.ignoredHttpMetricRoutes.has(route)) return;
|
||||
|
||||
this.httpTotal++;
|
||||
const now = Date.now();
|
||||
this.httpTimestamps.push(now);
|
||||
@@ -246,14 +347,25 @@ class MetricsCollector {
|
||||
this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000);
|
||||
}
|
||||
if (Number.isFinite(durationMs)) {
|
||||
this.httpLatencies.push(durationMs);
|
||||
if (this.httpLatencies.length > this.maxHttpLatencyHistory) {
|
||||
this.httpLatencies.shift();
|
||||
const sample = { ts: now, duration: Math.max(0, durationMs), statusCode };
|
||||
this.httpSamples.push(sample);
|
||||
if (this.httpSamples.length > this.maxHttpLatencyHistory) {
|
||||
this.httpSamples.splice(0, this.httpSamples.length - this.maxHttpLatencyHistory);
|
||||
}
|
||||
|
||||
const routeSamples = this.httpRouteSamples.get(route) || [];
|
||||
routeSamples.push(sample);
|
||||
if (routeSamples.length > this.maxHttpRouteSamples) {
|
||||
routeSamples.splice(0, routeSamples.length - this.maxHttpRouteSamples);
|
||||
}
|
||||
this.httpRouteSamples.set(route, routeSamples);
|
||||
}
|
||||
}
|
||||
|
||||
async withHttpMetrics<T extends Response>(handler: () => Promise<T>): Promise<T> {
|
||||
async withHttpMetrics<T extends Response>(
|
||||
handler: () => Promise<T>,
|
||||
route = "unknown",
|
||||
): Promise<T> {
|
||||
const start = typeof performance !== "undefined" && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
@@ -262,13 +374,13 @@ class MetricsCollector {
|
||||
const end = typeof performance !== "undefined" && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
this.recordHttpRequest(end - start, response?.status ?? 0);
|
||||
this.recordHttpRequest(end - start, response?.status ?? 0, route);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const end = typeof performance !== "undefined" && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
this.recordHttpRequest(end - start, 500);
|
||||
this.recordHttpRequest(end - start, 500, route);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -280,6 +392,21 @@ class MetricsCollector {
|
||||
commandsPerMinute: snapshot.commands.lastMinute,
|
||||
bandwidthSent: this.sentPerSecond,
|
||||
bandwidthReceived: this.receivedPerSecond,
|
||||
httpRequestsPerMinute: snapshot.http.lastMinute,
|
||||
httpErrorsPerMinute: snapshot.http.lastMinuteErrors,
|
||||
httpLatencyAvg: snapshot.http.latencyAvg,
|
||||
httpLatencyP95: snapshot.http.latencyP95,
|
||||
httpLatencyP99: snapshot.http.latencyP99,
|
||||
eventLoopAvg: snapshot.eventLoop.avg,
|
||||
eventLoopP95: snapshot.eventLoop.p95,
|
||||
heapUsed: snapshot.server.memoryUsage.heapUsed,
|
||||
rss: snapshot.server.memoryUsage.rss,
|
||||
systemMemoryUsedPercent: snapshot.server.systemMemory.usedPercent,
|
||||
activeSessions:
|
||||
snapshot.sessions.console +
|
||||
snapshot.sessions.remoteDesktop +
|
||||
snapshot.sessions.fileBrowser +
|
||||
snapshot.sessions.process,
|
||||
};
|
||||
|
||||
this.history.push(historyEntry);
|
||||
@@ -297,6 +424,7 @@ class MetricsCollector {
|
||||
this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
|
||||
this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
|
||||
this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
|
||||
this.pruneHttpSamples(this.httpSamples, oneMinuteAgo);
|
||||
|
||||
const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
|
||||
const commandsLastHour = this.commandTimestamps.length;
|
||||
@@ -309,16 +437,13 @@ class MetricsCollector {
|
||||
const httpLastMinute = this.httpTimestamps.length;
|
||||
const httpErrorsLastMinute = this.httpErrorTimestamps.length;
|
||||
|
||||
const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b);
|
||||
const httpLatencyAvg = httpLatencySamples.length
|
||||
? httpLatencySamples.reduce((a, b) => a + b, 0) / httpLatencySamples.length
|
||||
: 0;
|
||||
const httpP95Index = httpLatencySamples.length
|
||||
? Math.max(0, Math.floor(httpLatencySamples.length * 0.95) - 1)
|
||||
: 0;
|
||||
const httpLatencyP95 = httpLatencySamples.length
|
||||
? httpLatencySamples[httpP95Index] ?? 0
|
||||
: 0;
|
||||
const httpLatencySamples = this.httpSamples
|
||||
.map((sample) => sample.duration)
|
||||
.sort((a, b) => a - b);
|
||||
const httpLatencyAvg = this.average(httpLatencySamples);
|
||||
const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95);
|
||||
const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99);
|
||||
const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo);
|
||||
|
||||
const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
|
||||
const eventLoopAvg = eventLoopSamples.length
|
||||
@@ -400,6 +525,8 @@ class MetricsCollector {
|
||||
lastMinuteErrors: httpErrorsLastMinute,
|
||||
latencyAvg: httpLatencyAvg,
|
||||
latencyP95: httpLatencyP95,
|
||||
latencyP99: httpLatencyP99,
|
||||
routes: httpRoutes,
|
||||
},
|
||||
eventLoop: {
|
||||
avg: eventLoopAvg,
|
||||
@@ -428,7 +555,8 @@ class MetricsCollector {
|
||||
this.httpTotal = 0;
|
||||
this.httpTimestamps = [];
|
||||
this.httpErrorTimestamps = [];
|
||||
this.httpLatencies = [];
|
||||
this.httpSamples = [];
|
||||
this.httpRouteSamples.clear();
|
||||
this.eventLoopDelays = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,55 @@ const PERMISSIONS = {
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:configure": {
|
||||
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
|
||||
description: "Legacy full server settings access (grants all system:* settings permissions)",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:security": {
|
||||
description: "Change security policy settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:tls": {
|
||||
description: "Change TLS and certificate settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:registration": {
|
||||
description: "Change user registration policy settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:notifications": {
|
||||
description: "Change global notification delivery settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:chat": {
|
||||
description: "Change team chat settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:appearance": {
|
||||
description: "Change custom CSS and appearance settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:thumbnails": {
|
||||
description: "Change screenshot thumbnail settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:build-limits": {
|
||||
description: "Change build rate limit settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:export-import": {
|
||||
description: "Export and import server settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:health": {
|
||||
description: "View server health diagnostics",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:health:manage": {
|
||||
description: "Run server health maintenance actions",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:profiler": {
|
||||
description: "Run server CPU and memory profiler captures",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"clients:elevate": {
|
||||
@@ -128,8 +176,17 @@ export function hasPermission(
|
||||
if (grantedByRole) return true;
|
||||
// Permission groups + per-user extras are *additive only*: they can grant a
|
||||
// permission the role doesn't have, but they cannot revoke one the role does.
|
||||
if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
|
||||
return true;
|
||||
if (userId !== undefined) {
|
||||
const granted = getUserGrantedPermissions(userId);
|
||||
if (granted.has(permission)) return true;
|
||||
if (
|
||||
typeof permission === "string" &&
|
||||
permission.startsWith("system:") &&
|
||||
permission !== "system:configure" &&
|
||||
granted.has("system:configure")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildClientGraph, clientIpSubnet } from "./client-graph";
|
||||
|
||||
describe("client graph", () => {
|
||||
test("builds client relationship nodes and edges", () => {
|
||||
const graph = buildClientGraph([
|
||||
{
|
||||
id: "client-a",
|
||||
host: "alpha",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.40",
|
||||
country: "US",
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
{
|
||||
id: "client-b",
|
||||
host: "beta",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.55",
|
||||
country: "US",
|
||||
online: false,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const nodeIds = new Set(graph.nodes.map((node) => node.data.id));
|
||||
expect(nodeIds.has("client:client-a")).toBe(true);
|
||||
expect(nodeIds.has("client:client-b")).toBe(true);
|
||||
expect(nodeIds.has("group:7")).toBe(true);
|
||||
expect(nodeIds.has("build:prod-build")).toBe(true);
|
||||
expect(nodeIds.has("os:windows")).toBe(true);
|
||||
expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true);
|
||||
expect(nodeIds.has("user:alice")).toBe(true);
|
||||
expect(graph.summary.clients).toBe(2);
|
||||
expect(graph.summary.online).toBe(1);
|
||||
expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes IPv4 and IPv6 subnets", () => {
|
||||
expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24");
|
||||
expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64");
|
||||
expect(clientIpSubnet("not-an-ip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ListItem } from "../types";
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "client"
|
||||
| "group"
|
||||
| "build"
|
||||
| "os"
|
||||
| "country"
|
||||
| "subnet"
|
||||
| "user"
|
||||
| "status";
|
||||
|
||||
export type ClientGraphNode = {
|
||||
data: {
|
||||
id: string;
|
||||
label: string;
|
||||
type: GraphNodeKind;
|
||||
weight: number;
|
||||
count?: number;
|
||||
onlineCount?: number;
|
||||
clientId?: string;
|
||||
online?: boolean;
|
||||
href?: string;
|
||||
color?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraphEdge = {
|
||||
data: {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraph = {
|
||||
nodes: ClientGraphNode[];
|
||||
edges: ClientGraphEdge[];
|
||||
summary: {
|
||||
clients: number;
|
||||
totalClients?: number;
|
||||
online: number;
|
||||
categories: number;
|
||||
relationships: number;
|
||||
generatedAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
function compactLabel(value: unknown, fallback = "Unknown"): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return fallback;
|
||||
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
||||
}
|
||||
|
||||
function safeKey(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown";
|
||||
}
|
||||
|
||||
function ipv4Subnet(ip: string): string | null {
|
||||
const parts = ip.trim().split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
|
||||
}
|
||||
|
||||
function ipv6Subnet(ip: string): string | null {
|
||||
if (!ip.includes(":")) return null;
|
||||
const parts = ip.split(":").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return `${parts.slice(0, 4).join(":")}::/64`;
|
||||
}
|
||||
|
||||
export function clientIpSubnet(ip: unknown): string | null {
|
||||
const value = String(ip ?? "").trim();
|
||||
if (!value) return null;
|
||||
return ipv4Subnet(value) || ipv6Subnet(value);
|
||||
}
|
||||
|
||||
export function buildClientGraph(clients: ListItem[]): ClientGraph {
|
||||
const nodes = new Map<string, ClientGraphNode>();
|
||||
const edges = new Map<string, ClientGraphEdge>();
|
||||
|
||||
const addNode = (
|
||||
id: string,
|
||||
label: string,
|
||||
type: GraphNodeKind,
|
||||
data: Partial<ClientGraphNode["data"]> = {},
|
||||
) => {
|
||||
const existing = nodes.get(id);
|
||||
if (existing) {
|
||||
existing.data.count = (existing.data.count || 0) + 1;
|
||||
if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1;
|
||||
existing.data.weight = Math.max(existing.data.weight, data.weight || 1);
|
||||
return existing;
|
||||
}
|
||||
const node: ClientGraphNode = {
|
||||
data: {
|
||||
id,
|
||||
label,
|
||||
type,
|
||||
weight: data.weight || 1,
|
||||
count: data.count,
|
||||
onlineCount: data.online ? 1 : data.onlineCount,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const addEdge = (source: string, target: string, label: string, type: string) => {
|
||||
const id = `${source}->${target}:${type}`;
|
||||
if (edges.has(id)) return;
|
||||
edges.set(id, { data: { id, source, target, label, type } });
|
||||
};
|
||||
|
||||
for (const client of clients) {
|
||||
const clientNodeId = `client:${client.id}`;
|
||||
const clientLabel = compactLabel(client.nickname || client.host || client.id);
|
||||
addNode(clientNodeId, clientLabel, "client", {
|
||||
weight: client.online ? 5 : 3,
|
||||
clientId: client.id,
|
||||
online: client.online,
|
||||
href: `/?q=${encodeURIComponent(client.id)}`,
|
||||
meta: {
|
||||
id: client.id,
|
||||
host: client.host || "",
|
||||
user: client.user || "",
|
||||
os: client.os || "",
|
||||
ip: client.ip || "",
|
||||
country: client.country || "",
|
||||
groupName: client.groupName || "",
|
||||
buildTag: client.buildTag || "",
|
||||
lastSeen: client.lastSeen,
|
||||
},
|
||||
});
|
||||
|
||||
const statusNode = client.online
|
||||
? { id: "status:online", label: "Online", color: "#22c55e" }
|
||||
: { id: "status:offline", label: "Offline", color: "#64748b" };
|
||||
addNode(statusNode.id, statusNode.label, "status", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: statusNode.color,
|
||||
});
|
||||
addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status");
|
||||
|
||||
if (client.groupName || client.groupId) {
|
||||
const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`;
|
||||
addNode(groupId, compactLabel(client.groupName || "Group"), "group", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: client.groupColor || null,
|
||||
});
|
||||
addEdge(clientNodeId, groupId, "group", "group");
|
||||
}
|
||||
|
||||
if (client.buildTag) {
|
||||
const id = `build:${safeKey(client.buildTag)}`;
|
||||
addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "build", "build");
|
||||
}
|
||||
|
||||
if (client.os) {
|
||||
const id = `os:${safeKey(client.os)}`;
|
||||
addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "os", "os");
|
||||
}
|
||||
|
||||
if (client.country) {
|
||||
const label = String(client.country).toUpperCase();
|
||||
const id = `country:${safeKey(label)}`;
|
||||
addNode(id, label, "country", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "country", "country");
|
||||
}
|
||||
|
||||
const subnet = clientIpSubnet(client.ip);
|
||||
if (subnet) {
|
||||
const id = `subnet:${safeKey(subnet)}`;
|
||||
addNode(id, subnet, "subnet", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "subnet", "subnet");
|
||||
}
|
||||
|
||||
if (client.user) {
|
||||
const id = `user:${safeKey(client.user)}`;
|
||||
addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online });
|
||||
addEdge(clientNodeId, id, "user", "user");
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNodes = Array.from(nodes.values()).map((node) => {
|
||||
if (node.data.type !== "client") {
|
||||
node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1)));
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: sortedNodes,
|
||||
edges: Array.from(edges.values()),
|
||||
summary: {
|
||||
clients: clients.length,
|
||||
online: clients.filter((client) => client.online).length,
|
||||
categories: sortedNodes.filter((node) => node.data.type !== "client").length,
|
||||
relationships: edges.size,
|
||||
generatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { clearRequestRateLimitsForTests } from "../rateLimit";
|
||||
import { createHttpFetchHandler } from "./http-dispatch";
|
||||
import { createHttpFetchHandler, normalizeHttpRoute } from "./http-dispatch";
|
||||
|
||||
const metrics = {
|
||||
withHttpMetrics: (fn: () => Promise<Response>) => fn(),
|
||||
@@ -35,3 +35,22 @@ describe("createHttpFetchHandler rate limiting", () => {
|
||||
expect(limited.headers.get("Retry-After")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHttpRoute", () => {
|
||||
test("collapses dynamic route segments", () => {
|
||||
const req = new Request(
|
||||
"https://localhost/api/clients/0a44a8d4d61c7840540b43fd187fe3c43e2c46c9600b2aa1a61a6b1ab03c5b92/rd/ws?cache=1",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(normalizeHttpRoute(req, new URL(req.url))).toBe(
|
||||
"POST /api/clients/:id/rd/ws",
|
||||
);
|
||||
});
|
||||
|
||||
test("groups static asset files", () => {
|
||||
const req = new Request("https://localhost/assets/metrics.js");
|
||||
|
||||
expect(normalizeHttpRoute(req, new URL(req.url))).toBe("GET /assets/:file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { consumeUnauthorizedRateLimit } from "../rateLimit";
|
||||
|
||||
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise<Response | null>;
|
||||
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
|
||||
|
||||
function tooManyRequestsResponse(retryAfter = 60): Response {
|
||||
return new Response("Too many requests", {
|
||||
status: 429,
|
||||
@@ -13,17 +16,35 @@ function tooManyRequestsResponse(retryAfter = 60): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRouteSegment(segment: string): string {
|
||||
if (/^\d+$/.test(segment)) return ":id";
|
||||
if (UUID_SEGMENT.test(segment) || LONG_ID_SEGMENT.test(segment)) return ":id";
|
||||
if (segment.includes(".") && !segment.startsWith(".")) return ":file";
|
||||
if (segment.length >= 24 && /^[a-z0-9_-]+$/i.test(segment)) return ":id";
|
||||
return segment;
|
||||
}
|
||||
|
||||
export function normalizeHttpRoute(req: Request, url: URL): string {
|
||||
const path = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(normalizeRouteSegment)
|
||||
.join("/");
|
||||
return `${req.method.toUpperCase()} /${path}`;
|
||||
}
|
||||
|
||||
export function createHttpFetchHandler(deps: {
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>, route?: string) => Promise<Response> };
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
routes: RouteHandler[];
|
||||
}) {
|
||||
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const routeLabel = normalizeHttpRoute(req, url);
|
||||
return deps.metrics.withHttpMetrics(async () => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
|
||||
const ip = wrapped.requestIP(req)?.address || "unknown";
|
||||
|
||||
@@ -38,6 +59,6 @@ export function createHttpFetchHandler(deps: {
|
||||
}
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
}, routeLabel);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => {
|
||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("script-src 'self'");
|
||||
expect(csp).toContain("font-src 'self' data:");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CORS_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-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
|
||||
@@ -103,6 +103,19 @@ describe("/api/auth/me permissions array", () => {
|
||||
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
|
||||
});
|
||||
|
||||
test("legacy system:configure extra grants the split settings permissions", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
setUserExtraPermissions(viewer.user.id, ["system:configure"]);
|
||||
const body = await fetchMe(viewer.token);
|
||||
const set = new Set(body.permissions);
|
||||
expect(set.has("system:configure")).toBe(true);
|
||||
expect(set.has("system:security")).toBe(true);
|
||||
expect(set.has("system:tls")).toBe(true);
|
||||
expect(set.has("system:registration")).toBe(true);
|
||||
expect(set.has("system:health")).toBe(true);
|
||||
expect(set.has("system:profiler")).toBe(true);
|
||||
});
|
||||
|
||||
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
|
||||
import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { buildClientGraph } from "../client-graph";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
getActiveProxies,
|
||||
@@ -329,13 +330,59 @@ export async function handleMiscRoutes(
|
||||
snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount();
|
||||
snapshot.sessions.process = deps.getProcessSessionCount();
|
||||
|
||||
const history = metrics.getHistory();
|
||||
const requestedHistoryLimit = Number(url.searchParams.get("historyLimit") || 240);
|
||||
const historyLimit = Number.isFinite(requestedHistoryLimit)
|
||||
? Math.max(1, Math.min(2000, requestedHistoryLimit))
|
||||
: 240;
|
||||
const history = metrics.getHistory().slice(-historyLimit);
|
||||
|
||||
return new Response(JSON.stringify({ snapshot, history }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/client-graph") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300)));
|
||||
const search = (url.searchParams.get("q") || "").trim().toLowerCase();
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const groupFilter = url.searchParams.get("group") || "all";
|
||||
let allowedClientIds: string[] | undefined;
|
||||
let deniedClientIds: string[] | undefined;
|
||||
|
||||
if (user.role !== "admin") {
|
||||
const scope = getUserClientAccessScope(user.userId);
|
||||
if (scope === "none") {
|
||||
return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
if (scope === "allowlist") {
|
||||
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
|
||||
} else if (scope === "denylist") {
|
||||
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
|
||||
}
|
||||
}
|
||||
|
||||
const clients = listClients({
|
||||
page: 1,
|
||||
pageSize: limit,
|
||||
search,
|
||||
sort: "last_seen_desc",
|
||||
statusFilter,
|
||||
osFilter: "all",
|
||||
countryFilter: "all",
|
||||
enrollmentFilter: "approved",
|
||||
groupFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
});
|
||||
|
||||
const graph = buildClientGraph(clients.items);
|
||||
graph.summary.totalClients = clients.total;
|
||||
return Response.json(graph, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
@@ -385,7 +432,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:security");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -447,7 +494,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:tls");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -502,7 +549,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:tls");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -743,7 +790,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:export-import");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -776,7 +823,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:export-import");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -828,7 +875,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:chat");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -869,7 +916,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:appearance");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -934,7 +981,7 @@ export async function handleMiscRoutes(
|
||||
if (url.pathname === "/api/settings/registration") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:registration"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
|
||||
@@ -970,7 +1017,7 @@ export async function handleMiscRoutes(
|
||||
if (url.pathname === "/api/settings/build-rate-limit") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:build-limits"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
|
||||
@@ -1011,7 +1058,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:thumbnails"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {
|
||||
@@ -1040,7 +1087,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "GET" && url.pathname === "/api/settings/health") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:health"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
return Response.json({
|
||||
@@ -1069,7 +1116,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:health:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
|
||||
@@ -1083,7 +1130,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:profiler"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (activeServerProfile) {
|
||||
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function handleNotificationsConfigRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:notifications");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -95,7 +95,7 @@ export async function handleNotificationsConfigRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:notifications");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
|
||||
@@ -82,6 +82,7 @@ type QueryClientPageDef = {
|
||||
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||
const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
|
||||
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import * as clientManager from "../clientManager";
|
||||
import { decodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
import {
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
rdStreamingState,
|
||||
} from "./ws-console-rd-hvnc";
|
||||
|
||||
type MockWs = {
|
||||
data: SocketData;
|
||||
sent: unknown[];
|
||||
closedCode?: number;
|
||||
closedReason?: string;
|
||||
send: (msg: unknown) => void;
|
||||
close: (code: number, reason: string) => void;
|
||||
getBufferedAmount: () => number;
|
||||
};
|
||||
|
||||
const clientIdsToCleanup = new Set<string>();
|
||||
|
||||
function createMockWs(data: Partial<SocketData>): MockWs {
|
||||
return {
|
||||
data: {
|
||||
role: "rd_viewer",
|
||||
clientId: "rd-test-client",
|
||||
...data,
|
||||
} as SocketData,
|
||||
sent: [],
|
||||
send(msg: unknown) {
|
||||
this.sent.push(msg);
|
||||
},
|
||||
close(code: number, reason: string) {
|
||||
this.closedCode = code;
|
||||
this.closedReason = reason;
|
||||
},
|
||||
getBufferedAmount() {
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(id: string) {
|
||||
const agentWs = createMockWs({ role: "client", clientId: id });
|
||||
const info: ClientInfo = {
|
||||
id,
|
||||
role: "client",
|
||||
ws: agentWs,
|
||||
lastSeen: Date.now(),
|
||||
online: true,
|
||||
host: "rd-test-host",
|
||||
os: "windows",
|
||||
user: "tester",
|
||||
monitors: 1,
|
||||
};
|
||||
clientManager.addClient(id, info);
|
||||
clientIdsToCleanup.add(id);
|
||||
return { info, agentWs };
|
||||
}
|
||||
|
||||
function agentCommands(ws: MockWs) {
|
||||
return ws.sent.map((msg) => decodeMessage(msg as Uint8Array) as any);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const clientId of clientIdsToCleanup) {
|
||||
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
|
||||
sessionManager.deleteRdSession(session.id);
|
||||
}
|
||||
rdStreamingState.delete(clientId);
|
||||
clientManager.deleteClient(clientId);
|
||||
}
|
||||
clientIdsToCleanup.clear();
|
||||
});
|
||||
|
||||
describe("remote desktop viewer control", () => {
|
||||
test("starts once, ignores duplicate starts, and only stops after the last viewer leaves", () => {
|
||||
const clientId = `rd-control-${Date.now().toString(36)}`;
|
||||
const { agentWs } = createClient(clientId);
|
||||
const firstViewer = createMockWs({ clientId });
|
||||
const secondViewer = createMockWs({ clientId });
|
||||
|
||||
handleRemoteDesktopViewerOpen(firstViewer as any);
|
||||
handleRemoteDesktopViewerOpen(secondViewer as any);
|
||||
|
||||
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
|
||||
let commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_start")).toHaveLength(1);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
|
||||
|
||||
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_stop" }));
|
||||
|
||||
commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(0);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
|
||||
|
||||
sessionManager.deleteRdSession(firstViewer.data.sessionId!);
|
||||
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_stop" }));
|
||||
|
||||
commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(1);
|
||||
expect(commands.filter((msg) => msg.commandType === "webrtc_stop")).toHaveLength(1);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
test("does not forward desktop_start when a macOS client is missing required permissions", () => {
|
||||
const clientId = `rd-mac-perms-${Date.now().toString(36)}`;
|
||||
const { info, agentWs } = createClient(clientId);
|
||||
info.os = "darwin";
|
||||
info.permissions = { screenRecording: false, accessibility: true };
|
||||
const viewer = createMockWs({ clientId });
|
||||
|
||||
handleRemoteDesktopViewerOpen(viewer as any);
|
||||
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
|
||||
expect(agentCommands(agentWs).filter((msg) => msg.commandType === "desktop_start")).toHaveLength(0);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true);
|
||||
expect(viewer.sent.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,7 @@ function broadcastFrameToViewers(
|
||||
}
|
||||
|
||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||
const rdDebugFrameLogAt = new Map<string, number>();
|
||||
export const rdStreamingState = new Map<string, {
|
||||
isStreaming: boolean;
|
||||
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) {
|
||||
if (!target) return false;
|
||||
if (!target) {
|
||||
logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`);
|
||||
return false;
|
||||
}
|
||||
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 }));
|
||||
metrics.recordCommand(commandType);
|
||||
return true;
|
||||
@@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
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) {
|
||||
const targetOs = String(target.os || "").toLowerCase();
|
||||
if ((targetOs.includes("darwin") || targetOs.includes("mac")) && target.permissions) {
|
||||
@@ -364,14 +369,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
sendDesktopCommand(target, "desktop_start", {});
|
||||
state.isStreaming = true;
|
||||
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 {
|
||||
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;
|
||||
case "desktop_stop": {
|
||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||
.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) {
|
||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
@@ -649,6 +655,12 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
const frameFps = Number(header?.fps) || 0;
|
||||
if (frameFps > 0) state.lastFps = frameFps;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -661,7 +673,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
|
||||
}
|
||||
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||
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 => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const SERVER_VERSION = "2.3.4";
|
||||
export const SERVER_VERSION = "2.3.5";
|
||||
|
||||
Reference in New Issue
Block a user