Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
421 lines
16 KiB
React
421 lines
16 KiB
React
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import api from './lib/api.js';
|
|
import { IconRefresh, IconTopologyRing, IconClock } from '@tabler/icons-react';
|
|
import PageHeader from './components/PageHeader.jsx';
|
|
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
|
|
|
|
/** Параллельно выполняем промисы с лимитом одновременных */
|
|
async function runWithLimit(tasks, limit = 4) {
|
|
const results = [];
|
|
let index = 0;
|
|
async function runNext() {
|
|
const i = index++;
|
|
if (i >= tasks.length) return;
|
|
const task = tasks[i];
|
|
try {
|
|
const value = await task();
|
|
results[i] = { value };
|
|
} catch (err) {
|
|
results[i] = { error: err };
|
|
}
|
|
await runNext();
|
|
}
|
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Выполняет задачи так, что на каждый ключ (ноду) в один момент времени
|
|
* выполняется не более одной задачи. Разные ноды — параллельно.
|
|
* getKey(i) возвращает ключ для задачи с индексом i.
|
|
*/
|
|
async function runWithOnePerKey(tasks, getKey) {
|
|
const n = tasks.length;
|
|
const keyToIndices = new Map();
|
|
for (let i = 0; i < n; i++) {
|
|
const k = String(getKey(i));
|
|
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
|
|
keyToIndices.get(k).push(i);
|
|
}
|
|
const results = [];
|
|
for (let i = 0; i < n; i++) results[i] = null;
|
|
|
|
const keys = Array.from(keyToIndices.keys());
|
|
let round = 0;
|
|
while (true) {
|
|
const batch = [];
|
|
for (const k of keys) {
|
|
const indices = keyToIndices.get(k);
|
|
if (round < indices.length) batch.push(indices[round]);
|
|
}
|
|
if (batch.length === 0) break;
|
|
await Promise.all(
|
|
batch.map(async (i) => {
|
|
try {
|
|
const value = await tasks[i]();
|
|
results[i] = { value };
|
|
} catch (err) {
|
|
results[i] = { error: err };
|
|
}
|
|
})
|
|
);
|
|
round++;
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export default function NetworkMapDashboard() {
|
|
const [servers, setServers] = useState([]);
|
|
const [connections, setConnections] = useState([]);
|
|
const [pingMap, setPingMap] = useState({});
|
|
const [speedMap, setSpeedMap] = useState({});
|
|
const [pingStaleMap, setPingStaleMap] = useState({});
|
|
const [speedStaleMap, setSpeedStaleMap] = useState({});
|
|
const pingTimestampsRef = useRef({});
|
|
const lastSuccessfulPingRef = useRef({});
|
|
const lastSuccessfulSpeedRef = 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);
|
|
/** Время применения кеша от планировщика (чтобы не запускать пинг/скорость повторно при открытии) */
|
|
const cacheAppliedAtRef = useRef(null);
|
|
|
|
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
cacheAppliedAtRef.current = null;
|
|
try {
|
|
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
|
|
api.get('/servers'),
|
|
api.get('/network-config').catch(() => ({ data: null })),
|
|
api.get('/ui-settings').catch(() => ({ data: {} })),
|
|
api.get('/network-map-cache').catch(() => ({ data: {} })),
|
|
]);
|
|
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
|
const config = configRes?.data || {};
|
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
|
const uiSettings = uiSettingsRes?.data || {};
|
|
const cache = cacheRes?.data || {};
|
|
const ttlSeconds = Math.max(
|
|
0,
|
|
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
|
);
|
|
setPingCacheSeconds(ttlSeconds);
|
|
|
|
const getServer = (serverId) =>
|
|
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
|
|
|
const connList = [];
|
|
tunnelInterfaces.forEach((iface) => {
|
|
if (!iface.serverId || !iface.serverId2) return;
|
|
const s1 = getServer(iface.serverId);
|
|
const s2 = getServer(iface.serverId2);
|
|
if (!s1 || !s2 || s1.ip === s2.ip) return;
|
|
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',
|
|
interfaceName: iface.name || null,
|
|
fromKey: s1Key,
|
|
toKey: s2Key,
|
|
internalFromTo: iface.remoteIp || null,
|
|
internalToFrom: iface.localIp || null,
|
|
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
|
|
});
|
|
});
|
|
|
|
setServers(serversList);
|
|
setConnections(connList);
|
|
|
|
const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000);
|
|
const cacheValid =
|
|
cache.updatedAt &&
|
|
Date.now() - cache.updatedAt < cacheMaxAgeMs &&
|
|
(Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0);
|
|
if (cacheValid) {
|
|
const cachedPingMap = cache.pingMap || {};
|
|
const cachedSpeedMap = cache.speedMap || {};
|
|
setPingMap(cachedPingMap);
|
|
setSpeedMap(cachedSpeedMap);
|
|
// Сохраняем успешные значения из кеша
|
|
Object.keys(cachedPingMap).forEach((k) => {
|
|
if (typeof cachedPingMap[k] === 'number') {
|
|
lastSuccessfulPingRef.current[k] = cachedPingMap[k];
|
|
}
|
|
pingTimestampsRef.current[k] = cache.updatedAt;
|
|
});
|
|
Object.keys(cachedSpeedMap).forEach((k) => {
|
|
if (cachedSpeedMap[k] && typeof cachedSpeedMap[k] === 'object') {
|
|
lastSuccessfulSpeedRef.current[k] = cachedSpeedMap[k];
|
|
}
|
|
});
|
|
cacheAppliedAtRef.current = cache.updatedAt;
|
|
// Сбрасываем флаги устаревших данных при загрузке валидного кеша
|
|
setPingStaleMap({});
|
|
setSpeedStaleMap({});
|
|
} else {
|
|
setSpeedMap({});
|
|
// При невалидном кеше также сбрасываем флаги
|
|
setPingStaleMap({});
|
|
setSpeedStaleMap({});
|
|
}
|
|
} catch (e) {
|
|
console.error('NetworkMap fetch:', e);
|
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
|
setServers([]);
|
|
setConnections([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
/** Один ключ пинга на ребро (маршруты статичны — одна сторона) */
|
|
const edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
|
|
|
|
const requestPings = useCallback(async () => {
|
|
if (connections.length === 0) return;
|
|
pingAbortRef.current = false;
|
|
setPingLoading(true);
|
|
const ttlMs = Math.max(0, (pingCacheSeconds || 0) * 1000);
|
|
|
|
const tasks = [];
|
|
connections.forEach((c) => {
|
|
const from = String(c.from);
|
|
const to = String(c.to);
|
|
if (!from || !to || from === to) return;
|
|
const srcFrom = servers.find((s) => s.ip === from);
|
|
const srcTo = servers.find((s) => s.ip === to);
|
|
const canPingFrom =
|
|
srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
|
|
const canPingTo =
|
|
srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
|
|
|
const ekey = edgePingKey(from, to);
|
|
if (ttlMs > 0 && pingTimestampsRef.current[ekey] && Date.now() - pingTimestampsRef.current[ekey] < ttlMs) {
|
|
return;
|
|
}
|
|
|
|
// Один пинг на ребро — с той стороны, где есть jumphost
|
|
if (canPingFrom) {
|
|
tasks.push(async () => {
|
|
if (pingAbortRef.current) return;
|
|
try {
|
|
const { data } = await api.post('/mikrotik/ping', {
|
|
serverId: c.fromKey,
|
|
target: c.internalFromTo,
|
|
gatewayIp: c.internalFromTo,
|
|
count: 3,
|
|
});
|
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
|
if (ms != null) {
|
|
pingTimestampsRef.current[ekey] = Date.now();
|
|
lastSuccessfulPingRef.current[ekey] = ms;
|
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
|
}
|
|
} catch {
|
|
pingTimestampsRef.current[ekey] = Date.now();
|
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
|
if (lastSuccess != null) {
|
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
|
} else {
|
|
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
|
}
|
|
}
|
|
});
|
|
} else if (canPingTo) {
|
|
tasks.push(async () => {
|
|
if (pingAbortRef.current) return;
|
|
try {
|
|
const { data } = await api.post('/mikrotik/ping', {
|
|
serverId: c.toKey,
|
|
target: c.internalToFrom,
|
|
gatewayIp: c.internalToFrom,
|
|
count: 3,
|
|
});
|
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
|
if (ms != null) {
|
|
pingTimestampsRef.current[ekey] = Date.now();
|
|
lastSuccessfulPingRef.current[ekey] = ms;
|
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
|
}
|
|
} catch {
|
|
pingTimestampsRef.current[ekey] = Date.now();
|
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
|
if (lastSuccess != null) {
|
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
|
} else {
|
|
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
await runWithLimit(tasks, 4);
|
|
if (pingAbortRef.current) return;
|
|
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)) {
|
|
const speedData = {
|
|
tcpDownloadBps: data.tcpDownloadBps,
|
|
tcpUploadBps: data.tcpUploadBps,
|
|
cached: data.cached === true,
|
|
durationSeconds: data.durationSeconds,
|
|
};
|
|
lastSuccessfulSpeedRef.current[key] = speedData;
|
|
setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
|
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
|
|
}
|
|
} catch {
|
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
|
const lastSuccess = lastSuccessfulSpeedRef.current[key];
|
|
if (lastSuccess != null) {
|
|
setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess }));
|
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: true }));
|
|
} else {
|
|
setSpeedMap((prev) => ({ ...prev, [key]: null }));
|
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
|
|
}
|
|
}
|
|
});
|
|
// В один момент — только один тест на один сервер назначения (удалённый конец туннеля),
|
|
// иначе несколько замеров к одному хостингу делят канал и дают заниженные результаты
|
|
await runWithOnePerKey(tasks, (i) => {
|
|
const c = withSpeed[i];
|
|
return c.speedTestServerId === c.fromKey ? c.toKey : c.fromKey;
|
|
});
|
|
if (speedAbortRef.current) return;
|
|
setSpeedLoading(false);
|
|
}, [connections]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
// Пинг и скорость не запускаются автоматически при открытии карты (даже если кеш просрочен).
|
|
// Обновление только по кнопкам «Пинг» и «Скорость» или по планировщику.
|
|
|
|
const handleRefreshPings = () => {
|
|
requestPings();
|
|
};
|
|
|
|
const handleRefreshSpeeds = () => {
|
|
requestSpeeds();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Карта сети"
|
|
icon={<IconTopologyRing size={24} />}
|
|
meta="Серверы и пинг между ними"
|
|
/>
|
|
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Карта сети"
|
|
icon={<IconTopologyRing size={24} />}
|
|
meta="Серверы и пинг между ними"
|
|
/>
|
|
<div className="alert alert-danger">{error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Карта сети"
|
|
icon={<IconTopologyRing size={24} />}
|
|
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг и скорость"
|
|
actions={
|
|
<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 ? (
|
|
<div className="alert alert-info">
|
|
Нет серверов. Добавьте серверы в разделе «Серверы».
|
|
</div>
|
|
) : (
|
|
<NetworkMapUnifi
|
|
servers={servers}
|
|
connections={connections}
|
|
pingMap={pingMap}
|
|
speedMap={speedMap}
|
|
pingStaleMap={pingStaleMap}
|
|
speedStaleMap={speedStaleMap}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|