diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx index c3f7f41..9f0efa9 100644 --- a/frontend/src/GraphView.jsx +++ b/frontend/src/GraphView.jsx @@ -11,7 +11,7 @@ import { Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react'; +import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react'; import Tooltip from './components/Tooltip.jsx'; function GraphView({ servers, connections, onCreateConnection }) { @@ -20,6 +20,9 @@ function GraphView({ servers, connections, onCreateConnection }) { const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); const [highlightedNodeId, setHighlightedNodeId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [showSearch, setShowSearch] = useState(false); const STORAGE_KEY = 'graph-layout-servers-v1'; const getTunnelStyle = useCallback((tunnelType) => { @@ -120,16 +123,20 @@ function GraphView({ servers, connections, onCreateConnection }) { const style = getTunnelStyle(c.tunnelType); const baseLabel = c.tunnelType || 'TUNNEL'; const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : ''; + const edgeId = `${c.from}-${c.to}-${idx}`; + const isHighlighted = highlightedEdges.has(edgeId); return { - id: `${c.from}-${c.to}-${idx}`, + id: edgeId, source: String(c.from), target: String(c.to), label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel, type: 'smoothstep', style: { stroke: style.color, - strokeWidth: style.width, + strokeWidth: isHighlighted ? style.width * 1.5 : style.width, strokeDasharray: style.dash, + opacity: highlightedNodeId || selectedNodeId ? (isHighlighted ? 1 : 0.25) : 0.9, + filter: isHighlighted ? 'drop-shadow(0 0 4px ' + style.color + ')' : 'none', }, labelBgPadding: [6, 4], labelBgBorderRadius: 999, @@ -144,7 +151,7 @@ function GraphView({ servers, connections, onCreateConnection }) { animated: false, }; }); - }, [connections, getTunnelStyle]); + }, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData); @@ -152,6 +159,76 @@ function GraphView({ servers, connections, onCreateConnection }) { setEdges(initialEdgesData); }, [initialEdgesData, setEdges]); + // Подсветка связей: вычисляем какие связи связаны с выделенным/выбранным узлом + const highlightedEdges = useMemo(() => { + const nodeId = selectedNodeId || highlightedNodeId; + if (!nodeId) return new Set(); + const related = new Set(); + (connections || []).forEach((c, idx) => { + if (String(c.from) === nodeId || String(c.to) === nodeId) { + related.add(`${c.from}-${c.to}-${idx}`); + } + }); + return related; + }, [selectedNodeId, highlightedNodeId, connections]); + + // Счётчик связей для каждого сервера + const connectionCounts = useMemo(() => { + const counts = {}; + (servers || []).forEach((s) => { + const ip = String(s.ip); + counts[ip] = (connections || []).filter((c) => String(c.from) === ip || String(c.to) === ip).length; + }); + return counts; + }, [servers, connections]); + + // Поиск сервера: фильтруем по IP, DNS, провайдеру + const searchResults = useMemo(() => { + if (!searchTerm.trim()) return []; + const term = searchTerm.toLowerCase(); + return (servers || []).filter((s) => + String(s.ip).toLowerCase().includes(term) || + String(s.dns || '').toLowerCase().includes(term) || + String(s.provider || '').toLowerCase().includes(term) + ); + }, [searchTerm, servers]); + + // Фокус на найденный сервер + const focusNode = useCallback((nodeId) => { + if (!instanceRef.current || !nodeId) return; + try { + instanceRef.current.fitView({ + nodes: [{ id: nodeId }], + padding: 0.3, + duration: 400 + }); + setSelectedNodeId(nodeId); + setTimeout(() => setSelectedNodeId(null), 2000); + } catch {} + }, []); + + // Сброс раскладки: очищаем localStorage и пересоздаём ноды + const resetLayout = useCallback(() => { + if (typeof window !== 'undefined') { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch {} + } + setNodes((current) => { + return current.map((n, i) => ({ + ...n, + position: computeGridPosition(i, current.length), + })); + }); + setTimeout(() => { + if (instanceRef.current) { + try { + instanceRef.current.fitView({ padding: 0.2 }); + } catch {} + } + }, 100); + }, [setNodes, computeGridPosition]); + const onConnect = useCallback( (params) => { // Делегируем создание связи в родителя, чтобы сохранить единую логику хранения @@ -178,24 +255,50 @@ function GraphView({ servers, connections, onCreateConnection }) { }; const flag = getFlagEmoji(s.country); const isHighlighted = highlightedNodeId === id; + const isSelected = selectedNodeId === id; + const connCount = connectionCounts[id] || 0; + const tooltipContent = ( +