Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
577 lines
22 KiB
React
577 lines
22 KiB
React
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 = (
|
|
<div style={{ fontSize: '12px', lineHeight: '1.5' }}>
|
|
<div><strong>IP:</strong> {s.ip}</div>
|
|
<div><strong>DNS:</strong> {s.dns}</div>
|
|
<div><strong>Провайдер:</strong> {s.provider}</div>
|
|
<div><strong>Страна:</strong> {s.country}</div>
|
|
<div><strong>Туннель:</strong> {s.tunnel}</div>
|
|
{s.gateway && <div><strong>Шлюз:</strong> {s.gateway}</div>}
|
|
<div style={{ marginTop: '4px', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.2)' }}>
|
|
<strong>Связей:</strong> {connCount}
|
|
</div>
|
|
</div>
|
|
);
|
|
return (
|
|
<Tooltip content={tooltipContent} position="top" delay={300}>
|
|
<div
|
|
className={`card shadow-sm${isHighlighted || isSelected ? ' border-primary' : ''}`}
|
|
style={{
|
|
width: 200,
|
|
borderRadius: 12,
|
|
border: `2px solid ${isSelected ? '#206bc4' : isHighlighted ? '#206bc4' : countryColor}`,
|
|
overflow: 'hidden',
|
|
background: '#fff',
|
|
boxShadow: isSelected
|
|
? '0 0 0 2px rgba(32,107,196,0.2), 0 12px 28px rgba(15,23,42,0.3)'
|
|
: isHighlighted
|
|
? '0 0 0 1px rgba(32,107,196,0.15), 0 10px 24px rgba(15,23,42,0.25)'
|
|
: '0 4px 12px rgba(15,23,42,0.12)',
|
|
transform: isSelected ? 'translateY(-3px) scale(1.02)' : isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
|
|
transition: 'box-shadow 120ms ease-out, transform 120ms ease-out, border-color 120ms ease-out',
|
|
cursor: 'pointer',
|
|
}}
|
|
onMouseEnter={() => setHighlightedNodeId(id)}
|
|
onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))}
|
|
onClick={() => {
|
|
if (selectedNodeId === id) {
|
|
setSelectedNodeId(null);
|
|
} else {
|
|
setSelectedNodeId(id);
|
|
}
|
|
}}
|
|
>
|
|
{/* Точка входа соединений */}
|
|
<Handle type="target" position={Position.Left} style={{ background: countryColor }} />
|
|
<div className="px-2 py-1 d-flex align-items-center" style={{ background: '#f8fafc', borderBottom: '1px solid #eef2f7' }}>
|
|
<div className="d-flex align-items-center" style={{ gap: 6 }}>
|
|
<span style={{ fontSize: 16, lineHeight: '1' }}>{flag}</span>
|
|
<span
|
|
className="badge"
|
|
style={{
|
|
background: '#fff',
|
|
color: '#475569',
|
|
border: `1px solid ${countryColor}`,
|
|
borderRadius: 8,
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
{String(s.country || '').toUpperCase().slice(0, 3)}
|
|
</span>
|
|
</div>
|
|
<div className="ms-2 text-truncate" style={{ fontWeight: 700, color: '#1f2937', fontSize: 13 }}>{s.dns}</div>
|
|
{connCount > 0 && (
|
|
<span
|
|
className="badge ms-auto"
|
|
style={{
|
|
background: isSelected ? '#206bc4' : '#e0e7ff',
|
|
color: isSelected ? '#fff' : '#206bc4',
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
minWidth: '20px',
|
|
height: '18px',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
title={`${connCount} ${connCount === 1 ? 'связь' : connCount < 5 ? 'связи' : 'связей'}`}
|
|
>
|
|
{connCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="px-2 py-2" style={{ lineHeight: 1.2 }}>
|
|
<div style={{ fontSize: 12, color: '#334155' }}>{s.ip}</div>
|
|
<div style={{ fontSize: 10, color: '#6b7280' }}>{s.provider}</div>
|
|
</div>
|
|
{/* Точка исхода соединений */}
|
|
<Handle type="source" position={Position.Right} style={{ background: countryColor }} />
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
}),
|
|
[getCountryColor, highlightedNodeId, selectedNodeId, connectionCounts]
|
|
);
|
|
|
|
const onInit = useCallback((inst) => {
|
|
instanceRef.current = inst;
|
|
// Автоподгон при инициализации
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
|
} catch {}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!instanceRef.current) return;
|
|
// Подгон при изменении данных
|
|
const i = instanceRef.current;
|
|
const t = setTimeout(() => {
|
|
try {
|
|
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
|
} catch {}
|
|
}, 50);
|
|
return () => clearTimeout(t);
|
|
}, [servers, connections]);
|
|
|
|
// Трек полноэкранного режима и авто-fit после входа/выхода
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
const fs = Boolean(document.fullscreenElement);
|
|
setIsFullscreen(fs);
|
|
if (instanceRef.current) {
|
|
setTimeout(() => {
|
|
try {
|
|
instanceRef.current.fitView({ padding: 0.2 });
|
|
} catch {}
|
|
}, 60);
|
|
}
|
|
};
|
|
document.addEventListener('fullscreenchange', handler);
|
|
return () => document.removeEventListener('fullscreenchange', handler);
|
|
}, []);
|
|
|
|
if (!servers || servers.length === 0) {
|
|
return (
|
|
<div className="text-center py-5">
|
|
<div className="text-muted">
|
|
<h4>Нет серверов для отображения</h4>
|
|
<p>Добавьте серверы, чтобы увидеть граф связей</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
width: '100%',
|
|
height: isFullscreen ? '100vh' : 500,
|
|
border: '1px solid #e5e7eb',
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
background: '#f8fafc',
|
|
}}
|
|
>
|
|
<ReactFlow
|
|
ref={flowRef}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
defaultEdgeOptions={{
|
|
type: 'smoothstep',
|
|
}}
|
|
onInit={onInit}
|
|
style={{ width: '100%', height: '100%', background: '#f8fafc' }}
|
|
>
|
|
<Background variant="dots" gap={20} size={1} color="#e5e7eb" />
|
|
<MiniMap
|
|
nodeColor={(n) => getCountryColor(n.data?.server?.country)}
|
|
nodeStrokeWidth={2}
|
|
maskColor="rgba(0,0,0,0.05)"
|
|
/>
|
|
</ReactFlow>
|
|
{/* Поиск сервера */}
|
|
{showSearch && (
|
|
<div className="position-absolute" style={{ left: 10, top: 10, zIndex: 10, pointerEvents: 'auto', minWidth: 280 }}>
|
|
<div className="card shadow-lg" style={{ borderRadius: 8 }}>
|
|
<div className="card-body p-2">
|
|
<div className="input-icon">
|
|
<span className="input-icon-addon">
|
|
<IconSearch size={16} />
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
placeholder="Поиск по IP, DNS, провайдеру..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
|
style={{ position: 'absolute', right: '4px', top: '50%', transform: 'translateY(-50%)' }}
|
|
onClick={() => {
|
|
setShowSearch(false);
|
|
setSearchTerm('');
|
|
}}
|
|
aria-label="Закрыть поиск"
|
|
>
|
|
<IconX size={14} />
|
|
</button>
|
|
</div>
|
|
{searchTerm.trim() && searchResults.length > 0 && (
|
|
<div className="mt-2" style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
{searchResults.map((s) => (
|
|
<button
|
|
key={s.ip}
|
|
type="button"
|
|
className="btn btn-ghost-secondary btn-sm w-100 text-start mb-1"
|
|
onClick={() => {
|
|
focusNode(String(s.ip));
|
|
setShowSearch(false);
|
|
setSearchTerm('');
|
|
}}
|
|
style={{ fontSize: '12px' }}
|
|
>
|
|
<div className="fw-bold">{s.dns}</div>
|
|
<div className="text-muted small">{s.ip} · {s.provider}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{searchTerm.trim() && searchResults.length === 0 && (
|
|
<div className="mt-2 text-muted small text-center">Ничего не найдено</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Своя панель управления: Поиск, -, +, Fit, Reset, Fullscreen */}
|
|
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
|
|
<div className="btn-group btn-group-sm">
|
|
<Tooltip content="Поиск сервера" position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-icon"
|
|
onClick={() => setShowSearch(!showSearch)}
|
|
aria-label="Поиск сервера"
|
|
>
|
|
<IconSearch size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Сбросить раскладку" position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-icon"
|
|
onClick={resetLayout}
|
|
aria-label="Сбросить раскладку"
|
|
>
|
|
<IconRefresh size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Уменьшить масштаб" position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-icon"
|
|
onClick={() => instanceRef.current?.zoomOut?.()}
|
|
aria-label="Уменьшить масштаб"
|
|
>
|
|
<IconZoomOut size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Увеличить масштаб" position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-icon"
|
|
onClick={() => instanceRef.current?.zoomIn?.()}
|
|
aria-label="Увеличить масштаб"
|
|
>
|
|
<IconZoomIn size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Подогнать граф к окну" position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
|
|
aria-label="Подогнать граф к окну"
|
|
>
|
|
Fit
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'} position="left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-icon"
|
|
onClick={() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
if (!document.fullscreenElement) {
|
|
el.requestFullscreen?.();
|
|
} else {
|
|
document.exitFullscreen?.();
|
|
}
|
|
}}
|
|
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'}
|
|
>
|
|
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default GraphView; |