Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m54s
538 lines
20 KiB
React
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; |