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} сек
+
+ )}
+
+
+ )}