feat: Enhance GraphView and ServerManager components by adding node highlighting and a modal for quick connection creation, improving user interaction and visual feedback in the graph interface.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
This commit is contained in:
@@ -12,12 +12,14 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
|
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
|
||||||
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
|
||||||
function GraphView({ servers, connections, onCreateConnection }) {
|
function GraphView({ servers, connections, onCreateConnection }) {
|
||||||
const flowRef = useRef(null);
|
const flowRef = useRef(null);
|
||||||
const instanceRef = useRef(null);
|
const instanceRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [highlightedNodeId, setHighlightedNodeId] = useState(null);
|
||||||
|
|
||||||
const getTunnelStyle = useCallback((tunnelType) => {
|
const getTunnelStyle = useCallback((tunnelType) => {
|
||||||
const map = {
|
const map = {
|
||||||
@@ -68,12 +70,25 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
const initialEdgesData = useMemo(() => {
|
const initialEdgesData = useMemo(() => {
|
||||||
return (connections || []).map((c, idx) => {
|
return (connections || []).map((c, idx) => {
|
||||||
const style = getTunnelStyle(c.tunnelType);
|
const style = getTunnelStyle(c.tunnelType);
|
||||||
|
const baseLabel = c.tunnelType || 'TUNNEL';
|
||||||
|
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
|
||||||
return {
|
return {
|
||||||
id: `${c.from}-${c.to}-${idx}`,
|
id: `${c.from}-${c.to}-${idx}`,
|
||||||
source: String(c.from),
|
source: String(c.from),
|
||||||
target: String(c.to),
|
target: String(c.to),
|
||||||
label: c.ipA && c.ipB ? `${c.tunnelType} ${c.ipA} ⇄ ${c.ipB}` : c.tunnelType,
|
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
|
||||||
style: { stroke: style.color, strokeWidth: style.width, strokeDasharray: style.dash },
|
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 },
|
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
|
||||||
animated: false,
|
animated: false,
|
||||||
};
|
};
|
||||||
@@ -99,7 +114,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
|
|
||||||
const nodeTypes = useMemo(
|
const nodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
server: ({ data }) => {
|
server: ({ id, data }) => {
|
||||||
const s = data.server || {};
|
const s = data.server || {};
|
||||||
const countryColor = getCountryColor(s.country);
|
const countryColor = getCountryColor(s.country);
|
||||||
const getFlagEmoji = (code) => {
|
const getFlagEmoji = (code) => {
|
||||||
@@ -111,16 +126,24 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
|
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
|
||||||
};
|
};
|
||||||
const flag = getFlagEmoji(s.country);
|
const flag = getFlagEmoji(s.country);
|
||||||
|
const isHighlighted = highlightedNodeId === id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="card shadow-sm"
|
className={`card shadow-sm${isHighlighted ? ' border-primary' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: 200,
|
width: 200,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: `2px solid ${countryColor}`,
|
border: `2px solid ${isHighlighted ? '#206bc4' : countryColor}`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
boxShadow: 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: isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
|
||||||
|
transition: 'box-shadow 120ms ease-out, transform 120ms ease-out, border-color 120ms ease-out',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedNodeId(id)}
|
||||||
|
onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))}
|
||||||
>
|
>
|
||||||
{/* Точка входа соединений */}
|
{/* Точка входа соединений */}
|
||||||
<Handle type="target" position={Position.Left} style={{ background: countryColor }} />
|
<Handle type="target" position={Position.Left} style={{ background: countryColor }} />
|
||||||
|
|||||||
+114
-13
@@ -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' }
|
// { 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 [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 });
|
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
|
||||||
@@ -475,21 +476,38 @@ function ServerManager() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавление новой связи
|
// Валидация и добавление связи (общая функция для формы и модального окна графа)
|
||||||
const handleAddConnection = () => {
|
const addConnectionInternal = (draft) => {
|
||||||
if (!newConnection.from || !newConnection.to || !newConnection.tunnelType || !newConnection.ipA || !newConnection.ipB) {
|
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
|
||||||
setError('Все поля для связи должны быть заполнены.');
|
setError('Все поля для связи должны быть заполнены.');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (newConnection.from === newConnection.to) {
|
if (draft.from === draft.to) {
|
||||||
setError('Сервер A и сервер B не могут быть одинаковыми.');
|
setError('Сервер A и сервер B не могут быть одинаковыми.');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setConnections([...connections, newConnection]);
|
const next = { ...draft };
|
||||||
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
setConnections((prev) => [...prev, next]);
|
||||||
setError('');
|
|
||||||
setSuccess('Связь успешно добавлена!');
|
setSuccess('Связь успешно добавлена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
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))}
|
servers={servers.filter(s => (!graphCountryFilter || s.country === graphCountryFilter) && (!graphProviderFilter || s.provider === graphProviderFilter))}
|
||||||
connections={connections.filter(c => graphTunnelFilters[c.tunnelType])}
|
connections={connections.filter(c => graphTunnelFilters[c.tunnelType])}
|
||||||
onCreateConnection={({ from, to }) => {
|
onCreateConnection={({ from, to }) => {
|
||||||
// Открываем модал добавления связи и проставляем выбранные узлы
|
// Открываем современный флоу создания связи прямо поверх графа
|
||||||
setNewConnection(prev => ({ ...prev, from, to }));
|
setNewConnection(prev => ({
|
||||||
const el = document.querySelector('#add-connection-anchor');
|
...prev,
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
from,
|
||||||
|
to,
|
||||||
|
// Если серверы уже имеют тип туннеля, предлагаем его по умолчанию
|
||||||
|
tunnelType:
|
||||||
|
prev.tunnelType ||
|
||||||
|
servers.find(s => s.ip === from)?.tunnel ||
|
||||||
|
servers.find(s => s.ip === to)?.tunnel ||
|
||||||
|
'GRE',
|
||||||
|
}));
|
||||||
|
setGraphConnectionModalOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -977,6 +1004,80 @@ function ServerManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Модальное окно для быстрого создания связи после соединения узлов на графе */}
|
||||||
|
<FormModal
|
||||||
|
show={graphConnectionModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setGraphConnectionModalOpen(false);
|
||||||
|
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
onSubmit={handleConfirmGraphConnection}
|
||||||
|
title="Создать связь между серверами"
|
||||||
|
submitLabel="Создать связь"
|
||||||
|
submitIcon={IconPlus}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger mb-3" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-muted small mb-1">Вы выбрали связь между:</div>
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<span className="badge bg-blue-lt text-blue">
|
||||||
|
{newConnection.from || 'Сервер A'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">⇄</span>
|
||||||
|
<span className="badge bg-blue-lt text-blue">
|
||||||
|
{newConnection.to || 'Сервер B'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<FormField
|
||||||
|
label="Тип туннеля"
|
||||||
|
name="tunnelType"
|
||||||
|
type="select"
|
||||||
|
value={newConnection.tunnelType}
|
||||||
|
onChange={(val) => setNewConnection({ ...newConnection, tunnelType: val })}
|
||||||
|
required
|
||||||
|
options={[
|
||||||
|
{ value: 'GRE', label: 'GRE' },
|
||||||
|
{ value: 'IPSec', label: 'IPSec' },
|
||||||
|
{ value: 'WireGuard', label: 'WireGuard' },
|
||||||
|
{ value: 'OpenVPN', label: 'OpenVPN' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<FormField
|
||||||
|
label="IP адрес сервера A"
|
||||||
|
name="ipA"
|
||||||
|
value={newConnection.ipA}
|
||||||
|
onChange={(val) => setNewConnection({ ...newConnection, ipA: val })}
|
||||||
|
placeholder="10.10.100.1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<FormField
|
||||||
|
label="IP адрес сервера B"
|
||||||
|
name="ipB"
|
||||||
|
value={newConnection.ipB}
|
||||||
|
onChange={(val) => setNewConnection({ ...newConnection, ipB: val })}
|
||||||
|
placeholder="10.10.100.2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-muted small">
|
||||||
|
Протяните линию между серверами в графе — здесь вы только уточняете параметры туннеля. Это делает
|
||||||
|
построение схемы максимально быстрым и наглядным.
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
<div id="add-connection-anchor" className="card w-100 mb-4">
|
<div id="add-connection-anchor" className="card w-100 mb-4">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
|
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user