diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx
index 1194563..2f549fb 100644
--- a/frontend/src/GraphView.jsx
+++ b/frontend/src/GraphView.jsx
@@ -12,12 +12,14 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } 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 getTunnelStyle = useCallback((tunnelType) => {
const map = {
@@ -68,12 +70,25 @@ function GraphView({ servers, connections, onCreateConnection }) {
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}` : '';
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 },
+ label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
+ style: {
+ stroke: style.color,
+ strokeWidth: style.width,
+ strokeDasharray: style.dash,
+ },
+ labelBgPadding: [6, 4],
+ labelBgBorderRadius: 999,
+ labelStyle: {
+ fontSize: 11,
+ fontWeight: 500,
+ fill: '#0f172a',
+ },
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
animated: false,
};
@@ -99,7 +114,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
const nodeTypes = useMemo(
() => ({
- server: ({ data }) => {
+ server: ({ id, data }) => {
const s = data.server || {};
const countryColor = getCountryColor(s.country);
const getFlagEmoji = (code) => {
@@ -111,16 +126,24 @@ function GraphView({ servers, connections, onCreateConnection }) {
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
};
const flag = getFlagEmoji(s.country);
+ const isHighlighted = highlightedNodeId === id;
return (
setHighlightedNodeId(id)}
+ onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))}
>
{/* Точка входа соединений */}
diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx
index 629cc1b..8f1c435 100644
--- a/frontend/src/ServerManager.jsx
+++ b/frontend/src/ServerManager.jsx
@@ -95,6 +95,7 @@ function ServerManager() {
// { from: '192.168.1.1', to: '192.168.1.2', tunnelType: 'GRE', ipA: '10.10.100.1', ipB: '10.10.100.2' }
]);
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
+ const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
// Фильтры графа
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
@@ -475,21 +476,38 @@ function ServerManager() {
);
}
- // Добавление новой связи
- const handleAddConnection = () => {
- if (!newConnection.from || !newConnection.to || !newConnection.tunnelType || !newConnection.ipA || !newConnection.ipB) {
+ // Валидация и добавление связи (общая функция для формы и модального окна графа)
+ const addConnectionInternal = (draft) => {
+ if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
setError('Все поля для связи должны быть заполнены.');
- return;
+ return false;
}
- if (newConnection.from === newConnection.to) {
+ if (draft.from === draft.to) {
setError('Сервер A и сервер B не могут быть одинаковыми.');
- return;
+ return false;
}
- setConnections([...connections, newConnection]);
- setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
- setError('');
+ const next = { ...draft };
+ setConnections((prev) => [...prev, next]);
setSuccess('Связь успешно добавлена!');
setTimeout(() => setSuccess(''), 3000);
+ return true;
+ };
+
+ // Добавление новой связи из нижней формы
+ const handleAddConnection = () => {
+ const ok = addConnectionInternal(newConnection);
+ if (!ok) return;
+ setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
+ setError('');
+ };
+
+ // Подтверждение связи, созданной через граф (модальное окно)
+ const handleConfirmGraphConnection = () => {
+ const ok = addConnectionInternal(newConnection);
+ if (!ok) return;
+ setGraphConnectionModalOpen(false);
+ setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
+ setError('');
};
// Удаление связи
@@ -961,10 +979,19 @@ function ServerManager() {
servers={servers.filter(s => (!graphCountryFilter || s.country === graphCountryFilter) && (!graphProviderFilter || s.provider === graphProviderFilter))}
connections={connections.filter(c => graphTunnelFilters[c.tunnelType])}
onCreateConnection={({ from, to }) => {
- // Открываем модал добавления связи и проставляем выбранные узлы
- setNewConnection(prev => ({ ...prev, from, to }));
- const el = document.querySelector('#add-connection-anchor');
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ // Открываем современный флоу создания связи прямо поверх графа
+ setNewConnection(prev => ({
+ ...prev,
+ from,
+ to,
+ // Если серверы уже имеют тип туннеля, предлагаем его по умолчанию
+ tunnelType:
+ prev.tunnelType ||
+ servers.find(s => s.ip === from)?.tunnel ||
+ servers.find(s => s.ip === to)?.tunnel ||
+ 'GRE',
+ }));
+ setGraphConnectionModalOpen(true);
}}
/>
@@ -977,6 +1004,80 @@ function ServerManager() {
+ {/* Модальное окно для быстрого создания связи после соединения узлов на графе */}
+ {
+ setGraphConnectionModalOpen(false);
+ setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
+ setError('');
+ }}
+ onSubmit={handleConfirmGraphConnection}
+ title="Создать связь между серверами"
+ submitLabel="Создать связь"
+ submitIcon={IconPlus}
+ size="md"
+ >
+ {error && (
+
+ {error}
+
+ )}
+
+
Вы выбрали связь между:
+
+
+ {newConnection.from || 'Сервер A'}
+
+ ⇄
+
+ {newConnection.to || 'Сервер B'}
+
+
+
+
+
+ setNewConnection({ ...newConnection, tunnelType: val })}
+ required
+ options={[
+ { value: 'GRE', label: 'GRE' },
+ { value: 'IPSec', label: 'IPSec' },
+ { value: 'WireGuard', label: 'WireGuard' },
+ { value: 'OpenVPN', label: 'OpenVPN' },
+ ]}
+ />
+
+
+ setNewConnection({ ...newConnection, ipA: val })}
+ placeholder="10.10.100.1"
+ required
+ />
+
+
+ setNewConnection({ ...newConnection, ipB: val })}
+ placeholder="10.10.100.2"
+ required
+ />
+
+
+
+ Протяните линию между серверами в графе — здесь вы только уточняете параметры туннеля. Это делает
+ построение схемы максимально быстрым и наглядным.
+
+
Добавить связь между серверами