From 034e0d1eeebe31113f57711e3b83042daa80cac8 Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 18 Feb 2026 11:43:33 +0700 Subject: [PATCH] feat(NetworkMap): add speed test functionality to Network Map Dashboard and Unifi components, including UI updates for speed display and handling --- frontend/src/NetworkMapDashboard.jsx | 101 +++++++++++++++++++++++---- frontend/src/NetworkMapUnifi.jsx | 96 +++++++++++++++++++------ 2 files changed, 162 insertions(+), 35 deletions(-) diff --git a/frontend/src/NetworkMapDashboard.jsx b/frontend/src/NetworkMapDashboard.jsx index 6d4974a..b5d9f53 100644 --- a/frontend/src/NetworkMapDashboard.jsx +++ b/frontend/src/NetworkMapDashboard.jsx @@ -29,12 +29,15 @@ export default function NetworkMapDashboard() { const [servers, setServers] = useState([]); const [connections, setConnections] = useState([]); const [pingMap, setPingMap] = useState({}); + const [speedMap, setSpeedMap] = useState({}); const pingTimestampsRef = useRef({}); const [pingCacheSeconds, setPingCacheSeconds] = useState(0); const [loading, setLoading] = useState(true); const [pingLoading, setPingLoading] = useState(false); + const [speedLoading, setSpeedLoading] = useState(false); const [error, setError] = useState(null); const pingAbortRef = useRef(false); + const speedAbortRef = useRef(false); /** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */ const fetchData = useCallback(async () => { @@ -68,20 +71,24 @@ export default function NetworkMapDashboard() { const s1Key = s1.id || s1.dns || s1.ip; const s2Key = s2.id || s2.dns || s2.ip; if (!s1Key || !s2Key) return; + const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase()); + const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase()); connList.push({ from: s1.ip, to: s2.ip, tunnelType: iface.type || 'GRE', - // Внутренние IP туннеля: с сервера 1 до сервера 2 и обратно + interfaceName: iface.name || null, fromKey: s1Key, toKey: s2Key, - internalFromTo: iface.remoteIp || null, // ping: server1 -> remoteIp - internalToFrom: iface.localIp || null, // ping: server2 -> localIp + internalFromTo: iface.remoteIp || null, + internalToFrom: iface.localIp || null, + speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null, }); }); setServers(serversList); setConnections(connList); + setSpeedMap({}); } catch (e) { console.error('NetworkMap fetch:', e); setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные'); @@ -177,6 +184,49 @@ export default function NetworkMapDashboard() { setPingLoading(false); }, [connections, servers, pingCacheSeconds]); + /** Ключ скорости по паре серверов (без учёта порядка) */ + const speedKey = (key1, key2) => + [String(key1), String(key2)].sort().join(':'); + + const requestSpeeds = useCallback(async () => { + const withSpeed = connections.filter( + (c) => c.speedTestServerId && c.interfaceName + ); + if (withSpeed.length === 0) return; + speedAbortRef.current = false; + setSpeedLoading(true); + const tasks = withSpeed.map((c) => async () => { + if (speedAbortRef.current) return; + const key = speedKey(c.fromKey, c.toKey); + try { + const { data } = await api.post( + '/mikrotik/speed-test', + { + serverId: c.speedTestServerId, + interfaceName: c.interfaceName, + }, + { timeout: 120000 } + ); + if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) { + setSpeedMap((prev) => ({ + ...prev, + [key]: { + tcpDownloadBps: data.tcpDownloadBps, + tcpUploadBps: data.tcpUploadBps, + cached: data.cached === true, + durationSeconds: data.durationSeconds, + }, + })); + } + } catch { + setSpeedMap((prev) => ({ ...prev, [key]: null })); + } + }); + await runWithLimit(tasks, 2); + if (speedAbortRef.current) return; + setSpeedLoading(false); + }, [connections]); + useEffect(() => { fetchData(); }, [fetchData]); @@ -190,10 +240,23 @@ export default function NetworkMapDashboard() { }; }, [loading, connections.length]); + useEffect(() => { + if (!loading && connections.length > 0) { + requestSpeeds(); + } + return () => { + speedAbortRef.current = true; + }; + }, [loading, connections.length, requestSpeeds]); + const handleRefreshPings = () => { requestPings(); }; + const handleRefreshSpeeds = () => { + requestSpeeds(); + }; + if (loading) { return (
@@ -225,17 +288,28 @@ export default function NetworkMapDashboard() { } - meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг (мс)" + meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость" actions={ - +
+ + +
} /> {servers.length === 0 ? ( @@ -247,6 +321,7 @@ export default function NetworkMapDashboard() { servers={servers} connections={connections} pingMap={pingMap} + speedMap={speedMap} /> )}
diff --git a/frontend/src/NetworkMapUnifi.jsx b/frontend/src/NetworkMapUnifi.jsx index 01f7c63..b067a4a 100644 --- a/frontend/src/NetworkMapUnifi.jsx +++ b/frontend/src/NetworkMapUnifi.jsx @@ -57,7 +57,18 @@ function computeInitialLayout(servers, size) { * Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах. * Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка. */ -export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {} }) { +function formatMbps(bps) { + if (bps == null || Number.isNaN(bps)) return '—'; + const mbps = bps / 1_000_000; + if (!Number.isFinite(mbps)) return '—'; + return `${mbps.toFixed(1)} Мбит/с`; +} + +function speedKey(key1, key2) { + return [String(key1), String(key2)].sort().join(':'); +} + +export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {} }) { const containerRef = useRef(null); const svgRef = useRef(null); const [size, setSize] = useState({ w: 800, h: 520 }); @@ -184,6 +195,12 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`); if (typeof rev === 'number') parts.push(`← ${rev} ms`); const pingLabel = parts.length ? parts.join(' ') : null; + const sk = speedKey(c.fromKey, c.toKey); + const speed = speedMap[sk]; + const speedLabel = + speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) + ? `↓ ${formatMbps(speed.tcpDownloadBps)} ↑ ${formatMbps(speed.tcpUploadBps)}` + : null; return { edgeKey: `${from}-${to}-${c.tunnelType || ''}`, from, @@ -191,11 +208,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa p1, p2, pingLabel, + speedLabel, + speed, tunnelType: c.tunnelType, raw: c, }; }).filter((e) => e.p1 && e.p2); - }, [connections, positions, pingMap]); + }, [connections, positions, pingMap, speedMap]); // Вычисляем старт/финиш и одну контрольную точку: // рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов. @@ -307,20 +326,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa strokeLinecap="round" opacity={dimFactor} /> - {e.pingLabel && (() => { + {(() => { const base = labelPoint(e); let x = base.x; let y = base.y; - // Простейшее разруливание коллизий: если рядом уже есть бейдж, - // сдвигаем вниз на LABEL_H + GAP, пока не найдём свободное место. + const labels = [e.pingLabel, e.speedLabel].filter(Boolean); + if (labels.length === 0) return null; let level = 0; - // ограничиваем число итераций, чтобы не зависнуть while ( level < 10 && used.some( (p) => Math.abs(p.x - x) < LABEL_W && - Math.abs(p.y - (y + level * (LABEL_H + LABEL_GAP))) < LABEL_H + Math.abs(p.y - (y + level * (LABEL_H + LABEL_GAP))) < (labels.length * (LABEL_H + LABEL_GAP)) ) ) { level += 1; @@ -329,22 +347,29 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa used.push({ x, y }); const pad = 6; - const w = e.pingLabel.length * 6 + pad * 2; - const h = 18; return ( - - - {e.pingLabel} - + {labels.map((text, i) => { + const yy = y + i * (LABEL_H + LABEL_GAP); + const w = Math.min(text.length * 5.5 + pad * 2, 140); + const h = 18; + return ( + + + + {text.length > 24 ? text.slice(0, 22) + '…' : text} + + + ); + })} ); })()} @@ -412,6 +437,8 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa const toServer = servers.find((s) => String(s.ip) === selectedEdge.to); const fwd = pingMap[`${selectedEdge.from}:${selectedEdge.to}`]; const rev = pingMap[`${selectedEdge.to}:${selectedEdge.from}`]; + const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey); + const speed = speedMap[sk] ?? selectedEdge.speed; return (
-
+
Пинг
@@ -503,6 +530,31 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
+ {speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && ( +
+
+ Скорость по туннелю + {speed.cached && ( + кеш + )} +
+
+
+ ↓ Download + {formatMbps(speed.tcpDownloadBps)} +
+
+ ↑ Upload + {formatMbps(speed.tcpUploadBps)} +
+ {speed.durationSeconds != null && ( +
+ Замер за {speed.durationSeconds} сек +
+ )} +
+
+ )}