import React, { useEffect, useState, useRef, useCallback, useMemo, useReducer, } 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 { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogAction, } from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Move3D, ZoomIn, ZoomOut, RotateCw, AlertCircle, Download, Upload, Link2, FolderPlus, Edit, FolderInput, FolderMinus, Settings2, Terminal, ArrowUp, NetworkIcon, FolderOpen, Container, Server, Check, ChevronsUpDown, ArrowDownUp, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader"; 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; } export function NetworkGraphCard({ embedded = true, }: 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 [hostComboOpen, setHostComboOpen] = useState(false); const [groupComboOpen, setGroupComboOpen] = useState(false); const [moveGroupComboOpen, setMoveGroupComboOpen] = useState(false); const [sourceComboOpen, setSourceComboOpen] = useState(false); const [targetComboOpen, setTargetComboOpen] = useState(false); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); 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(() => { 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"); } const nodeIds = new Set(nodes.map((n: any) => n.data.id)); const validEdges = edges.filter((edge: any) => { const sourceExists = nodeIds.has(edge.data.source); const targetExists = nodeIds.has(edge.data.target); return sourceExists && targetExists; }); setElements([...nodes, ...validEdges]); } catch (err) { console.error("Failed to load topology:", err); } 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; if (!embedded) { cy.nodes().forEach((node) => { node.grabify(); }); } else { cy.nodes().forEach((node) => { node.ungrabify(); }); } 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 ? `rgb(16, 185, 129)` : isOffline ? `rgb(239, 68, 68)` : `rgb(100, 116, 139)`; const isDarkMode = document.documentElement.classList.contains("dark"); const bgColor = isDarkMode ? "#09090b" : "#f9fafb"; const textColor = isDarkMode ? "#f1f5f9" : "#18181b"; const secondaryTextColor = isDarkMode ? "#94a3b8" : "#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": -5, 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 nodeId = node.id(); const isGroup = node.isParent() || String(nodeId).startsWith("group-"); if (isGroup && embedded) { return; } const x = evt.originalEvent.clientX; const y = evt.originalEvent.clientY; setContextMenu({ visible: true, x, y, targetId: nodeId, type: isGroup ? "group" : "node", }); }); cy.on("zoom pan", () => { setContextMenu((prev) => prev.visible ? { ...prev, visible: false } : prev, ); }); cy.on("free", "node", () => !embedded && debouncedSave()); cy.on("boxselect", "node", () => { const selected = cy.$("node:selected"); if (selected.length === 1) setSelectedNodeId(selected[0].id()); }); }, [debouncedSave, embedded], ); 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 handleConnectAction = (appType: string) => { setContextMenu((prev) => ({ ...prev, visible: false })); const host = hostMap[contextMenu.targetId]; if (!host) return; const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; addTab({ type: appType, title, hostConfig: host }); }; const hasTunnelConnections = (host: SSHHostWithStatus | undefined) => { if (!host?.tunnelConnections) return false; try { const tunnelConnections = Array.isArray(host.tunnelConnections) ? host.tunnelConnections : JSON.parse(host.tunnelConnections); return Array.isArray(tunnelConnections) && tunnelConnections.length > 0; } catch { return false; } }; const handleAddNode = () => { setSelectedHostForAddNode(""); setSelectedGroupForAddNode("ROOT"); setShowAddNodeDialog(true); }; const handleConfirmAddNode = async () => { if (!cyRef.current || !selectedHostForAddNode) return; try { if (cyRef.current.$id(selectedHostForAddNode).length > 0) { setError(t("networkGraph.hostAlreadyExists")); 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(); setElements([...cyRef.current.elements().jsons()]); forceUpdate(); setShowAddNodeDialog(false); } catch (err) { 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()]); forceUpdate(); 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(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 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]); const handleOpenInNewTab = () => { addTab({ type: "network_graph", title: t("dashboard.networkGraph"), }); }; if (!embedded) { return (

{t("dashboard.networkGraph")}

setError(null)}>
{error}
setError(null)}> OK
{ 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 (err) { setError(t("networkGraph.invalidFile")); } }; reader.readAsText(file); }} className="hidden" />
{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 && ( <>
)}
)}
{t("networkGraph.addHost")}
{t("networkGraph.noHostFound")} {availableHostsForAdd.map((h) => ( { setSelectedHostForAddNode(String(h.id)); setHostComboOpen(false); }} >
{h.name || h.ip} {h.username}@{h.ip}:{h.port}
))}
{t("networkGraph.noGroupFound")} { setSelectedGroupForAddNode("ROOT"); setGroupComboOpen(false); }} > {t("networkGraph.noGroup")} {availableGroups.map((g) => ( { setSelectedGroupForAddNode(g.id); setGroupComboOpen(false); }} > {g.label} ))}
{ if (!open) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); } }} > {showEditGroupDialog ? t("networkGraph.editGroup") : t("networkGraph.createGroup")}
setNewGroupName(e.target.value)} placeholder={t("networkGraph.groupName")} className="border-2 border-edge" />
{AVAILABLE_COLORS.map((color) => (
{t("networkGraph.moveToGroup")}
{t("networkGraph.noGroupFound")} { setSelectedGroupForMove("ROOT"); setMoveGroupComboOpen(false); }} > {t("networkGraph.noGroup")} {availableGroups.map((g) => ( { if (g.id !== selectedNodeId) { setSelectedGroupForMove(g.id); setMoveGroupComboOpen(false); } }} > {g.label} ))}
{t("networkGraph.addConnection")}
{t("networkGraph.noNodeFound")} {availableNodesForConnection.map((el) => ( { setSelectedHostForEdge(el.id); setSourceComboOpen(false); }} > {el.label} ))}
{t("networkGraph.noNodeFound")} {availableNodesForConnection.map((el) => ( { setTargetHostForEdge(el.id); setTargetComboOpen(false); }} > {el.label} ))}
{t("networkGraph.hostDetails")} {selectedNodeForDetail && (
{t("networkGraph.name")}: {selectedNodeForDetail.name} {t("networkGraph.ip")}: {selectedNodeForDetail.ip} {t("networkGraph.status")}: {selectedNodeForDetail.status || t("networkGraph.unknown")} {t("networkGraph.id")}: {selectedNodeForDetail.id}
{selectedNodeForDetail.tags && selectedNodeForDetail.tags.length > 0 && (
{selectedNodeForDetail.tags.map((t) => ( {t} ))}
)}
)}
); } return (

{t("dashboard.networkGraph")}

setError(null)}>
{error}
setError(null)}> OK
{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 && ( )} )}
)}
); }