feat: Replace ForceGraph with react-vis-network-graph for improved visualization and enhance ServerManager with connection validation and deletion functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 7m50s

This commit is contained in:
2025-07-15 19:37:31 +07:00
parent 2590b08262
commit c5da9e357e
4 changed files with 379 additions and 265 deletions
+133 -63
View File
@@ -1,73 +1,143 @@
import React, { useRef, useEffect } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import React, { useMemo } from 'react';
import { Network } from 'react-vis-network-graph';
function GraphView({ servers, connections }) {
// Преобразуем данные в формат, подходящий для ForceGraph
const nodes = servers.map(s => ({ id: s.ip, label: s.dns || s.ip }));
const links = connections.map(c => ({
source: c.from,
target: c.to,
tunnelType: c.tunnelType,
ipA: c.ipA,
ipB: c.ipB
}));
const graph = useMemo(() => {
const nodes = servers.map(server => ({
id: server.ip,
label: `${server.dns}\n${server.ip}`,
title: `${server.dns} (${server.ip})\n${server.country} - ${server.provider}`,
color: {
background: '#3b82f6',
border: '#1e40af',
highlight: {
background: '#60a5fa',
border: '#3b82f6'
}
},
font: {
color: '#ffffff',
size: 14,
face: 'Arial'
},
shape: 'box',
borderWidth: 2,
shadow: true
}));
// Кастомный рендер связей с подписями
const fgRef = useRef();
const edges = connections.map((connection, index) => ({
id: index,
from: connection.from,
to: connection.to,
label: `${connection.tunnelType}\n${connection.ipA}${connection.ipB}`,
title: `${connection.tunnelType} туннель\n${connection.ipA}${connection.ipB}`,
color: {
color: '#64748b',
highlight: '#3b82f6',
hover: '#3b82f6'
},
font: {
color: '#374151',
size: 12,
face: 'Arial',
align: 'middle'
},
width: 2,
arrows: {
to: {
enabled: true,
scaleFactor: 0.8
}
},
smooth: {
type: 'curvedCW',
roundness: 0.2
}
}));
useEffect(() => {
if (fgRef.current) {
fgRef.current.d3ReheatSimulation();
return { nodes, edges };
}, [servers, connections]);
const options = {
nodes: {
shape: 'box',
margin: 10,
shadow: true,
font: {
size: 14,
face: 'Arial'
}
},
edges: {
font: {
size: 12,
align: 'middle'
},
smooth: {
type: 'curvedCW',
roundness: 0.2
}
},
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -50,
centralGravity: 0.01,
springLength: 200,
springConstant: 0.08,
damping: 0.4
},
stabilization: {
enabled: true,
iterations: 1000,
updateInterval: 100
}
},
interaction: {
hover: true,
tooltipDelay: 200,
zoomView: true,
dragView: true
},
layout: {
improvedLayout: true,
hierarchical: {
enabled: false
}
}
}, [nodes.length, links.length]);
};
const events = {
select: function(event) {
const { nodes, edges } = event;
console.log('Selected nodes:', nodes);
console.log('Selected edges:', edges);
},
hoverNode: function(event) {
const { node } = event;
console.log('Hovered node:', node);
}
};
if (servers.length === 0) {
return (
<div className="text-center py-5">
<div className="text-muted">
<h4>Нет серверов для отображения</h4>
<p>Добавьте серверы, чтобы увидеть граф связей</p>
</div>
</div>
);
}
return (
<div style={{ width: '100%', height: 400 }}>
<ForceGraph2D
ref={fgRef}
graphData={{ nodes, links }}
nodeLabel={node => node.label}
nodeAutoColorBy="id"
linkDirectionalArrowLength={6}
linkDirectionalArrowRelPos={1}
linkLabel={l => `${l.tunnelType}: ${l.ipA}${l.ipB}`}
linkWidth={2}
linkColor={() => '#888'}
nodeCanvasObject={(node, ctx, globalScale) => {
const label = node.label;
const fontSize = 14/globalScale;
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = '#1e293b';
ctx.beginPath();
ctx.arc(node.x, node.y, 10, 0, 2 * Math.PI, false);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, node.x, node.y - 18);
}}
linkCanvasObjectMode={() => 'after'}
linkCanvasObject={(link, ctx, globalScale) => {
const label = `${link.tunnelType}: ${link.ipA}${link.ipB}`;
if (!label) return;
const start = link.source;
const end = link.target;
if (typeof start !== 'object' || typeof end !== 'object') return;
const textPos = {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2
};
ctx.save();
ctx.font = `${12/globalScale}px sans-serif`;
ctx.fillStyle = '#2563eb';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, textPos.x, textPos.y);
ctx.restore();
}}
<div style={{ width: '100%', height: '500px', border: '1px solid #e5e7eb', borderRadius: '8px' }}>
<Network
graph={graph}
options={options}
events={events}
style={{ width: '100%', height: '100%' }}
/>
</div>
);