Files
router-lists-ui/frontend/src/GraphView.jsx
T

538 lines
20 KiB
React

import React, { useState, useRef, useEffect } from 'react';
import * as d3 from 'd3';
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
function GraphView({ servers, connections }) {
// Debug helper (enable/disable via window.GRAPH_DEBUG = true/false in console)
const isDebug = typeof window !== 'undefined' ? (window.GRAPH_DEBUG !== false) : true;
const dbg = (...args) => { if (isDebug && typeof console !== 'undefined') console.log('[GraphView]', ...args); };
const [nodePositions, setNodePositions] = useState({});
const [draggedNode, setDraggedNode] = useState(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [hoveredLinkId, setHoveredLinkId] = useState(null);
const containerRef = useRef(null);
const svgRef = useRef(null);
const gRef = useRef(null);
const zoomRef = useRef(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Размеры карточки узла
const NODE_WIDTH = 160;
const NODE_HEIGHT = 74;
// Автоматическая раскладка (force-directed)
useEffect(() => {
if (!servers || servers.length === 0) return;
const nodes = servers.map((s) => ({ id: s.ip }));
const links = (connections || []).map((c) => ({ source: c.from, target: c.to, type: c.tunnelType }));
const distanceForType = (t) => ({ GRE: 200, IPSec: 220, WireGuard: 180, OpenVPN: 200 }[t] || 210);
const simulation = d3
.forceSimulation(nodes)
.force('link', d3.forceLink(links).id((d) => d.id).distance((d) => distanceForType(d.type)))
.force('charge', d3.forceManyBody().strength(-500))
.force('collide', d3.forceCollide(70))
.force('center', d3.forceCenter(450, 300))
.stop();
for (let i = 0; i < 300; i += 1) simulation.tick();
const pos = {};
nodes.forEach((n) => {
pos[n.id] = { x: n.x, y: n.y };
});
setNodePositions(pos);
// После раскладки подгоняем граф по области видимости
try {
const svgEl = svgRef.current;
const box = svgEl.getBoundingClientRect();
const padding = 40;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach((n) => {
minX = Math.min(minX, n.x - NODE_WIDTH / 2);
minY = Math.min(minY, n.y - NODE_HEIGHT / 2);
maxX = Math.max(maxX, n.x + NODE_WIDTH / 2);
maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2);
});
const contentW = Math.max(1, maxX - minX);
const contentH = Math.max(1, maxY - minY);
const scale = Math.max(0.5, Math.min(1.4, Math.min((box.width - padding) / contentW, (box.height - padding) / contentH)));
const tx = (box.width - scale * (minX + maxX)) / 2;
const ty = (box.height - scale * (minY + maxY)) / 2;
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
d3.select(svgEl).transition().duration(350).call(zoomRef.current.transform, t);
} catch (e) {
// ignore
}
}, [servers, connections]);
// Зум/панорамирование
useEffect(() => {
const svg = d3.select(svgRef.current);
const g = d3.select(gRef.current);
const width = svgRef.current?.clientWidth || 900;
const height = svgRef.current?.clientHeight || 600;
zoomRef.current = d3
.zoom()
.scaleExtent([0.3, 3])
.extent([[0, 0], [width, height]])
// Разрешаем панораму в широких пределах
.translateExtent([[-10000, -10000], [10000, 10000]])
.on('zoom', (event) => {
g.attr('transform', event.transform);
// dbg('onZoom', { k: event.transform.k, x: event.transform.x, y: event.transform.y });
});
svg.call(zoomRef.current);
dbg('zoom initialized', { width, height });
}, []);
// Контролы масштабирования
const zoomBy = (factor) => {
if (!zoomRef.current || !svgRef.current) return;
const svgEl = svgRef.current;
const current = d3.zoomTransform(svgEl);
const cx = svgEl.clientWidth / 2;
const cy = svgEl.clientHeight / 2;
const minK = 0.3;
const maxK = 3;
let nextK = current.k * factor;
if (nextK < minK) nextK = minK;
if (nextK > maxK) nextK = maxK;
dbg('zoomBy click', { factor, current: { k: current.k, x: current.x, y: current.y }, nextK, center: { cx, cy } });
const svgSel = d3.select(svgEl);
svgSel.interrupt();
svgSel
.transition()
.duration(220)
.call(zoomRef.current.scaleTo, nextK, [cx, cy])
.on('end', () => {
const nt = d3.zoomTransform(svgEl);
dbg('zoomBy end', { next: { k: nt.k, x: nt.x, y: nt.y } });
});
};
const fitToView = () => {
const svgEl = svgRef.current;
if (!svgEl) return;
const box = svgEl.getBoundingClientRect();
const padding = 40;
const positions = nodePositions;
const ids = Object.keys(positions);
if (ids.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
ids.forEach((id) => {
const p = positions[id];
minX = Math.min(minX, p.x - NODE_WIDTH / 2);
minY = Math.min(minY, p.y - NODE_HEIGHT / 2);
maxX = Math.max(maxX, p.x + NODE_WIDTH / 2);
maxY = Math.max(maxY, p.y + NODE_HEIGHT / 2);
});
const contentW = Math.max(1, maxX - minX);
const contentH = Math.max(1, maxY - minY);
const scale = Math.max(0.5, Math.min(1.6, Math.min((box.width - padding) / contentW, (box.height - padding) / contentH)));
const tx = (box.width - scale * (minX + maxX)) / 2;
const ty = (box.height - scale * (minY + maxY)) / 2;
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
d3.select(svgEl).transition().duration(250).call(zoomRef.current.transform, t);
};
// Полноэкранный режим
useEffect(() => {
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const toggleFullscreen = () => {
const el = containerRef.current;
if (!el) return;
dbg('toggleFullscreen', { isFullscreen: Boolean(document.fullscreenElement) });
if (!document.fullscreenElement) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
};
if (servers.length === 0) {
return (
<div className="text-center py-5">
<div className="text-muted">
<h4>Нет серверов для отображения</h4>
<p>Добавьте серверы, чтобы увидеть граф связей</p>
</div>
</div>
);
}
// Инициализация позиций узлов при первом рендере
const getInitialNodePosition = (index, total) => {
if (nodePositions[servers[index]?.ip]) {
return nodePositions[servers[index].ip];
}
const cols = Math.ceil(Math.sqrt(total));
const row = Math.floor(index / cols);
const col = index % cols;
const spacing = 180;
return {
x: 120 + col * spacing,
y: 120 + row * spacing
};
};
// Обработчик начала перетаскивания
const handleMouseDown = (e, serverIp) => {
const target = gRef.current || svgRef.current;
const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(target.getScreenCTM().inverse());
const currentPos = nodePositions[serverIp] || getInitialNodePosition(
servers.findIndex(s => s.ip === serverIp),
servers.length
);
setDragOffset({
x: svgP.x - currentPos.x,
y: svgP.y - currentPos.y
});
setDraggedNode(serverIp);
};
// Обработчик перетаскивания
const handleMouseMove = (e) => {
if (!draggedNode) return;
const target = gRef.current || svgRef.current;
const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(target.getScreenCTM().inverse());
setNodePositions(prev => ({
...prev,
[draggedNode]: {
x: svgP.x - dragOffset.x,
y: svgP.y - dragOffset.y
}
}));
};
// Обработчик окончания перетаскивания
const handleMouseUp = () => {
setDraggedNode(null);
};
// Получение позиции узла
const getNodePosition = (serverIp) => {
return nodePositions[serverIp] || getInitialNodePosition(
servers.findIndex(s => s.ip === serverIp),
servers.length
);
};
// Цвета для разных стран
const getCountryColor = (country) => {
const colors = {
'RU': '#dc2626', // красный
'US': '#2563eb', // синий
'DE': '#059669', // зеленый
'SE': '#7c3aed', // фиолетовый
'NL': '#ea580c', // оранжевый
'SG': '#0891b2' // голубой
};
return colors[country] || '#6b7280';
};
// Цвета/стили для разных типов туннелей
const getTunnelStyle = (tunnelType) => {
const map = {
GRE: { color: '#206bc4', dash: '0', width: 3 },
IPSec: { color: '#f59f00', dash: '6,4', width: 3 },
WireGuard: { color: '#2fb344', dash: '0', width: 4 },
OpenVPN: { color: '#be4bdb', dash: '3,3', width: 3 },
};
return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 };
};
// Флаг страны по ISO-2 с поддержкой нестандартных кодов из списка (SWE->SE)
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()));
};
return (
<div ref={containerRef} style={{ width: '100%', height: '500px', border: '1px solid #e5e7eb', borderRadius: '8px', overflow: 'hidden', background: '#f8fafc', position: 'relative' }}>
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 900 600"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: draggedNode ? 'grabbing' : 'default' }}
>
<defs>
{/* Лёгкая сетка фона */}
<pattern id="grid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#f2f4f6" strokeWidth="1" />
</pattern>
{/* Определение стрелок под цвет канала */}
{Array.from(new Set(connections.map(c => c.tunnelType))).map((t) => {
const style = getTunnelStyle(t);
return (
<marker key={`arrow-${t}`}
id={`arrow-${t}`}
markerWidth="12"
markerHeight="8"
refX="10"
refY="4"
orient="auto"
>
<polygon points="0 0, 12 4, 0 8" fill={style.color} />
</marker>
);
})}
{/* Свечение для выделения */}
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
{/* Тень карточки */}
<filter id="cardShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodOpacity="0.2" />
</filter>
{/* Тень для ярлыков ссылок */}
<filter id="labelShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.25" />
</filter>
</defs>
{/* Подложка сетки */}
<rect width="100%" height="100%" fill="url(#grid)" />
<g ref={gRef}>
{/* Связи */}
{connections.map((connection, index) => {
const fromServer = servers.find(s => s.ip === connection.from);
const toServer = servers.find(s => s.ip === connection.to);
if (!fromServer || !toServer) return null;
const fromPos = getNodePosition(connection.from);
const toPos = getNodePosition(connection.to);
const style = getTunnelStyle(connection.tunnelType);
const id = `${connection.from}-${connection.to}-${index}`;
const isHovered = hoveredLinkId === id;
// Кривая линия с небольшим отступом от прямой для читаемости
const mx = (fromPos.x + toPos.x) / 2;
const my = (fromPos.y + toPos.y) / 2;
const dx = toPos.x - fromPos.x;
const dy = toPos.y - fromPos.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = (-dy / len) * 30; // перпендикуляр, 30px
const ny = (dx / len) * 30;
const cx = mx + nx;
const cy = my + ny;
return (
<g key={`link-${index}`}
onMouseEnter={() => setHoveredLinkId(id)}
onMouseLeave={() => setHoveredLinkId(null)}
>
{/* Линия связи */}
<path
d={`M ${fromPos.x} ${fromPos.y} Q ${cx} ${cy} ${toPos.x} ${toPos.y}`}
fill="none"
stroke={style.color}
strokeWidth={isHovered ? style.width + 1 : style.width}
strokeDasharray={style.dash}
markerEnd={`url(#arrow-${connection.tunnelType})`}
filter={isHovered ? 'url(#glow)' : undefined}
opacity={isHovered ? 1 : 0.9}
style={{ vectorEffect: 'non-scaling-stroke' }}
/>
{/* Подпись связи: ярлык с подложкой и halo */}
{(() => {
const labelW = 140;
const labelH = 38;
const lx = cx - labelW / 2;
const ly = cy - labelH / 2 - 4; // слегка выше вершины
return (
<g style={{ pointerEvents: 'none' }}>
<rect
x={lx}
y={ly}
width={labelW}
height={labelH}
rx="16"
fill="#ffffff"
stroke={style.color}
strokeWidth="1"
filter="url(#labelShadow)"
/>
{/* Halo для заголовка */}
<text
x={cx}
y={ly + 16}
textAnchor="middle"
fontSize="12"
fontWeight="700"
stroke="#ffffff"
strokeWidth="3"
strokeLinejoin="round"
fill="#374151"
style={{ paintOrder: 'stroke fill' }}
>
{connection.tunnelType}
</text>
{/* Линия IP с более мелким шрифтом */}
<text
x={cx}
y={ly + 30}
textAnchor="middle"
fontSize="10"
fill="#6b7280"
stroke="#ffffff"
strokeWidth="2"
strokeLinejoin="round"
style={{ paintOrder: 'stroke fill' }}
>
{connection.ipA} {connection.ipB}
</text>
</g>
);
})()}
</g>
);
})}
{/* Узлы (серверы) */}
{servers.map((server) => {
const pos = getNodePosition(server.ip);
const countryColor = getCountryColor(server.country);
const isDragging = draggedNode === server.ip;
return (
<g key={server.ip}>
{/* Карточка узла */}
<rect
x={pos.x - NODE_WIDTH / 2}
y={pos.y - NODE_HEIGHT / 2}
rx={12}
ry={12}
width={NODE_WIDTH}
height={NODE_HEIGHT}
fill="#ffffff"
stroke={countryColor}
strokeWidth={2}
filter="url(#cardShadow)"
style={{ cursor: 'grab' }}
onMouseDown={(e) => handleMouseDown(e, server.ip)}
/>
{/* Плашка страны */}
{(() => {
const badgeX = pos.x - NODE_WIDTH / 2 + 8;
const badgeY = pos.y - NODE_HEIGHT / 2 - 10;
const flag = getFlagEmoji(server.country);
return (
<g>
<rect
x={badgeX}
y={badgeY}
rx={8}
ry={8}
width={52}
height={20}
fill="#fff"
stroke={countryColor}
/>
{/* Рендер флага через foreignObject как в HTML (надёжнее для Windows/Chrome) */}
<foreignObject x={badgeX + 4} y={badgeY + 3} width={16} height={14} style={{ pointerEvents: 'none' }}>
<div xmlns="http://www.w3.org/1999/xhtml"
style={{fontSize:'14px', lineHeight:'14px', fontFamily:'Segoe UI Emoji, Noto Color Emoji, Apple Color Emoji, "Twemoji Mozilla", "EmojiOne Color", sans-serif'}}>
{flag}
</div>
</foreignObject>
<text
x={badgeX + 30}
y={badgeY + 13}
fontSize="11"
fill="#475569"
dominantBaseline="middle"
>
{String(server.country).toUpperCase().slice(0,3)}
</text>
</g>
);
})()}
{/* Название */}
<text
x={pos.x}
y={pos.y - 4}
textAnchor="middle"
fontSize="13"
fill="#1f2937"
fontWeight="700"
>
{server.dns}
</text>
{/* IP */}
<text
x={pos.x}
y={pos.y + 14}
textAnchor="middle"
fontSize="12"
fill="#334155"
>
{server.ip}
</text>
{/* Провайдер */}
<text
x={pos.x}
y={pos.y + 30}
textAnchor="middle"
fontSize="10"
fill="#6b7280"
>
{server.provider}
</text>
</g>
);
})}
{/* Доп. определения добавлены выше */}
</g>
</svg>
{/* Контролы масштабирования */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto' }}>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
<IconZoomIn size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1/1.2)} title="Уменьшить">
<IconZoomOut size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={toggleFullscreen} title={isFullscreen ? 'Выйти из полноэкранного' : 'Во весь экран'}>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</div>
</div>
</div>
);
}
export default GraphView;