Merge branch 'main' into dev
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user