/* eslint-disable react-hooks/exhaustive-deps */ import React, { useEffect, useState, useRef, useCallback, useMemo, useReducer, } from "react"; import { Card } from "@/components/card"; import CytoscapeComponent from "react-cytoscapejs"; import cytoscape from "cytoscape"; import { getSSHHosts, getNetworkTopology, saveNetworkTopology, type SSHHostWithStatus, type NetworkTopologyEdge, type NetworkTopologyNode, } from "@/main-axios"; import { Button } from "@/components/button"; import { Badge } from "@/components/badge"; import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogAction, } from "@/components/alert-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/dialog"; import { Input } from "@/components/input"; import { Label } from "@/components/label"; import { Plus, Trash2, ZoomIn, ZoomOut, RotateCw, AlertCircle, Download, ExternalLink, Upload, Link2, FolderPlus, Edit, FolderInput, FolderMinus, Terminal, ArrowUp, Network, FolderOpen, Container, Server, ArrowDownUp, Loader2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTabsSafe } from "@/shell/TabContext"; import { cn } from "@/lib/utils"; const AVAILABLE_COLORS = [ { value: "#ef4444", label: "Red" }, { value: "#f97316", label: "Orange" }, { value: "#eab308", label: "Yellow" }, { value: "#22c55e", label: "Green" }, { value: "#3b82f6", label: "Blue" }, { value: "#a855f7", label: "Purple" }, { value: "#ec4899", label: "Pink" }, { value: "#6b7280", label: "Gray" }, ]; interface HostMap { [key: string]: SSHHostWithStatus; } interface ContextMenuState { visible: boolean; x: number; y: number; targetId: string; type: "node" | "group" | "edge" | null; } interface NetworkGraphCardProps { isTopbarOpen?: boolean; rightSidebarOpen?: boolean; rightSidebarWidth?: number; embedded?: boolean; onOpenInNewTab?: () => void; } type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; function resolveCssVar(varName: string, fallback: string): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue(varName) .trim(); if (!raw) return fallback; const tmp = document.createElement("div"); tmp.style.position = "absolute"; tmp.style.visibility = "hidden"; tmp.style.backgroundColor = `var(${varName})`; document.body.appendChild(tmp); const resolved = getComputedStyle(tmp).backgroundColor; document.body.removeChild(tmp); if ( !resolved || resolved === "rgba(0, 0, 0, 0)" || resolved.includes("oklch") ) return fallback; return resolved; } const NODE_W = 220; const NODE_H = 88; function buildNodeSvg( name: string, ip: string, tags: string[], status: string, ): string { const isOnline = status === "online"; const isOffline = status === "offline"; const useRealColors = localStorage.getItem("statusColorScheme") === "status"; let statusColor: string; if (isOnline) { statusColor = useRealColors ? "rgb(16,185,129)" : resolveCssVar("--accent-brand", "rgb(16,185,129)"); } else if (isOffline) { statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; } else { statusColor = "rgb(100,116,139)"; } const bg = resolveCssVar("--card", "#1e1e20"); const border = resolveCssVar("--border", "#2a2a2c"); const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9"); const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8"); const dpr = Math.min(window.devicePixelRatio || 1, 3); const W = NODE_W * dpr; const H = NODE_H * dpr; const s = dpr; const esc = (str: string) => str.replace( /[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c] ?? c, ); const tagsHtml = tags .slice(0, 3) .map( (tag) => `${esc(tag)}`, ) .join(""); const serverIcon = ` `; return ( "data:image/svg+xml;utf8," + encodeURIComponent( ` ${serverIcon} ${esc(name)} ${esc(ip)} ${tagsHtml ? `
${tagsHtml}
` : ""}
`, ) ); } export function NetworkGraphCard({ embedded = true, onOpenInNewTab, }: NetworkGraphCardProps): React.ReactElement { const { t } = useTranslation(); const { addTab } = useTabsSafe(); // Gate Cytoscape mounting on the container actually having non-zero dimensions. // This avoids the "bb is undefined" crash when the card is hidden via display:none. const [containerReady, setContainerReady] = useState(false); const [elements, setElements] = useState([]); const [hosts, setHosts] = useState([]); const [hostMap, setHostMap] = useState({}); const hostMapRef = useRef({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [showAddNodeDialog, setShowAddNodeDialog] = useState(false); const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false); const [showAddGroupDialog, setShowAddGroupDialog] = useState(false); const [showEditGroupDialog, setShowEditGroupDialog] = useState(false); const [showNodeDetail, setShowNodeDetail] = useState(false); const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false); const [selectedHostForAddNode, setSelectedHostForAddNode] = useState(""); const [selectedGroupForAddNode, setSelectedGroupForAddNode] = useState("ROOT"); const [newGroupName, setNewGroupName] = useState(""); const [newGroupColor, setNewGroupColor] = useState("#3b82f6"); const [editingGroupId, setEditingGroupId] = useState(null); const [selectedGroupForMove, setSelectedGroupForMove] = useState("ROOT"); const [selectedHostForEdge, setSelectedHostForEdge] = useState(""); const [targetHostForEdge, setTargetHostForEdge] = useState(""); const [selectedNodeForDetail, setSelectedNodeForDetail] = useState(null); const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, y: 0, targetId: "", type: null, }); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); useEffect(() => { if (containerReady) return; const el = cyContainerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width > 0 && height > 0) { setContainerReady(true); ro.disconnect(); } }); ro.observe(el); return () => ro.disconnect(); }, [containerReady]); const cyRef = useRef(null); const statusIntervalRef = useRef | null>(null); const saveTimeoutRef = useRef | null>(null); const contextMenuRef = useRef(null); const fileInputRef = useRef(null); const cyContainerRef = useRef(null); useEffect(() => { hostMapRef.current = hostMap; }, [hostMap]); useEffect(() => { loadData(); statusIntervalRef.current = setInterval(updateHostStatuses, 30000); const onClickOutside = (e: MouseEvent) => { if ( contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node) ) setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); }; document.addEventListener("mousedown", onClickOutside, true); const themeObserver = new MutationObserver(() => { if (cyRef.current) applyStyle(cyRef.current); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"], }); return () => { if (statusIntervalRef.current) clearInterval(statusIntervalRef.current); document.removeEventListener("mousedown", onClickOutside, true); themeObserver.disconnect(); }; }, []); const loadData = async () => { setLoading(true); setError(null); try { const hostsData = await getSSHHosts(); const hostsArray = Array.isArray(hostsData) ? hostsData : []; setHosts(hostsArray); const newMap: HostMap = {}; hostsArray.forEach((h) => (newMap[String(h.id)] = h)); setHostMap(newMap); let nodes: NetworkTopologyNode[] = []; let edges: NetworkTopologyEdge[] = []; try { const topo = await getNetworkTopology(); if (topo?.nodes && Array.isArray(topo.nodes)) { nodes = topo.nodes.map((node) => { const h = newMap[node.data.id]; return { data: { id: node.data.id, label: h?.name || node.data.label || "Unknown", ip: h ? `${h.ip}:${h.port}` : node.data.ip || "", status: h?.status || "unknown", tags: h?.tags || [], parent: node.data.parent, color: node.data.color, }, position: node.position || { x: 0, y: 0 }, }; }); edges = topo.edges || []; } } catch { /* start with empty topology */ } const nodeIds = new Set(nodes.map((n) => n.data.id)); const validEdges = edges.filter( (e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target), ); setElements([...nodes, ...validEdges]); } catch { /* ignore */ } finally { setLoading(false); } }; const updateHostStatuses = useCallback(async () => { if (!cyRef.current) return; try { const updated = await getSSHHosts(); const updatedMap: HostMap = {}; updated.forEach((h) => (updatedMap[String(h.id)] = h)); cyRef.current.nodes().forEach((node) => { if (node.isParent()) return; const h = updatedMap[node.data("id")]; if (h) { node.data("status", h.status); node.data("tags", h.tags || []); } }); setHostMap(updatedMap); } catch { /* ignore */ } }, []); const debouncedSave = useCallback(() => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000); }, []); const saveCurrentLayout = async () => { if (!cyRef.current) return; try { const nodes = cyRef.current.nodes().map((n) => ({ data: { id: n.data("id"), label: n.data("label"), ip: n.data("ip"), status: n.data("status"), tags: n.data("tags") || [], parent: n.data("parent"), color: n.data("color"), }, position: n.position(), })); const edges = cyRef.current.edges().map((e) => ({ data: { id: e.data("id"), source: e.data("source"), target: e.data("target"), }, })); await saveNetworkTopology({ nodes, edges }); } catch { /* ignore */ } }; useEffect(() => { if (!cyRef.current || loading || elements.length === 0) return; const hasPositions = elements.some( (el) => "position" in el && el.position && (el.position.x !== 0 || el.position.y !== 0), ); if (!hasPositions) { cyRef.current .layout({ name: "cose", animate: false, randomize: true, componentSpacing: 100, nodeOverlap: 20, }) .run(); } else { cyRef.current.fit(); } }, [loading]); const applyStyle = useCallback((cy: cytoscape.Core) => { const edgeColor = resolveCssVar("--border", "#4a4a4e"); const foreground = resolveCssVar("--foreground", "#f1f5f9"); cy.style() .selector("node") .style({ label: "", width: `${NODE_W}px`, height: `${NODE_H}px`, shape: "rectangle", "border-width": "0px", "background-opacity": 0, "background-image": (ele) => buildNodeSvg( ele.data("label") || "", ele.data("ip") || "", ele.data("tags") || [], ele.data("status") || "unknown", ), "background-fit": "contain", }) .selector("node:parent") .style({ "background-image": "none", "background-color": (ele) => ele.data("color") || "#1e3a8a", "background-opacity": 0.08, "border-color": (ele) => ele.data("color") || "#3b82f6", "border-width": "1.5px", "border-style": "dashed", label: "data(label)", "text-valign": "top", "text-halign": "center", "text-margin-y": -6, color: foreground, "font-size": "13px", "font-weight": "bold", shape: "rectangle", padding: "20px", }) .selector("edge") .style({ width: "1.5px", "line-color": edgeColor, "curve-style": "bezier", "target-arrow-shape": "none", }) .selector("edge:selected") .style({ "line-color": "#f59145", width: "2.5px" }) .selector("node:selected") .style({ "overlay-color": "#f59145", "overlay-opacity": 0.06, "overlay-padding": "6px", }) .update(); }, []); const handleNodeInit = useCallback( (cy: cytoscape.Core) => { cyRef.current = cy; if (embedded) { cy.nodes().forEach((n) => n.ungrabify()); } else { cy.nodes().forEach((n) => n.grabify()); } applyStyle(cy); cy.on("tap", "node", (evt) => { setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); setSelectedEdgeId(null); setSelectedNodeId(evt.target.id()); }); cy.on("tap", "edge", (evt) => { evt.stopPropagation(); setSelectedEdgeId(evt.target.id()); setSelectedNodeId(null); }); cy.on("tap", (evt) => { if (evt.target === cy) { setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); setSelectedNodeId(null); setSelectedEdgeId(null); } }); cy.on("cxttap", "node", (evt) => { evt.preventDefault(); evt.stopPropagation(); const node = evt.target; const nodeId = node.id(); const isGroup = node.isParent() || String(nodeId).startsWith("group-"); if (isGroup && embedded) return; setContextMenu({ visible: true, x: evt.originalEvent.clientX, y: evt.originalEvent.clientY, targetId: nodeId, type: isGroup ? "group" : "node", }); }); cy.on("zoom pan", () => setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)), ); cy.on("free", "node", () => !embedded && debouncedSave()); cy.on("boxselect", "node", () => { const sel = cy.$("node:selected"); if (sel.length === 1) setSelectedNodeId(sel[0].id()); }); }, [applyStyle, debouncedSave, embedded], ); // Zoom centered on the viewport midpoint — no panning const zoomIn = useCallback(() => { const cy = cyRef.current; if (!cy || !cyContainerRef.current) return; const rect = cyContainerRef.current.getBoundingClientRect(); cy.zoom({ level: cy.zoom() * 1.25, renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, }); }, []); const zoomOut = useCallback(() => { const cy = cyRef.current; if (!cy || !cyContainerRef.current) return; const rect = cyContainerRef.current.getBoundingClientRect(); cy.zoom({ level: cy.zoom() / 1.25, renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, }); }, []); const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); const fireOpen = (hostId: string, type: string) => { window.dispatchEvent( new CustomEvent("termix:open-tab", { detail: { hostId, type } }), ); }; const handleContextAction = (action: string) => { hideMenu(); const targetId = contextMenu.targetId; if (!cyRef.current) return; if (action === "details") { const h = hostMap[targetId]; if (h) { setSelectedNodeForDetail(h); setShowNodeDetail(true); } } else if (action === "connect") { fireOpen(targetId, "terminal"); } else if (action === "move") { setSelectedNodeId(targetId); const node = cyRef.current.$id(targetId); setSelectedGroupForMove(node.data("parent") || "ROOT"); setShowMoveNodeDialog(true); } else if (action === "removeFromGroup") { cyRef.current.$id(targetId).move({ parent: null }); debouncedSave(); } else if (action === "editGroup") { const node = cyRef.current.$id(targetId); setEditingGroupId(targetId); setNewGroupName(node.data("label")); setNewGroupColor(node.data("color") || "#3b82f6"); setShowEditGroupDialog(true); } else if (action === "addHostToGroup") { setSelectedGroupForAddNode(targetId); setSelectedHostForAddNode(""); setShowAddNodeDialog(true); } else if (action === "delete") { cyRef.current.$id(targetId).remove(); debouncedSave(); } }; const handleConnectAction = (appType: string) => { hideMenu(); fireOpen(contextMenu.targetId, appType); }; const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => { if (!h?.tunnelConnections) return false; try { const arr = Array.isArray(h.tunnelConnections) ? h.tunnelConnections : JSON.parse(h.tunnelConnections as string); return Array.isArray(arr) && arr.length > 0; } catch { return false; } }; const handleConfirmAddNode = async () => { if (!cyRef.current || !selectedHostForAddNode) return; try { if (cyRef.current.$id(selectedHostForAddNode).length > 0) { setError(t("networkGraph.hostAlreadyExists")); return; } const h = hostMap[selectedHostForAddNode]; const parent = selectedGroupForAddNode === "ROOT" ? undefined : selectedGroupForAddNode; cyRef.current.add({ data: { id: selectedHostForAddNode, label: h?.name || h?.ip || selectedHostForAddNode, ip: h ? `${h.ip}:${h.port}` : "", status: h?.status || "unknown", tags: h?.tags || [], parent, }, position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200, }, }); applyStyle(cyRef.current); await saveCurrentLayout(); setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); forceUpdate(); setShowAddNodeDialog(false); } catch { setError(t("networkGraph.failedToAddNode")); } }; const handleAddGroup = async () => { if (!cyRef.current || !newGroupName) return; const groupId = `group-${Date.now()}`; cyRef.current.add({ data: { id: groupId, label: newGroupName, color: newGroupColor }, }); await saveCurrentLayout(); setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); forceUpdate(); setShowAddGroupDialog(false); setNewGroupName(""); }; const handleUpdateGroup = async () => { if (!cyRef.current || !editingGroupId || !newGroupName) return; const g = cyRef.current.$id(editingGroupId); g.data("label", newGroupName); g.data("color", newGroupColor); await saveCurrentLayout(); setShowEditGroupDialog(false); setEditingGroupId(null); }; const handleMoveNodeToGroup = async () => { if (!cyRef.current || !selectedNodeId) return; cyRef.current.$id(selectedNodeId).move({ parent: selectedGroupForMove === "ROOT" ? null : selectedGroupForMove, }); await saveCurrentLayout(); setShowMoveNodeDialog(false); }; const handleAddEdge = async () => { if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return; if (selectedHostForEdge === targetHostForEdge) return setError(t("networkGraph.sourceDifferentFromTarget")); const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`; if (cyRef.current.$id(edgeId).length > 0) return setError(t("networkGraph.connectionExists")); cyRef.current.add({ data: { id: edgeId, source: selectedHostForEdge, target: targetHostForEdge, }, }); await saveCurrentLayout(); setShowAddEdgeDialog(false); }; const handleRemoveSelected = () => { if (!cyRef.current) return; if (selectedNodeId) { cyRef.current.$id(selectedNodeId).remove(); setSelectedNodeId(null); } else if (selectedEdgeId) { cyRef.current.$id(selectedEdgeId).remove(); setSelectedEdgeId(null); } debouncedSave(); }; const handleExport = () => { if (!cyRef.current) return; const json = JSON.stringify(cyRef.current.json().elements, null, 2); const a = document.createElement("a"); a.href = URL.createObjectURL( new Blob([json], { type: "application/json" }), ); a.download = "network.json"; a.click(); }; const handleOpenInNewTab = () => { if (onOpenInNewTab) { onOpenInNewTab(); } else { addTab({ type: "network_graph", title: t("dashboard.networkGraph"), }); } }; const availableGroups = useMemo( () => elements .filter( (el) => !el.data.source && !el.data.target && !el.data.ip && el.data.id, ) .map((el) => ({ id: el.data.id!, label: el.data.label || el.data.id!, })), [elements], ); const availableNodesForConnection = useMemo( () => elements .filter((el) => !el.data.source && !el.data.target) .map((el) => ({ id: el.data.id!, label: el.data.label || el.data.id!, })), [elements], ); const availableHostsForAdd = useMemo(() => { if (!cyRef.current) return hosts; const existing = new Set(elements.map((e) => e.data.id)); return hosts.filter((h) => !existing.has(String(h.id))); }, [hosts, elements]); const btnCls = "h-7 w-7 p-0 rounded-sm border-0 hover:bg-muted/60 transition-colors flex items-center justify-center text-muted-foreground hover:text-foreground"; const contextMenuEl = contextMenu.visible ? (
{contextMenu.type === "node" && ( <> {hostMap[contextMenu.targetId]?.enableTerminal && ( )} {hostMap[contextMenu.targetId]?.enableFileManager && ( )} {hostMap[contextMenu.targetId]?.enableTunnel && hasTunnelConnections(hostMap[contextMenu.targetId]) && ( )} {hostMap[contextMenu.targetId]?.enableDocker && ( )} {!embedded && ( <>
{cyRef.current?.$id(contextMenu.targetId).parent().length ? ( ) : null}
)} )} {contextMenu.type === "group" && !embedded && ( <>
)}
) : null; const cytoscapeEl = (
e.preventDefault()} > {loading && (
)} {contextMenuEl} {containerReady && ( )} {!loading && elements.length === 0 && (

{t("networkGraph.noNodes")}

)}
); const dialogs = ( <> setError(null)}>
{error}
setError(null)}> OK
{/* Add Host dialog */} {t("networkGraph.addHost")}
{/* Add / Edit Group dialog */} { if (!o) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); } }} > {showEditGroupDialog ? t("networkGraph.editGroup") : t("networkGraph.createGroup")}
setNewGroupName(e.target.value)} placeholder={t("networkGraph.groupName")} />
{AVAILABLE_COLORS.map((c) => (
{/* Move to Group dialog */} {t("networkGraph.moveToGroup")}
{/* Add Edge dialog */} {t("networkGraph.addConnection")}
{/* Node Detail dialog */} {t("networkGraph.hostDetails")} {selectedNodeForDetail && (
{t("networkGraph.name")} {selectedNodeForDetail.name} {t("networkGraph.ip")} {selectedNodeForDetail.ip} {t("networkGraph.status")} {selectedNodeForDetail.status || t("networkGraph.unknown")}
{selectedNodeForDetail.tags && selectedNodeForDetail.tags.length > 0 && (
{selectedNodeForDetail.tags.map((tag) => ( {tag} ))}
)}
)}
{ const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (evt) => { try { const json = JSON.parse(evt.target?.result as string); await saveNetworkTopology({ nodes: json.nodes, edges: json.edges, }); await loadData(); if (fileInputRef.current) fileInputRef.current.value = ""; } catch { setError(t("networkGraph.invalidFile")); } }; reader.readAsText(file); }} /> ); if (!embedded) { return (
{cytoscapeEl} {dialogs}
); } /* ── embedded card ── */ const nodeCount = elements.filter((e) => !e.data.source).length; return (
{t("dashboard.networkGraph")} {!loading && ( {t("dashboardTab.nodes", { count: nodeCount })} )}
{cytoscapeEl} {dialogs}
); }