feat(NetworkMap): add speed test functionality to Network Map Dashboard and Unifi components, including UI updates for speed display and handling
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s

This commit is contained in:
2026-02-18 11:43:33 +07:00
parent 29161ae0cb
commit 034e0d1eee
2 changed files with 162 additions and 35 deletions
+88 -13
View File
@@ -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 (
<div>
@@ -225,17 +288,28 @@ export default function NetworkMapDashboard() {
<PageHeader
title="Карта сети"
icon={<IconTopologyRing size={24} />}
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг (мс)"
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshPings}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
{pingLoading ? 'Пинг…' : 'Обновить пинг'}
</button>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshPings}
disabled={pingLoading}
>
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
{pingLoading ? 'Пинг…' : 'Пинг'}
</button>
<button
type="button"
className="btn btn-outline-primary"
onClick={handleRefreshSpeeds}
disabled={speedLoading}
>
<IconRefresh className={speedLoading ? 'spin me-2' : 'me-2'} size={18} />
{speedLoading ? 'Скорость…' : 'Скорость'}
</button>
</div>
}
/>
{servers.length === 0 ? (
@@ -247,6 +321,7 @@ export default function NetworkMapDashboard() {
servers={servers}
connections={connections}
pingMap={pingMap}
speedMap={speedMap}
/>
)}
</div>
+74 -22
View File
@@ -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 (
<g>
<rect x={x - w / 2} y={y - h / 2} width={w} height={h} rx={4} fill="rgba(15,23,42,0.9)" stroke={UNIFI_GREEN} strokeWidth={1} />
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fill={UNIFI_GREEN}
fontSize={11}
fontWeight={600}
>
{e.pingLabel}
</text>
{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 (
<g key={i}>
<rect x={x - w / 2} y={yy - h / 2} width={w} height={h} rx={4} fill="rgba(15,23,42,0.9)" stroke={UNIFI_GREEN} strokeWidth={1} />
<text
x={x}
y={yy}
textAnchor="middle"
dominantBaseline="middle"
fill={UNIFI_GREEN}
fontSize={i === 0 ? 11 : 10}
fontWeight={600}
>
{text.length > 24 ? text.slice(0, 22) + '…' : text}
</text>
</g>
);
})}
</g>
);
})()}
@@ -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 (
<div
className="network-map-edge-modal-backdrop"
@@ -488,7 +515,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
)}
</div>
</div>
<div className="mb-1">
<div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
Пинг
</div>
@@ -503,6 +530,31 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div>
</div>
</div>
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
<div className="mb-1">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
Скорость по туннелю
{speed.cached && (
<span className="ms-1 badge bg-secondary" style={{ fontSize: 10 }}>кеш</span>
)}
</div>
<div>
<div>
<span className="text-muted"> Download </span>
{formatMbps(speed.tcpDownloadBps)}
</div>
<div>
<span className="text-muted"> Upload </span>
{formatMbps(speed.tcpUploadBps)}
</div>
{speed.durationSeconds != null && (
<div className="text-muted small mt-1">
Замер за {speed.durationSeconds} сек
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>