diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock
index 7fae326..218ad01 100644
--- a/Overlord-Server/bun.lock
+++ b/Overlord-Server/bun.lock
@@ -9,6 +9,7 @@
"@solana/web3.js": "^1.98.4",
"adm-zip": "^0.5.12",
"ansi-to-html": "0.7.2",
+ "cytoscape": "^3.34.0",
"fuse.js": "^7.4.1",
"geoip-lite": "latest",
"gridstack": "^12.6.0",
@@ -264,6 +265,8 @@
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
+ "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json
index 6f85831..494d30a 100644
--- a/Overlord-Server/package.json
+++ b/Overlord-Server/package.json
@@ -20,6 +20,7 @@
"@solana/web3.js": "^1.98.4",
"adm-zip": "^0.5.12",
"ansi-to-html": "0.7.2",
+ "cytoscape": "^3.34.0",
"fuse.js": "^7.4.1",
"geoip-lite": "latest",
"gridstack": "^12.6.0",
diff --git a/Overlord-Server/public/assets/client-graph.js b/Overlord-Server/public/assets/client-graph.js
new file mode 100644
index 0000000..a00538a
--- /dev/null
+++ b/Overlord-Server/public/assets/client-graph.js
@@ -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, """);
+}
+
+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]) => `
+
+ ${escapeHtml(label)}
+ ${escapeHtml(value)}
+
+ `).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
+ ? ""
+ : `Client is offline
`;
+ detailActions.innerHTML = `
+ ${disabledNote}
+
+
+
+
+ `;
+ } 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();
diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css
index fc433c3..3fee38c 100644
--- a/Overlord-Server/public/assets/main.css
+++ b/Overlord-Server/public/assets/main.css
@@ -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% {
diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js
index d6e704e..f953bc6 100644
--- a/Overlord-Server/public/assets/nav.js
+++ b/Overlord-Server/public/assets/nav.js
@@ -36,6 +36,7 @@ if (host) {
const activeMap = {
"/": "nav-clients",
"/metrics": "metrics-link",
+ "/graph": "graph-link",
"/screenshots": "screenshots-link",
"/logs": "logs-link",
"/scripts": "scripts-link",
diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js
index f346ab7..77aa4d9 100644
--- a/Overlord-Server/public/assets/nav/template.js
+++ b/Overlord-Server/public/assets/nav/template.js
@@ -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 },
],
},
diff --git a/Overlord-Server/public/graph.html b/Overlord-Server/public/graph.html
new file mode 100644
index 0000000..56a180f
--- /dev/null
+++ b/Overlord-Server/public/graph.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+ Overlord Graph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts
index 1086f05..90c6722 100644
--- a/Overlord-Server/scripts/vendor.ts
+++ b/Overlord-Server/scripts/vendor.ts
@@ -199,6 +199,14 @@ copyFile(
path.join(VENDOR, "gridstack", "gridstack.min.css"),
);
+/* ── Cytoscape.js ────────────────────────────────────────────────── */
+
+console.log("Copying Cytoscape.js ...");
+copyFile(
+ path.join(NM, "cytoscape", "dist", "cytoscape.min.js"),
+ path.join(VENDOR, "cytoscape", "cytoscape.min.js"),
+);
+
/* ── highlight.js (bundle core + languages) ──────────────────────── */
console.log("Bundling highlight.js ...");
diff --git a/Overlord-Server/src/server/client-graph.test.ts b/Overlord-Server/src/server/client-graph.test.ts
new file mode 100644
index 0000000..d754a04
--- /dev/null
+++ b/Overlord-Server/src/server/client-graph.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, test } from "bun:test";
+import { buildClientGraph, clientIpSubnet } from "./client-graph";
+
+describe("client graph", () => {
+ test("builds client relationship nodes and edges", () => {
+ const graph = buildClientGraph([
+ {
+ id: "client-a",
+ host: "alpha",
+ user: "alice",
+ os: "windows",
+ ip: "10.20.30.40",
+ country: "US",
+ online: true,
+ lastSeen: Date.now(),
+ groupId: 7,
+ groupName: "Ops",
+ groupColor: "#38bdf8",
+ buildTag: "prod-build",
+ } as any,
+ {
+ id: "client-b",
+ host: "beta",
+ user: "alice",
+ os: "windows",
+ ip: "10.20.30.55",
+ country: "US",
+ online: false,
+ lastSeen: Date.now(),
+ groupId: 7,
+ groupName: "Ops",
+ groupColor: "#38bdf8",
+ buildTag: "prod-build",
+ } as any,
+ ]);
+
+ const nodeIds = new Set(graph.nodes.map((node) => node.data.id));
+ expect(nodeIds.has("client:client-a")).toBe(true);
+ expect(nodeIds.has("client:client-b")).toBe(true);
+ expect(nodeIds.has("group:7")).toBe(true);
+ expect(nodeIds.has("build:prod-build")).toBe(true);
+ expect(nodeIds.has("os:windows")).toBe(true);
+ expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true);
+ expect(nodeIds.has("user:alice")).toBe(true);
+ expect(graph.summary.clients).toBe(2);
+ expect(graph.summary.online).toBe(1);
+ expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true);
+ });
+
+ test("normalizes IPv4 and IPv6 subnets", () => {
+ expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24");
+ expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64");
+ expect(clientIpSubnet("not-an-ip")).toBeNull();
+ });
+});
diff --git a/Overlord-Server/src/server/client-graph.ts b/Overlord-Server/src/server/client-graph.ts
new file mode 100644
index 0000000..428ab44
--- /dev/null
+++ b/Overlord-Server/src/server/client-graph.ts
@@ -0,0 +1,213 @@
+import type { ListItem } from "../types";
+
+export type GraphNodeKind =
+ | "client"
+ | "group"
+ | "build"
+ | "os"
+ | "country"
+ | "subnet"
+ | "user"
+ | "status";
+
+export type ClientGraphNode = {
+ data: {
+ id: string;
+ label: string;
+ type: GraphNodeKind;
+ weight: number;
+ count?: number;
+ onlineCount?: number;
+ clientId?: string;
+ online?: boolean;
+ href?: string;
+ color?: string | null;
+ meta?: Record;
+ };
+};
+
+export type ClientGraphEdge = {
+ data: {
+ id: string;
+ source: string;
+ target: string;
+ label: string;
+ type: string;
+ };
+};
+
+export type ClientGraph = {
+ nodes: ClientGraphNode[];
+ edges: ClientGraphEdge[];
+ summary: {
+ clients: number;
+ totalClients?: number;
+ online: number;
+ categories: number;
+ relationships: number;
+ generatedAt: number;
+ };
+};
+
+function compactLabel(value: unknown, fallback = "Unknown"): string {
+ const text = String(value ?? "").trim();
+ if (!text) return fallback;
+ return text.length > 48 ? `${text.slice(0, 45)}...` : text;
+}
+
+function safeKey(value: string): string {
+ return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown";
+}
+
+function ipv4Subnet(ip: string): string | null {
+ const parts = ip.trim().split(".");
+ if (parts.length !== 4) return null;
+ const octets = parts.map((part) => Number(part));
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
+ return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
+}
+
+function ipv6Subnet(ip: string): string | null {
+ if (!ip.includes(":")) return null;
+ const parts = ip.split(":").filter(Boolean);
+ if (parts.length < 2) return null;
+ return `${parts.slice(0, 4).join(":")}::/64`;
+}
+
+export function clientIpSubnet(ip: unknown): string | null {
+ const value = String(ip ?? "").trim();
+ if (!value) return null;
+ return ipv4Subnet(value) || ipv6Subnet(value);
+}
+
+export function buildClientGraph(clients: ListItem[]): ClientGraph {
+ const nodes = new Map();
+ const edges = new Map();
+
+ const addNode = (
+ id: string,
+ label: string,
+ type: GraphNodeKind,
+ data: Partial = {},
+ ) => {
+ const existing = nodes.get(id);
+ if (existing) {
+ existing.data.count = (existing.data.count || 0) + 1;
+ if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1;
+ existing.data.weight = Math.max(existing.data.weight, data.weight || 1);
+ return existing;
+ }
+ const node: ClientGraphNode = {
+ data: {
+ id,
+ label,
+ type,
+ weight: data.weight || 1,
+ count: data.count,
+ onlineCount: data.online ? 1 : data.onlineCount,
+ ...data,
+ },
+ };
+ nodes.set(id, node);
+ return node;
+ };
+
+ const addEdge = (source: string, target: string, label: string, type: string) => {
+ const id = `${source}->${target}:${type}`;
+ if (edges.has(id)) return;
+ edges.set(id, { data: { id, source, target, label, type } });
+ };
+
+ for (const client of clients) {
+ const clientNodeId = `client:${client.id}`;
+ const clientLabel = compactLabel(client.nickname || client.host || client.id);
+ addNode(clientNodeId, clientLabel, "client", {
+ weight: client.online ? 5 : 3,
+ clientId: client.id,
+ online: client.online,
+ href: `/?q=${encodeURIComponent(client.id)}`,
+ meta: {
+ id: client.id,
+ host: client.host || "",
+ user: client.user || "",
+ os: client.os || "",
+ ip: client.ip || "",
+ country: client.country || "",
+ groupName: client.groupName || "",
+ buildTag: client.buildTag || "",
+ lastSeen: client.lastSeen,
+ },
+ });
+
+ const statusNode = client.online
+ ? { id: "status:online", label: "Online", color: "#22c55e" }
+ : { id: "status:offline", label: "Offline", color: "#64748b" };
+ addNode(statusNode.id, statusNode.label, "status", {
+ weight: 4,
+ online: client.online,
+ color: statusNode.color,
+ });
+ addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status");
+
+ if (client.groupName || client.groupId) {
+ const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`;
+ addNode(groupId, compactLabel(client.groupName || "Group"), "group", {
+ weight: 4,
+ online: client.online,
+ color: client.groupColor || null,
+ });
+ addEdge(clientNodeId, groupId, "group", "group");
+ }
+
+ if (client.buildTag) {
+ const id = `build:${safeKey(client.buildTag)}`;
+ addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online });
+ addEdge(clientNodeId, id, "build", "build");
+ }
+
+ if (client.os) {
+ const id = `os:${safeKey(client.os)}`;
+ addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online });
+ addEdge(clientNodeId, id, "os", "os");
+ }
+
+ if (client.country) {
+ const label = String(client.country).toUpperCase();
+ const id = `country:${safeKey(label)}`;
+ addNode(id, label, "country", { weight: 3, online: client.online });
+ addEdge(clientNodeId, id, "country", "country");
+ }
+
+ const subnet = clientIpSubnet(client.ip);
+ if (subnet) {
+ const id = `subnet:${safeKey(subnet)}`;
+ addNode(id, subnet, "subnet", { weight: 3, online: client.online });
+ addEdge(clientNodeId, id, "subnet", "subnet");
+ }
+
+ if (client.user) {
+ const id = `user:${safeKey(client.user)}`;
+ addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online });
+ addEdge(clientNodeId, id, "user", "user");
+ }
+ }
+
+ const sortedNodes = Array.from(nodes.values()).map((node) => {
+ if (node.data.type !== "client") {
+ node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1)));
+ }
+ return node;
+ });
+
+ return {
+ nodes: sortedNodes,
+ edges: Array.from(edges.values()),
+ summary: {
+ clients: clients.length,
+ online: clients.filter((client) => client.online).length,
+ categories: sortedNodes.filter((node) => node.data.type !== "client").length,
+ relationships: edges.size,
+ generatedAt: Date.now(),
+ },
+ };
+}
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
index 11e7ae0..48c186a 100644
--- a/Overlord-Server/src/server/routes/misc-routes.ts
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -2,12 +2,13 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
-import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db";
+import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
import { getThumbnailStats } from "../../thumbnails";
import { getClientCount, getOnlineClients } from "../../clientManager";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
+import { buildClientGraph } from "../client-graph";
import { runCertbotSetup } from "../certbot-setup";
import {
getActiveProxies,
@@ -336,6 +337,48 @@ export async function handleMiscRoutes(
});
}
+ if (req.method === "GET" && url.pathname === "/api/client-graph") {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+
+ const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300)));
+ const search = (url.searchParams.get("q") || "").trim().toLowerCase();
+ const statusFilter = url.searchParams.get("status") || "all";
+ const groupFilter = url.searchParams.get("group") || "all";
+ let allowedClientIds: string[] | undefined;
+ let deniedClientIds: string[] | undefined;
+
+ if (user.role !== "admin") {
+ const scope = getUserClientAccessScope(user.userId);
+ if (scope === "none") {
+ return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS });
+ }
+ if (scope === "allowlist") {
+ allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
+ } else if (scope === "denylist") {
+ deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
+ }
+ }
+
+ const clients = listClients({
+ page: 1,
+ pageSize: limit,
+ search,
+ sort: "last_seen_desc",
+ statusFilter,
+ osFilter: "all",
+ countryFilter: "all",
+ enrollmentFilter: "approved",
+ groupFilter,
+ allowedClientIds,
+ deniedClientIds,
+ });
+
+ const graph = buildClientGraph(clients.items);
+ graph.summary.totalClients = clients.total;
+ return Response.json(graph, { headers: deps.CORS_HEADERS });
+ }
+
if (url.pathname === "/health") {
return new Response("ok", { headers: deps.CORS_HEADERS });
}
diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts
index 2444bbc..05d135b 100644
--- a/Overlord-Server/src/server/routes/page-routes.ts
+++ b/Overlord-Server/src/server/routes/page-routes.ts
@@ -82,6 +82,7 @@ type QueryClientPageDef = {
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
const STATIC_PAGES: StaticPageDef[] = [
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
+ { path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true },
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },