Files
overlord/Overlord-Server/public/assets/client-graph.js
T

353 lines
11 KiB
JavaScript
Raw Normal View History

2026-07-13 20:00:04 -05:00
import cytoscape from "/vendor/cytoscape/cytoscape.esm.min.mjs";
2026-06-04 16:07:42 -05:00
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;
2026-07-13 20:00:04 -05:00
let graphPageActive = true;
2026-06-04 16:07:42 -05:00
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
const typeColors = {
client: "#38bdf8",
group: "#a78bfa",
build: "#f59e0b",
os: "#22c55e",
country: "#f472b6",
subnet: "#60a5fa",
user: "#f97316",
status: "#64748b",
};
function flagEmoji(country) {
const cc = String(country || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(cc) || cc === "ZZ") return "";
const base = 0x1f1e6;
return String.fromCodePoint(
base + cc.charCodeAt(0) - 65,
base + cc.charCodeAt(1) - 65,
);
}
function osSymbol(osRaw) {
const os = String(osRaw || "").toLowerCase();
if (os.includes("win")) return "⊞";
if (os.includes("mac") || os.includes("darwin")) return "⌘";
if (os.includes("android")) return "▣";
if (os.includes("ubuntu")) return "◌";
if (os.includes("debian")) return "◆";
if (os.includes("fedora")) return "●";
if (os.includes("freebsd")) return "◇";
if (os.includes("linux")) return "⬢";
return "◯";
}
function nodeDisplayLabel(data) {
const label = String(data.label || data.id || "");
if (data.type === "country") {
const flag = flagEmoji(label);
return flag ? `${flag} ${label} ${flag}` : label;
}
if (data.type === "os") return `${osSymbol(label)}\n${label}`;
if (data.type === "status") return `${data.online ? "●" : "○"}\n${label}`;
if (data.type === "group") return `◫\n${label}`;
if (data.type === "build") return `⬡\n${label}`;
if (data.type === "subnet") return `⌁\n${label}`;
if (data.type === "user") return `@\n${label}`;
return label;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
2026-08-24 09:35:03 -05:00
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
2026-06-04 16:07:42 -05:00
}
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) {
2026-07-13 20:00:04 -05:00
if (!graphPageActive || !container?.isConnected) return;
2026-06-04 16:07:42 -05:00
currentGraph = graph;
setSummary(graph.summary);
if (!cy) {
2026-07-13 20:00:04 -05:00
cy = cytoscape({
2026-06-04 16:07:42 -05:00
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() {
2026-07-13 20:00:04 -05:00
if (!graphPageActive || !container) return;
2026-06-04 16:07:42 -05:00
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) {
2026-07-13 20:00:04 -05:00
if (!graphPageActive) return;
2026-06-04 16:07:42 -05:00
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
return;
}
2026-07-13 20:00:04 -05:00
const graph = await res.json();
if (graphPageActive) renderGraph(graph);
2026-06-04 16:07:42 -05:00
}
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));
2026-07-13 20:00:04 -05:00
const handleResize = () => {
2026-06-04 16:07:42 -05:00
cy?.resize();
cy?.fit(undefined, 32);
2026-07-13 20:00:04 -05:00
};
window.addEventListener("resize", handleResize);
window.addEventListener("pagehide", () => {
graphPageActive = false;
clearTimeout(searchTimer);
window.removeEventListener("resize", handleResize);
cy?.destroy();
cy = null;
}, { once: true });
2026-06-04 16:07:42 -05:00
bindInspectorActions();
loadGraph();