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 } from '@tabler/icons-react'; function GraphView({ servers, connections, onCreateConnection }) { const flowRef = useRef(null); const instanceRef = useRef(null); const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); 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(() => { return (servers || []).map((s, i) => ({ id: String(s.ip), type: 'server', position: computeGridPosition(i, servers.length), data: { server: s }, })); }, [servers, computeGridPosition]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodesData); useEffect(() => { setNodes(initialNodesData); }, [initialNodesData, setNodes]); const initialEdgesData = useMemo(() => { return (connections || []).map((c, idx) => { const style = getTunnelStyle(c.tunnelType); return { id: `${c.from}-${c.to}-${idx}`, source: String(c.from), target: String(c.to), label: c.ipA && c.ipB ? `${c.tunnelType} ${c.ipA} ⇄ ${c.ipB}` : c.tunnelType, style: { stroke: style.color, strokeWidth: style.width, strokeDasharray: style.dash }, markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 }, animated: false, }; }); }, [connections, getTunnelStyle]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData); useEffect(() => { setEdges(initialEdgesData); }, [initialEdgesData, setEdges]); 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: ({ 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); return (
Добавьте серверы, чтобы увидеть граф связей