feat: Add search functionality and node selection in GraphView, enhancing user interaction with server filtering and improved edge highlighting for better visualization.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s

This commit is contained in:
2025-12-01 23:17:33 +07:00
parent edbf2f4299
commit e922f3ec78
+224 -23
View File
@@ -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 = (
<div style={{ fontSize: '12px', lineHeight: '1.5' }}>
<div><strong>IP:</strong> {s.ip}</div>
<div><strong>DNS:</strong> {s.dns}</div>
<div><strong>Провайдер:</strong> {s.provider}</div>
<div><strong>Страна:</strong> {s.country}</div>
<div><strong>Туннель:</strong> {s.tunnel}</div>
{s.gateway && <div><strong>Шлюз:</strong> {s.gateway}</div>}
<div style={{ marginTop: '4px', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.2)' }}>
<strong>Связей:</strong> {connCount}
</div>
</div>
);
return (
<div
className={`card shadow-sm${isHighlighted ? ' border-primary' : ''}`}
style={{
width: 200,
borderRadius: 12,
border: `2px solid ${isHighlighted ? '#206bc4' : countryColor}`,
overflow: 'hidden',
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))}
>
<Tooltip content={tooltipContent} position="top" delay={300}>
<div
className={`card shadow-sm${isHighlighted || isSelected ? ' border-primary' : ''}`}
style={{
width: 200,
borderRadius: 12,
border: `2px solid ${isSelected ? '#206bc4' : isHighlighted ? '#206bc4' : countryColor}`,
overflow: 'hidden',
background: '#fff',
boxShadow: isSelected
? '0 0 0 2px rgba(32,107,196,0.2), 0 12px 28px rgba(15,23,42,0.3)'
: 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: isSelected ? 'translateY(-3px) scale(1.02)' : isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
transition: 'box-shadow 120ms ease-out, transform 120ms ease-out, border-color 120ms ease-out',
cursor: 'pointer',
}}
onMouseEnter={() => setHighlightedNodeId(id)}
onMouseLeave={() => setHighlightedNodeId((prev) => (prev === id ? null : prev))}
onClick={() => {
if (selectedNodeId === id) {
setSelectedNodeId(null);
} else {
setSelectedNodeId(id);
}
}}
>
{/* Точка входа соединений */}
<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' }}>
@@ -215,6 +318,25 @@ function GraphView({ servers, connections, onCreateConnection }) {
</span>
</div>
<div className="ms-2 text-truncate" style={{ fontWeight: 700, color: '#1f2937', fontSize: 13 }}>{s.dns}</div>
{connCount > 0 && (
<span
className="badge ms-auto"
style={{
background: isSelected ? '#206bc4' : '#e0e7ff',
color: isSelected ? '#fff' : '#206bc4',
fontSize: 10,
fontWeight: 600,
minWidth: '20px',
height: '18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
title={`${connCount} ${connCount === 1 ? 'связь' : connCount < 5 ? 'связи' : 'связей'}`}
>
{connCount}
</span>
)}
</div>
<div className="px-2 py-2" style={{ lineHeight: 1.2 }}>
<div style={{ fontSize: 12, color: '#334155' }}>{s.ip}</div>
@@ -223,10 +345,11 @@ function GraphView({ servers, connections, onCreateConnection }) {
{/* Точка исхода соединений */}
<Handle type="source" position={Position.Right} style={{ background: countryColor }} />
</div>
</Tooltip>
);
},
}),
[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)"
/>
</ReactFlow>
{/* Своя панель управления: -, +, Fit, Fullscreen */}
{/* Поиск сервера */}
{showSearch && (
<div className="position-absolute" style={{ left: 10, top: 10, zIndex: 10, pointerEvents: 'auto', minWidth: 280 }}>
<div className="card shadow-lg" style={{ borderRadius: 8 }}>
<div className="card-body p-2">
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск по IP, DNS, провайдеру..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '4px', top: '50%', transform: 'translateY(-50%)' }}
onClick={() => {
setShowSearch(false);
setSearchTerm('');
}}
aria-label="Закрыть поиск"
>
<IconX size={14} />
</button>
</div>
{searchTerm.trim() && searchResults.length > 0 && (
<div className="mt-2" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{searchResults.map((s) => (
<button
key={s.ip}
type="button"
className="btn btn-ghost-secondary btn-sm w-100 text-start mb-1"
onClick={() => {
focusNode(String(s.ip));
setShowSearch(false);
setSearchTerm('');
}}
style={{ fontSize: '12px' }}
>
<div className="fw-bold">{s.dns}</div>
<div className="text-muted small">{s.ip} · {s.provider}</div>
</button>
))}
</div>
)}
{searchTerm.trim() && searchResults.length === 0 && (
<div className="mt-2 text-muted small text-center">Ничего не найдено</div>
)}
</div>
</div>
</div>
)}
{/* Своя панель управления: Поиск, -, +, Fit, Reset, Fullscreen */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
<div className="btn-group btn-group-sm">
<Tooltip content="Поиск сервера" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={() => setShowSearch(!showSearch)}
aria-label="Поиск сервера"
>
<IconSearch size={16} />
</button>
</Tooltip>
<Tooltip content="Сбросить раскладку" position="left">
<button
type="button"
className="btn btn-outline-secondary btn-icon"
onClick={resetLayout}
aria-label="Сбросить раскладку"
>
<IconRefresh size={16} />
</button>
</Tooltip>
<Tooltip content="Уменьшить масштаб" position="left">
<button
type="button"