From e922f3ec786b081b5a6c92badda52cda3f6cceba Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 1 Dec 2025 23:17:33 +0700 Subject: [PATCH] feat: Add search functionality and node selection in GraphView, enhancing user interaction with server filtering and improved edge highlighting for better visualization. --- frontend/src/GraphView.jsx | 247 +++++++++++++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 23 deletions(-) diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx index c3f7f41..9f0efa9 100644 --- a/frontend/src/GraphView.jsx +++ b/frontend/src/GraphView.jsx @@ -11,7 +11,7 @@ import { Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react'; +import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react'; import Tooltip from './components/Tooltip.jsx'; function GraphView({ servers, connections, onCreateConnection }) { @@ -20,6 +20,9 @@ function GraphView({ servers, connections, onCreateConnection }) { 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) => { @@ -120,16 +123,20 @@ function GraphView({ servers, connections, onCreateConnection }) { 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: `${c.from}-${c.to}-${idx}`, + id: edgeId, source: String(c.from), target: String(c.to), label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel, type: 'smoothstep', style: { stroke: style.color, - strokeWidth: style.width, + 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, @@ -144,7 +151,7 @@ function GraphView({ servers, connections, onCreateConnection }) { animated: false, }; }); - }, [connections, getTunnelStyle]); + }, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData); @@ -152,6 +159,76 @@ function GraphView({ servers, connections, onCreateConnection }) { setEdges(initialEdgesData); }, [initialEdgesData, setEdges]); + // Подсветка связей: вычисляем какие связи связаны с выделенным/выбранным узлом + 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]); + + // Счётчик связей для каждого сервера + 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]); + + // Поиск сервера: фильтруем по 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) => { // Делегируем создание связи в родителя, чтобы сохранить единую логику хранения @@ -178,24 +255,50 @@ function GraphView({ servers, connections, onCreateConnection }) { }; const flag = getFlagEmoji(s.country); const isHighlighted = highlightedNodeId === id; + const isSelected = selectedNodeId === id; + const connCount = connectionCounts[id] || 0; + const tooltipContent = ( +
+
IP: {s.ip}
+
DNS: {s.dns}
+
Провайдер: {s.provider}
+
Страна: {s.country}
+
Туннель: {s.tunnel}
+ {s.gateway &&
Шлюз: {s.gateway}
} +
+ Связей: {connCount} +
+
+ ); return ( -
setHighlightedNodeId(id)} - onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))} - > + +
setHighlightedNodeId(id)} + onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))} + onClick={() => { + if (selectedNodeId === id) { + setSelectedNodeId(null); + } else { + setSelectedNodeId(id); + } + }} + > {/* Точка входа соединений */}
@@ -215,6 +318,25 @@ function GraphView({ servers, connections, onCreateConnection }) {
{s.dns}
+ {connCount > 0 && ( + + {connCount} + + )}
{s.ip}
@@ -223,10 +345,11 @@ function GraphView({ servers, connections, onCreateConnection }) { {/* Точка исхода соединений */}
+
); }, }), - [getCountryColor] + [getCountryColor, highlightedNodeId, selectedNodeId, connectionCounts] ); const onInit = useCallback((inst) => { @@ -314,9 +437,87 @@ function GraphView({ servers, connections, onCreateConnection }) { maskColor="rgba(0,0,0,0.05)" /> - {/* Своя панель управления: -, +, Fit, Fullscreen */} + {/* Поиск сервера */} + {showSearch && ( +
+
+
+
+ + + + setSearchTerm(e.target.value)} + autoFocus + /> + +
+ {searchTerm.trim() && searchResults.length > 0 && ( +
+ {searchResults.map((s) => ( + + ))} +
+ )} + {searchTerm.trim() && searchResults.length === 0 && ( +
Ничего не найдено
+ )} +
+
+
+ )} + + {/* Своя панель управления: Поиск, -, +, Fit, Reset, Fullscreen */}
+ + + + + +