Merge branch 'main' into dev

This commit is contained in:
KingKDot
2026-06-04 21:23:42 -05:00
41 changed files with 3961 additions and 387 deletions
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
}
+264 -10
View File
@@ -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);
});
+24
View File
@@ -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
+427 -36
View File
@@ -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);
}
+480 -8
View File
@@ -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) {
+74 -22
View File
@@ -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 },
],
},
+79 -3
View File
@@ -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");
+182 -27
View File
@@ -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();
}
+357
View File
@@ -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;
}