import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react'; import { ReactFlow, Background, MiniMap, useEdgesState, useNodesState, addEdge, MarkerType, Handle, Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react'; import Tooltip from './components/Tooltip.jsx'; function GraphView({ servers, connections, onCreateConnection }) { const flowRef = useRef(null); const instanceRef = useRef(null); 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) => { const map = { GRE: { color: '#206bc4', dash: '0', width: 2.5 }, IPSec: { color: '#f59f00', dash: '6,4', width: 2.5 }, WireGuard: { color: '#2fb344', dash: '0', width: 3.2 }, OpenVPN: { color: '#be4bdb', dash: '3,3', width: 2.5 }, }; return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 }; }, []); const getCountryColor = useCallback((country) => { const colors = { RU: '#dc2626', US: '#2563eb', DE: '#059669', SE: '#7c3aed', NL: '#ea580c', SG: '#0891b2', }; return colors[country] || '#6b7280'; }, []); const computeGridPosition = useCallback((index, total) => { const cols = Math.ceil(Math.sqrt(total)); const row = Math.floor(index / cols); const col = index % cols; const spacingX = 280; const spacingY = 180; return { x: 80 + col * spacingX, y: 80 + row * spacingY }; }, []); const initialNodesData = useMemo(() => { // Базовая сетка const baseNodes = (servers || []).map((s, i) => ({ id: String(s.ip), type: 'server', position: computeGridPosition(i, servers.length), data: { server: s }, })); // Переопределяем позиции из localStorage, если есть if (typeof window !== 'undefined') { try { const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) { const saved = JSON.parse(raw) || {}; return baseNodes.map((n) => { const savedPos = saved[n.id]; if (savedPos && typeof savedPos.x === 'number' && typeof savedPos.y === 'number') { return { ...n, position: { x: savedPos.x, y: savedPos.y } }; } return n; }); } } catch { // игнорируем проблемы с localStorage } } return baseNodes; }, [servers, computeGridPosition]); const [nodes, setNodes, onNodesChangeInternal] = useNodesState(initialNodesData); // Обёртка над onNodesChange: обновляем состояние и сохраняем позиции в localStorage const onNodesChange = useCallback( (changes) => { onNodesChangeInternal(changes); // Сохраняем только позиции setNodes((current) => { if (typeof window !== 'undefined') { try { const layout = {}; current.forEach((n) => { if (n.position) { layout[n.id] = { x: n.position.x, y: n.position.y }; } }); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch { // ignore } } return current; }); }, [onNodesChangeInternal, setNodes] ); useEffect(() => { setNodes(initialNodesData); }, [initialNodesData, setNodes]); // Подсветка связей: вычисляем какие связи связаны с выделенным/выбранным узлом // ВАЖНО: должно быть ПЕРЕД initialEdgesData, чтобы использоваться там 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]); // Счётчик связей для каждого сервера // ВАЖНО: должно быть ПЕРЕД nodeTypes, чтобы использоваться там 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]); const initialEdgesData = useMemo(() => { return (connections || []).map((c, idx) => { 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: edgeId, source: String(c.from), target: String(c.to), label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel, type: 'smoothstep', style: { stroke: style.color, 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, labelStyle: { fontSize: 11, fontWeight: 500, fill: '#0f172a', }, className: c.tunnelType ? `edge-tunnel-${String(c.tunnelType).toLowerCase()}` : 'edge-tunnel-default', markerStart: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 }, markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 }, animated: false, }; }); }, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData); useEffect(() => { setEdges(initialEdgesData); }, [initialEdgesData, setEdges]); // Поиск сервера: фильтруем по 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) => { // Делегируем создание связи в родителя, чтобы сохранить единую логику хранения if (!params?.source || !params?.target || params.source === params.target) return; if (onCreateConnection) { onCreateConnection({ from: params.source, to: params.target }); } }, [onCreateConnection] ); const nodeTypes = useMemo( () => ({ server: ({ id, data }) => { const s = data.server || {}; const countryColor = getCountryColor(s.country); const getFlagEmoji = (code) => { if (!code) return ''; const map = { SWE: 'SE', UK: 'GB' }; const ccRaw = String(code).trim().toUpperCase(); const cc = (map[ccRaw] || ccRaw).slice(0, 2); if (cc.length !== 2) return ccRaw; return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt())); }; const flag = getFlagEmoji(s.country); const isHighlighted = highlightedNodeId === id; const isSelected = selectedNodeId === id; const connCount = connectionCounts[id] || 0; const tooltipContent = (
Добавьте серверы, чтобы увидеть граф связей