add graph view for people
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
if (!summaryEl) return;
|
||||
const total = summary.totalClients && summary.totalClients !== summary.clients
|
||||
? ` of ${summary.totalClients}`
|
||||
: "";
|
||||
summaryEl.textContent = `${summary.clients}${total} clients, ${summary.online} online, ${summary.relationships} relationships`;
|
||||
}
|
||||
|
||||
function graphElements(graph) {
|
||||
return [
|
||||
...graph.nodes.map((node) => ({
|
||||
group: "nodes",
|
||||
data: {
|
||||
...node.data,
|
||||
color: node.data.color || typeColors[node.data.type] || "#94a3b8",
|
||||
displayLabel: nodeDisplayLabel(node.data),
|
||||
},
|
||||
})),
|
||||
...graph.edges.map((edge) => ({ group: "edges", data: edge.data })),
|
||||
];
|
||||
}
|
||||
|
||||
function shouldAnimateLayout(graph = currentGraph) {
|
||||
const total = Number(graph?.summary?.totalClients || graph?.summary?.clients || 0);
|
||||
return total < LARGE_GRAPH_ANIMATION_CLIENTS;
|
||||
}
|
||||
|
||||
function layoutOptions(name, graph = currentGraph) {
|
||||
const animate = shouldAnimateLayout(graph);
|
||||
if (name === "grid") {
|
||||
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
|
||||
}
|
||||
if (name === "circle") {
|
||||
return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 };
|
||||
}
|
||||
if (name === "breadthfirst") {
|
||||
return { name, directed: false, animate, animationDuration: animate ? 260 : 0, padding: 36, spacingFactor: 1.15 };
|
||||
}
|
||||
return {
|
||||
name: "cose",
|
||||
animate,
|
||||
animationDuration: animate ? 360 : 0,
|
||||
padding: 40,
|
||||
idealEdgeLength: 110,
|
||||
nodeRepulsion: 5200,
|
||||
gravity: 0.28,
|
||||
numIter: 900,
|
||||
};
|
||||
}
|
||||
|
||||
function showSelection(node) {
|
||||
if (!detailTitle || !detailMeta || !detailActions) return;
|
||||
if (!node) {
|
||||
detailTitle.textContent = "Nothing selected";
|
||||
detailMeta.innerHTML = "";
|
||||
detailActions.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = node.data();
|
||||
const meta = data.meta || {};
|
||||
const countryFlag = flagEmoji(meta.country || (data.type === "country" ? data.label : ""));
|
||||
detailTitle.textContent = data.type === "country" && countryFlag
|
||||
? `${countryFlag} ${data.label || data.id} ${countryFlag}`
|
||||
: data.label || data.id;
|
||||
const rows = [
|
||||
["Type", data.type],
|
||||
["Count", data.type === "client" ? "" : data.count || 1],
|
||||
["Online", data.type === "client" ? (data.online ? "Yes" : "No") : data.onlineCount || 0],
|
||||
["Client ID", data.clientId || meta.id || ""],
|
||||
["Host", meta.host || ""],
|
||||
["User", meta.user || ""],
|
||||
["OS", meta.os || ""],
|
||||
["IP", meta.ip || ""],
|
||||
["Country", meta.country ? `${countryFlag ? `${countryFlag} ` : ""}${meta.country}${countryFlag ? ` ${countryFlag}` : ""}` : ""],
|
||||
["Group", meta.groupName || ""],
|
||||
["Build", meta.buildTag || ""],
|
||||
].filter(([, value]) => value !== "" && value !== undefined && value !== null);
|
||||
|
||||
detailMeta.innerHTML = rows.map(([label, value]) => `
|
||||
<div class="graph-meta-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
if (data.clientId) {
|
||||
const id = encodeURIComponent(data.clientId);
|
||||
const online = data.online === true;
|
||||
const disabled = online ? "" : " disabled aria-disabled=\"true\" title=\"Client is offline\"";
|
||||
const disabledNote = online
|
||||
? ""
|
||||
: `<div class="graph-action-note"><i class="fa-solid fa-plug-circle-xmark"></i><span>Client is offline</span></div>`;
|
||||
detailActions.innerHTML = `
|
||||
${disabledNote}
|
||||
<button class="graph-action graph-action--remote" data-graph-action="/remotedesktop?clientId=${id}"${disabled}>
|
||||
<i class="fa-solid fa-desktop"></i><span>Remote Desktop</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--console" data-graph-action="/${id}/console"${disabled}>
|
||||
<i class="fa-solid fa-terminal"></i><span>Console</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--files" data-graph-action="/${id}/files"${disabled}>
|
||||
<i class="fa-solid fa-folder-tree"></i><span>File Browser</span>
|
||||
</button>
|
||||
<button class="graph-action graph-action--copy" data-graph-copy="${escapeHtml(data.clientId)}">
|
||||
<i class="fa-solid fa-copy"></i><span>Copy ID</span>
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
detailActions.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function bindInspectorActions() {
|
||||
detailActions?.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button) return;
|
||||
if (button.disabled || button.getAttribute("aria-disabled") === "true") return;
|
||||
const href = button.dataset.graphAction;
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
const copyValue = button.dataset.graphCopy;
|
||||
if (copyValue) {
|
||||
await navigator.clipboard?.writeText(copyValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectNode(node) {
|
||||
if (!cy) return;
|
||||
cy.elements().removeClass("graph-dim graph-focus");
|
||||
if (!node) {
|
||||
showSelection(null);
|
||||
return;
|
||||
}
|
||||
const neighborhood = node.closedNeighborhood();
|
||||
cy.elements().difference(neighborhood).addClass("graph-dim");
|
||||
neighborhood.addClass("graph-focus");
|
||||
showSelection(node);
|
||||
}
|
||||
|
||||
function renderGraph(graph) {
|
||||
currentGraph = graph;
|
||||
setSummary(graph.summary);
|
||||
|
||||
if (!window.cytoscape) {
|
||||
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cy) {
|
||||
cy = window.cytoscape({
|
||||
container,
|
||||
elements: graphElements(graph),
|
||||
minZoom: 0.18,
|
||||
maxZoom: 5,
|
||||
wheelSensitivity: 1.8,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(displayLabel)",
|
||||
"background-color": "data(color)",
|
||||
"border-color": "#020617",
|
||||
"border-width": 2,
|
||||
color: "#dbeafe",
|
||||
"font-family": "Inter, Segoe UI, sans-serif",
|
||||
"font-size": 11,
|
||||
"font-weight": 600,
|
||||
"text-outline-color": "#020617",
|
||||
"text-outline-width": 3,
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"line-height": 1.15,
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": 88,
|
||||
width: "mapData(weight, 1, 12, 28, 74)",
|
||||
height: "mapData(weight, 1, 12, 28, 74)",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[type = "client"]',
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
"border-color": "data(color)",
|
||||
"border-width": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[online = true]',
|
||||
style: {
|
||||
"border-color": "#22c55e",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 1.35,
|
||||
"line-color": "rgba(148, 163, 184, 0.42)",
|
||||
"target-arrow-shape": "none",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".graph-dim",
|
||||
style: {
|
||||
opacity: 0.12,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".graph-focus",
|
||||
style: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => selectNode(event.target));
|
||||
cy.on("tap", (event) => {
|
||||
if (event.target === cy) selectNode(null);
|
||||
});
|
||||
} else {
|
||||
cy.elements().remove();
|
||||
cy.add(graphElements(graph));
|
||||
}
|
||||
|
||||
cy.layout(layoutOptions(layoutSelect?.value || "cose", graph)).run();
|
||||
setTimeout(() => cy?.fit(undefined, 32), 420);
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
if (!container) return;
|
||||
if (summaryEl) summaryEl.textContent = "Loading...";
|
||||
const params = new URLSearchParams({
|
||||
limit: "350",
|
||||
q: searchInput?.value || "",
|
||||
status: statusSelect?.value || "all",
|
||||
});
|
||||
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
|
||||
return;
|
||||
}
|
||||
renderGraph(await res.json());
|
||||
}
|
||||
|
||||
function debounceLoad() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(loadGraph, 220);
|
||||
}
|
||||
|
||||
layoutSelect?.addEventListener("change", () => {
|
||||
if (cy) cy.layout(layoutOptions(layoutSelect.value, currentGraph)).run();
|
||||
});
|
||||
searchInput?.addEventListener("input", debounceLoad);
|
||||
statusSelect?.addEventListener("change", loadGraph);
|
||||
refreshBtn?.addEventListener("click", loadGraph);
|
||||
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
|
||||
window.addEventListener("resize", () => {
|
||||
cy?.resize();
|
||||
cy?.fit(undefined, 32);
|
||||
});
|
||||
|
||||
bindInspectorActions();
|
||||
loadGraph();
|
||||
@@ -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% {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user