import cytoscape from "/vendor/cytoscape/cytoscape.esm.min.mjs"; 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; let graphPageActive = true; 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]) => `
`).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 ? "" : `