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

293 lines
9.7 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 } 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 (
<div
className="card shadow-sm"
style={{
width: 200,
borderRadius: 12,
border: `2px solid ${countryColor}`,
overflow: 'hidden',
background: '#fff',
}}
>
{/* Точка входа соединений */}
<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>
</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>
);
},
}),
[getCountryColor]
);
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: 'default',
markerEnd: { type: MarkerType.ArrowClosed },
}}
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>
{/* Своя панель управления: -, +, Fit, Fullscreen */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.zoomOut?.()}
title="Уменьшить"
>
<IconZoomOut size={16} />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.zoomIn?.()}
title="Увеличить"
>
<IconZoomIn size={16} />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
title="Подогнать к окну"
>
Fit
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}}
title="Во весь экран"
>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</div>
</div>
</div>
);
}
export default GraphView;