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 [servers, setServers] = useState([]);
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [pingMap, setPingMap] = useState({});
|
const [pingMap, setPingMap] = useState({});
|
||||||
|
const [speedMap, setSpeedMap] = useState({});
|
||||||
const pingTimestampsRef = useRef({});
|
const pingTimestampsRef = useRef({});
|
||||||
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
|
const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pingLoading, setPingLoading] = useState(false);
|
const [pingLoading, setPingLoading] = useState(false);
|
||||||
|
const [speedLoading, setSpeedLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const pingAbortRef = useRef(false);
|
const pingAbortRef = useRef(false);
|
||||||
|
const speedAbortRef = useRef(false);
|
||||||
|
|
||||||
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
@@ -68,20 +71,24 @@ export default function NetworkMapDashboard() {
|
|||||||
const s1Key = s1.id || s1.dns || s1.ip;
|
const s1Key = s1.id || s1.dns || s1.ip;
|
||||||
const s2Key = s2.id || s2.dns || s2.ip;
|
const s2Key = s2.id || s2.dns || s2.ip;
|
||||||
if (!s1Key || !s2Key) return;
|
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({
|
connList.push({
|
||||||
from: s1.ip,
|
from: s1.ip,
|
||||||
to: s2.ip,
|
to: s2.ip,
|
||||||
tunnelType: iface.type || 'GRE',
|
tunnelType: iface.type || 'GRE',
|
||||||
// Внутренние IP туннеля: с сервера 1 до сервера 2 и обратно
|
interfaceName: iface.name || null,
|
||||||
fromKey: s1Key,
|
fromKey: s1Key,
|
||||||
toKey: s2Key,
|
toKey: s2Key,
|
||||||
internalFromTo: iface.remoteIp || null, // ping: server1 -> remoteIp
|
internalFromTo: iface.remoteIp || null,
|
||||||
internalToFrom: iface.localIp || null, // ping: server2 -> localIp
|
internalToFrom: iface.localIp || null,
|
||||||
|
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setServers(serversList);
|
setServers(serversList);
|
||||||
setConnections(connList);
|
setConnections(connList);
|
||||||
|
setSpeedMap({});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('NetworkMap fetch:', e);
|
console.error('NetworkMap fetch:', e);
|
||||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
||||||
@@ -177,6 +184,49 @@ export default function NetworkMapDashboard() {
|
|||||||
setPingLoading(false);
|
setPingLoading(false);
|
||||||
}, [connections, servers, pingCacheSeconds]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
@@ -190,10 +240,23 @@ export default function NetworkMapDashboard() {
|
|||||||
};
|
};
|
||||||
}, [loading, connections.length]);
|
}, [loading, connections.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && connections.length > 0) {
|
||||||
|
requestSpeeds();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
speedAbortRef.current = true;
|
||||||
|
};
|
||||||
|
}, [loading, connections.length, requestSpeeds]);
|
||||||
|
|
||||||
const handleRefreshPings = () => {
|
const handleRefreshPings = () => {
|
||||||
requestPings();
|
requestPings();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshSpeeds = () => {
|
||||||
|
requestSpeeds();
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -225,17 +288,28 @@ export default function NetworkMapDashboard() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Карта сети"
|
title="Карта сети"
|
||||||
icon={<IconTopologyRing size={24} />}
|
icon={<IconTopologyRing size={24} />}
|
||||||
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг (мс)"
|
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
|
||||||
actions={
|
actions={
|
||||||
<button
|
<div className="btn-list">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-outline-primary"
|
type="button"
|
||||||
onClick={handleRefreshPings}
|
className="btn btn-outline-primary"
|
||||||
disabled={pingLoading}
|
onClick={handleRefreshPings}
|
||||||
>
|
disabled={pingLoading}
|
||||||
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
|
>
|
||||||
{pingLoading ? 'Пинг…' : 'Обновить пинг'}
|
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
</button>
|
{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 ? (
|
{servers.length === 0 ? (
|
||||||
@@ -247,6 +321,7 @@ export default function NetworkMapDashboard() {
|
|||||||
servers={servers}
|
servers={servers}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
pingMap={pingMap}
|
pingMap={pingMap}
|
||||||
|
speedMap={speedMap}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,18 @@ function computeInitialLayout(servers, size) {
|
|||||||
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
||||||
* Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка.
|
* Узлы можно перетаскивать; раскладка сохраняется в 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 containerRef = useRef(null);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const [size, setSize] = useState({ w: 800, h: 520 });
|
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 fwd === 'number') parts.push(`→ ${fwd} ms`);
|
||||||
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
|
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
|
||||||
const pingLabel = parts.length ? parts.join(' ') : null;
|
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 {
|
return {
|
||||||
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
|
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
|
||||||
from,
|
from,
|
||||||
@@ -191,11 +208,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
p1,
|
p1,
|
||||||
p2,
|
p2,
|
||||||
pingLabel,
|
pingLabel,
|
||||||
|
speedLabel,
|
||||||
|
speed,
|
||||||
tunnelType: c.tunnelType,
|
tunnelType: c.tunnelType,
|
||||||
raw: c,
|
raw: c,
|
||||||
};
|
};
|
||||||
}).filter((e) => e.p1 && e.p2);
|
}).filter((e) => e.p1 && e.p2);
|
||||||
}, [connections, positions, pingMap]);
|
}, [connections, positions, pingMap, speedMap]);
|
||||||
|
|
||||||
// Вычисляем старт/финиш и одну контрольную точку:
|
// Вычисляем старт/финиш и одну контрольную точку:
|
||||||
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
|
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
|
||||||
@@ -307,20 +326,19 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
opacity={dimFactor}
|
opacity={dimFactor}
|
||||||
/>
|
/>
|
||||||
{e.pingLabel && (() => {
|
{(() => {
|
||||||
const base = labelPoint(e);
|
const base = labelPoint(e);
|
||||||
let x = base.x;
|
let x = base.x;
|
||||||
let y = base.y;
|
let y = base.y;
|
||||||
// Простейшее разруливание коллизий: если рядом уже есть бейдж,
|
const labels = [e.pingLabel, e.speedLabel].filter(Boolean);
|
||||||
// сдвигаем вниз на LABEL_H + GAP, пока не найдём свободное место.
|
if (labels.length === 0) return null;
|
||||||
let level = 0;
|
let level = 0;
|
||||||
// ограничиваем число итераций, чтобы не зависнуть
|
|
||||||
while (
|
while (
|
||||||
level < 10 &&
|
level < 10 &&
|
||||||
used.some(
|
used.some(
|
||||||
(p) =>
|
(p) =>
|
||||||
Math.abs(p.x - x) < LABEL_W &&
|
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;
|
level += 1;
|
||||||
@@ -329,22 +347,29 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
used.push({ x, y });
|
used.push({ x, y });
|
||||||
|
|
||||||
const pad = 6;
|
const pad = 6;
|
||||||
const w = e.pingLabel.length * 6 + pad * 2;
|
|
||||||
const h = 18;
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<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} />
|
{labels.map((text, i) => {
|
||||||
<text
|
const yy = y + i * (LABEL_H + LABEL_GAP);
|
||||||
x={x}
|
const w = Math.min(text.length * 5.5 + pad * 2, 140);
|
||||||
y={y}
|
const h = 18;
|
||||||
textAnchor="middle"
|
return (
|
||||||
dominantBaseline="middle"
|
<g key={i}>
|
||||||
fill={UNIFI_GREEN}
|
<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} />
|
||||||
fontSize={11}
|
<text
|
||||||
fontWeight={600}
|
x={x}
|
||||||
>
|
y={yy}
|
||||||
{e.pingLabel}
|
textAnchor="middle"
|
||||||
</text>
|
dominantBaseline="middle"
|
||||||
|
fill={UNIFI_GREEN}
|
||||||
|
fontSize={i === 0 ? 11 : 10}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{text.length > 24 ? text.slice(0, 22) + '…' : text}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -412,6 +437,8 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to);
|
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to);
|
||||||
const fwd = pingMap[`${selectedEdge.from}:${selectedEdge.to}`];
|
const fwd = pingMap[`${selectedEdge.from}:${selectedEdge.to}`];
|
||||||
const rev = pingMap[`${selectedEdge.to}:${selectedEdge.from}`];
|
const rev = pingMap[`${selectedEdge.to}:${selectedEdge.from}`];
|
||||||
|
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
|
||||||
|
const speed = speedMap[sk] ?? selectedEdge.speed;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="network-map-edge-modal-backdrop"
|
className="network-map-edge-modal-backdrop"
|
||||||
@@ -488,7 +515,7 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1">
|
<div className="mb-3">
|
||||||
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
|
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
|
||||||
Пинг
|
Пинг
|
||||||
</div>
|
</div>
|
||||||
@@ -503,6 +530,31 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user