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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user