feat(NetworkMapUnifi): implement edge hover and selection features with detailed connection modal for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s

This commit is contained in:
2026-02-17 22:02:42 +07:00
parent 98b39c5703
commit baa2fc8044
+124 -5
View File
@@ -63,7 +63,9 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const [size, setSize] = useState({ w: 800, h: 520 });
const [positions, setPositions] = useState({});
const [drag, setDrag] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
useEffect(() => {
const el = containerRef.current;
@@ -183,12 +185,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
if (typeof rev === 'number') parts.push(`${rev} ms`);
const pingLabel = parts.length ? parts.join(' ') : null;
return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from,
to,
p1,
p2,
pingLabel,
tunnelType: c.tunnelType,
raw: c,
};
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap]);
@@ -281,14 +285,27 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const LABEL_W = 64;
const LABEL_H = 20;
const LABEL_GAP = 4;
return edgesWithPing.map((e, idx) => (
<g key={`${e.from}-${e.to}-${idx}`}>
return edgesWithPing.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5;
return (
<g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
onMouseEnter={() => setHoveredEdgeKey(e.edgeKey)}
onMouseLeave={() =>
setHoveredEdgeKey((prev) => (prev === e.edgeKey ? null : prev))
}
onClick={() => setSelectedEdge(e)}
style={{ cursor: 'pointer' }}
>
<path
d={pathD(e)}
fill="none"
stroke={UNIFI_GREEN}
strokeWidth={2.5}
strokeWidth={strokeWidth}
strokeLinecap="round"
opacity={dimFactor}
/>
{e.pingLabel && (() => {
const base = labelPoint(e);
@@ -332,7 +349,8 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
);
})()}
</g>
));
);
});
})()}
</g>
@@ -389,6 +407,107 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
);
})}
</svg>
{selectedEdge && (() => {
const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from);
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to);
const fwd = pingMap[`${selectedEdge.from}:${selectedEdge.to}`];
const rev = pingMap[`${selectedEdge.to}:${selectedEdge.from}`];
return (
<div
className="network-map-edge-modal-backdrop"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(15,23,42,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
}}
onClick={() => setSelectedEdge(null)}
>
<div
className="card shadow-lg"
style={{
minWidth: 360,
maxWidth: 520,
background: '#0f172a',
color: '#e5e7eb',
borderRadius: 12,
border: `1px solid ${UNIFI_CARD_BORDER}`,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="card-header d-flex align-items-center justify-content-between">
<div className="fw-semibold">Детали соединения</div>
<button
type="button"
className="btn-close btn-close-white"
aria-label="Закрыть"
onClick={() => setSelectedEdge(null)}
style={{ filter: 'invert(1)' }}
/>
</div>
<div className="card-body" style={{ fontSize: 13 }}>
<div className="mb-2">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
От
</div>
<div>
<strong>{fromServer?.dns || fromServer?.ip || selectedEdge.from}</strong>{' '}
<span className="text-muted">({fromServer?.ip || '—'})</span>{' '}
<span className="badge bg-blue-lt text-blue ms-1">
{String(fromServer?.type || '').toUpperCase() || 'SERVER'}
</span>
</div>
</div>
<div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
К
</div>
<div>
<strong>{toServer?.dns || toServer?.ip || selectedEdge.to}</strong>{' '}
<span className="text-muted">({toServer?.ip || '—'})</span>{' '}
<span className="badge bg-blue-lt text-blue ms-1">
{String(toServer?.type || '').toUpperCase() || 'SERVER'}
</span>
</div>
</div>
<div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
Туннель
</div>
<div>
<span className="badge bg-green-lt text-green me-2">
{selectedEdge.tunnelType || 'TUNNEL'}
</span>
{selectedEdge.raw?.internalFromTo && (
<span className="text-muted">
{selectedEdge.raw.internalToFrom} {selectedEdge.raw.internalFromTo}
</span>
)}
</div>
</div>
<div className="mb-1">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
Пинг
</div>
<div>
<div>
<span className="text-muted"> </span>
{typeof fwd === 'number' ? `${fwd} ms` : 'нет данных'}
</div>
<div>
<span className="text-muted"> </span>
{typeof rev === 'number' ? `${rev} ms` : 'нет данных'}
</div>
</div>
</div>
</div>
</div>
</div>
);
})()}
<div
style={{
position: 'absolute',