import React, { useEffect, useState, useRef, useCallback, useMemo, } from "react"; import CytoscapeComponent from "react-cytoscapejs"; import cytoscape from "cytoscape"; import { getSSHHosts, getNetworkTopology, saveNetworkTopology, type SSHHostWithStatus, } from "@/ui/main-axios"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Plus, Trash2, Move3D, ZoomIn, ZoomOut, RotateCw, Loader2, AlertCircle, Download, Upload, Link2, FolderPlus, Edit, FolderInput, FolderMinus, Settings2, Terminal, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; 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; } export function NetworkGraphCard({}: NetworkGraphCardProps): React.ReactElement { const { t } = useTranslation(); const { addTab } = useTabs(); 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 cyRef = useRef(null); const statusCheckIntervalRef = useRef(null); const saveTimeoutRef = useRef(null); const contextMenuRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { hostMapRef.current = hostMap; }, [hostMap]); useEffect(() => { if (!cyRef.current) return; const canvas = document.createElement("canvas"); const container = cyRef.current.container(); if (!container) return; canvas.style.position = "absolute"; canvas.style.top = "0"; canvas.style.left = "0"; canvas.style.pointerEvents = "none"; canvas.style.zIndex = "999"; container.appendChild(canvas); const ctx = canvas.getContext("2d"); if (!ctx) return; let animationFrame: number; let startTime = Date.now(); const animate = () => { if (!cyRef.current || !ctx) return; const canvasWidth = cyRef.current.width(); const canvasHeight = cyRef.current.height(); canvas.width = canvasWidth; canvas.height = canvasHeight; ctx.clearRect(0, 0, canvasWidth, canvasHeight); const elapsed = Date.now() - startTime; const cycle = (elapsed % 2000) / 2000; const zoom = cyRef.current.zoom(); const baseRadius = 4; const pulseAmount = 3; const pulseRadius = (baseRadius + Math.sin(cycle * Math.PI * 2) * pulseAmount) * zoom; const pulseOpacity = 0.6 - Math.sin(cycle * Math.PI * 2) * 0.4; cyRef.current.nodes('[status="online"]').forEach((node) => { if (node.isParent()) return; const pos = node.renderedPosition(); const nodeWidth = 180 * zoom; const nodeHeight = 90 * zoom; const dotX = pos.x + nodeWidth / 2 - 10 * zoom; const dotY = pos.y - nodeHeight / 2 + 10 * zoom; ctx.beginPath(); ctx.arc(dotX, dotY, pulseRadius, 0, Math.PI * 2); ctx.fillStyle = `rgba(16, 185, 129, ${pulseOpacity})`; ctx.fill(); ctx.beginPath(); ctx.arc(dotX, dotY, baseRadius * zoom, 0, Math.PI * 2); ctx.fillStyle = "#10b981"; ctx.fill(); }); cyRef.current.nodes('[status="offline"]').forEach((node) => { if (node.isParent()) return; const pos = node.renderedPosition(); const nodeWidth = 180 * zoom; const nodeHeight = 90 * zoom; const dotX = pos.x + nodeWidth / 2 - 10 * zoom; const dotY = pos.y - nodeHeight / 2 + 10 * zoom; ctx.beginPath(); ctx.arc(dotX, dotY, 4 * zoom, 0, Math.PI * 2); ctx.fillStyle = "#ef4444"; ctx.fill(); }); animationFrame = requestAnimationFrame(animate); }; animate(); return () => { if (animationFrame) cancelAnimationFrame(animationFrame); if (container && canvas.parentNode === container) { container.removeChild(canvas); } }; }, [elements]); useEffect(() => { loadData(); const interval = setInterval(updateHostStatuses, 30000); statusCheckIntervalRef.current = interval; const handleClickOutside = (e: MouseEvent) => { if ( contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node) ) { setContextMenu((prev) => prev.visible ? { ...prev, visible: false } : prev, ); } }; document.addEventListener("mousedown", handleClickOutside, true); return () => { if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); document.removeEventListener("mousedown", handleClickOutside, true); }; }, []); const loadData = async () => { try { setLoading(true); setError(null); const hostsData = await getSSHHosts(); const hostsArray = Array.isArray(hostsData) ? hostsData : []; setHosts(hostsArray); const newHostMap: HostMap = {}; hostsArray.forEach((host) => { newHostMap[String(host.id)] = host; }); setHostMap(newHostMap); let nodes: any[] = []; let edges: any[] = []; try { const topologyData = await getNetworkTopology(); if ( topologyData && topologyData.nodes && Array.isArray(topologyData.nodes) ) { nodes = topologyData.nodes.map((node: any) => { const host = newHostMap[node.data.id]; return { data: { id: node.data.id, label: host?.name || node.data.label || "Unknown", ip: host ? `${host.ip}:${host.port}` : node.data.ip || "", status: host?.status || "unknown", tags: host?.tags || [], parent: node.data.parent, color: node.data.color, }, position: node.position || { x: 0, y: 0 }, }; }); edges = topologyData.edges || []; } } catch (topologyError) { console.warn("Starting with empty topology"); } setElements([...nodes, ...edges]); } catch (err) { console.error("Failed to load topology:", err); setError("Failed to load data"); } finally { setLoading(false); } }; const updateHostStatuses = useCallback(async () => { if (!cyRef.current) return; try { const updatedHosts = await getSSHHosts(); const updatedHostMap: HostMap = {}; updatedHosts.forEach((host) => { updatedHostMap[String(host.id)] = host; }); cyRef.current.nodes().forEach((node) => { if (node.isParent()) return; const hostId = node.data("id"); const updatedHost = updatedHostMap[hostId]; if (updatedHost) { node.data("status", updatedHost.status); node.data("tags", updatedHost.tags || []); } }); setHostMap(updatedHostMap); } catch (err) { console.error("Status update failed:", err); } }, []); 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((node) => ({ data: { id: node.data("id"), label: node.data("label"), ip: node.data("ip"), status: node.data("status"), tags: node.data("tags") || [], parent: node.data("parent"), color: node.data("color"), }, position: node.position(), })); const edges = cyRef.current.edges().map((edge) => ({ data: { id: edge.data("id"), source: edge.data("source"), target: edge.data("target"), }, })); await saveNetworkTopology({ nodes, edges }); } catch (err) { console.error("Save failed:", err); } }; useEffect(() => { if (!cyRef.current || loading || elements.length === 0) return; const hasPositions = elements.some( (el: any) => 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 handleNodeInit = useCallback( (cy: cytoscape.Core) => { cyRef.current = cy; cy.style() .selector("node") .style({ label: "", width: "180px", height: "90px", shape: "round-rectangle", "border-width": "0px", "background-opacity": 0, "background-image": function (ele) { const host = ele.data(); const name = host.label || ""; const ip = host.ip || ""; const tags = host.tags || []; const isOnline = host.status === "online"; const isOffline = host.status === "offline"; const statusColor = isOnline ? "#10b981" : isOffline ? "#ef4444" : "#64748b"; const tagsHtml = tags .map( (t) => ` ${t}`, ) .join(""); const svg = `
${name}
${ip}
${tagsHtml}
`; return "data:image/svg+xml;utf8," + encodeURIComponent(svg); }, "background-fit": "contain", }) .selector("node:parent") .style({ "background-image": "none", "background-color": (ele) => ele.data("color") || "#1e3a8a", "background-opacity": 0.05, "border-color": (ele) => ele.data("color") || "#3b82f6", "border-width": "2px", "border-style": "dashed", label: "data(label)", "text-valign": "top", "text-halign": "center", "text-margin-y": -20, color: "#94a3b8", "font-size": "16px", "font-weight": "bold", shape: "round-rectangle", padding: "10px", }) .selector("edge") .style({ width: "2px", "line-color": "#373739", "curve-style": "round-taxi", "source-endpoint": "outside-to-node", "target-endpoint": "outside-to-node", "control-point-step-size": 10, "control-point-distances": [40, -40], "control-point-weights": [0.2, 0.8], "target-arrow-shape": "none", }) .selector("edge:selected") .style({ "line-color": "#3b82f6", width: "3px", }) .selector("node:selected") .style({ "overlay-color": "#3b82f6", "overlay-opacity": 0.05, "overlay-padding": "5px", }); cy.on("tap", "node", (evt) => { const node = evt.target; setContextMenu((prev) => prev.visible ? { ...prev, visible: false } : prev, ); setSelectedEdgeId(null); setSelectedNodeId(node.id()); }); cy.on("tap", "edge", (evt) => { evt.stopPropagation(); setSelectedEdgeId(evt.target.id()); setSelectedNodeId(null); }); cy.on("tap", (evt) => { if (evt.target === cy) { setContextMenu((prev) => prev.visible ? { ...prev, visible: false } : prev, ); setSelectedNodeId(null); setSelectedEdgeId(null); } }); cy.on("cxttap", "node", (evt) => { evt.preventDefault(); evt.stopPropagation(); const node = evt.target; const x = evt.originalEvent.clientX; const y = evt.originalEvent.clientY; setContextMenu({ visible: true, x, y, targetId: node.id(), type: node.isParent() ? "group" : "node", }); }); cy.on("zoom pan", () => { setContextMenu((prev) => prev.visible ? { ...prev, visible: false } : prev, ); }); cy.on("free", "node", () => debouncedSave()); cy.on("boxselect", "node", () => { const selected = cy.$("node:selected"); if (selected.length === 1) setSelectedNodeId(selected[0].id()); }); }, [debouncedSave], ); const handleContextAction = (action: string) => { setContextMenu((prev) => ({ ...prev, visible: false })); const targetId = contextMenu.targetId; if (!cyRef.current) return; if (action === "details") { const host = hostMap[targetId]; if (host) { setSelectedNodeForDetail(host); setShowNodeDetail(true); } } else if (action === "connect") { const host = hostMap[targetId]; if (host) { const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; addTab({ type: "terminal", title, hostConfig: host }); } } else if (action === "move") { setSelectedNodeId(targetId); const node = cyRef.current.$id(targetId); const parentId = node.data("parent"); setSelectedGroupForMove(parentId || "ROOT"); setShowMoveNodeDialog(true); } else if (action === "removeFromGroup") { const node = cyRef.current.$id(targetId); node.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 handleAddNode = () => { setSelectedHostForAddNode(""); setSelectedGroupForAddNode("ROOT"); setShowAddNodeDialog(true); }; const handleConfirmAddNode = async () => { if (!cyRef.current || !selectedHostForAddNode) return; try { if (cyRef.current.$id(selectedHostForAddNode).length > 0) { setError("Host is already in the topology"); return; } const host = hostMap[selectedHostForAddNode]; const parent = selectedGroupForAddNode === "ROOT" ? undefined : selectedGroupForAddNode; const newNode = { data: { id: selectedHostForAddNode, label: host.name || `${host.ip}`, ip: `${host.ip}:${host.port}`, status: host.status, tags: host.tags || [], parent: parent, }, position: { x: 100 + Math.random() * 50, y: 100 + Math.random() * 50 }, }; cyRef.current.add(newNode); await saveCurrentLayout(); setShowAddNodeDialog(false); } catch (err) { setError("Failed to add node"); } }; 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(); setShowAddGroupDialog(false); setNewGroupName(""); }; const handleUpdateGroup = async () => { if (!cyRef.current || !editingGroupId || !newGroupName) return; const group = cyRef.current.$id(editingGroupId); group.data("label", newGroupName); group.data("color", newGroupColor); await saveCurrentLayout(); setShowEditGroupDialog(false); setEditingGroupId(null); }; const handleMoveNodeToGroup = async () => { if (!cyRef.current || !selectedNodeId) return; const node = cyRef.current.$id(selectedNodeId); const parent = selectedGroupForMove === "ROOT" ? null : selectedGroupForMove; node.move({ parent: parent }); await saveCurrentLayout(); setShowMoveNodeDialog(false); }; const handleAddEdge = async () => { if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return; if (selectedHostForEdge === targetHostForEdge) return setError("Source and target must be different"); const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`; if (cyRef.current.$id(edgeId).length > 0) return setError("Connection exists"); 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 availableGroups = useMemo(() => { return 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 })); }, [elements]); const availableNodesForConnection = useMemo(() => { return elements .filter((el) => !el.data.source && !el.data.target) .map((el) => ({ id: el.data.id, label: el.data.label, })); }, [elements]); const availableHostsForAdd = useMemo(() => { if (!cyRef.current) return hosts; const existingIds = new Set(elements.map((e) => e.data.id)); return hosts.filter((h) => !existingIds.has(String(h.id))); }, [hosts, elements]); return (

{t("dashboard.networkGraph")}

{error && (
{error}
)}
{ const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const json = JSON.parse(evt.target?.result as string); saveNetworkTopology({ nodes: json.nodes, edges: json.edges, }).then(() => loadData()); } catch (err) { setError("Invalid File"); } }; reader.readAsText(file); }} className="hidden" />
{loading && (
)} {contextMenu.visible && (
{contextMenu.type === "node" && ( <> {cyRef.current?.$id(contextMenu.targetId).parent().length ? ( ) : null} )} {contextMenu.type === "group" && ( <> )}
)}
Add Host
{ if (!open) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); } }} > {showEditGroupDialog ? "Edit Group" : "Create Group"}
setNewGroupName(e.target.value)} placeholder="e.g. Cluster A" className="border-2 border-edge" />
setNewGroupColor(e.target.value)} className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent" /> {newGroupColor}
Move to Group
Add Connection
Host Details {selectedNodeForDetail && (
Name: {selectedNodeForDetail.name} IP: {selectedNodeForDetail.ip} Status: {selectedNodeForDetail.status} ID: {selectedNodeForDetail.id}
{selectedNodeForDetail.tags && selectedNodeForDetail.tags.length > 0 && (
{selectedNodeForDetail.tags.map((t) => ( {t} ))}
)}
)}
); }