feat(MikrotikSpeedTest): add speed test API route and frontend integration; implement caching and error handling for speed test functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
This commit is contained in:
@@ -21,6 +21,7 @@ const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
||||
const NETWORK_CONFIG_KEY = 'network-config.json';
|
||||
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||
const PING_CACHE_PREFIX = 'ping-cache/';
|
||||
const SPEEDTEST_CACHE_PREFIX = 'speed-test-cache/';
|
||||
|
||||
/** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */
|
||||
async function loadUiSettings() {
|
||||
@@ -39,6 +40,16 @@ function pingCacheKey(serverId, gatewayIp, target) {
|
||||
return PING_CACHE_PREFIX + crypto.createHash('sha256').update(payload, 'utf8').digest('hex') + '.json';
|
||||
}
|
||||
|
||||
/** Ключ кеша speed-test по serverId и interfaceName */
|
||||
function speedTestCacheKey(serverId, interfaceName) {
|
||||
const payload = `${serverId}|${interfaceName || ''}`;
|
||||
return (
|
||||
SPEEDTEST_CACHE_PREFIX +
|
||||
crypto.createHash('sha256').update(payload, 'utf8').digest('hex') +
|
||||
'.json'
|
||||
);
|
||||
}
|
||||
|
||||
/** Загрузить network-config из S3 */
|
||||
async function loadNetworkConfig() {
|
||||
try {
|
||||
@@ -791,6 +802,284 @@ async function tracerouteViaGateway(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseSpeedToBps(val) {
|
||||
if (val == null) return null;
|
||||
const s = String(val).trim();
|
||||
const m = s.match(/([\d.]+)\s*([kKmMgG])?[bB]ps/);
|
||||
if (!m) return null;
|
||||
const num = Number(m[1]);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
const unit = (m[2] || '').toLowerCase();
|
||||
let mult = 1;
|
||||
if (unit === 'k') mult = 1e3;
|
||||
else if (unit === 'm') mult = 1e6;
|
||||
else if (unit === 'g') mult = 1e9;
|
||||
return num * mult;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mikrotik/speed-test
|
||||
* Замер TCP download/upload между двумя MikroTik по туннельному интерфейсу.
|
||||
*
|
||||
* Body:
|
||||
* - serverId (обязательный) — локальный сервер (jumphost/home)
|
||||
* - interfaceName (обязательный) — имя туннельного интерфейса (как в /network-config.tunnelInterfaces и RouterOS)
|
||||
* - durationSeconds? — время теста для каждого этапа (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек)
|
||||
*
|
||||
* Локальный роутер выполняет /tool/speed-test до IP удалённого конца туннеля (localIp/remoteIp),
|
||||
* используя учетные данные удалённого MikroTik из servers.json. Берём tcp-download и tcp-upload.
|
||||
*/
|
||||
async function speedTestViaTunnel(req, res) {
|
||||
try {
|
||||
const { serverId, interfaceName } = req.body || {};
|
||||
let { durationSeconds } = req.body || {};
|
||||
|
||||
if (!serverId || !interfaceName) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'serverId и interfaceName обязательны',
|
||||
'E_BAD_REQUEST'
|
||||
);
|
||||
}
|
||||
|
||||
const uiSettings = await loadUiSettings();
|
||||
const defaultDuration = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
600,
|
||||
parseInt(uiSettings.interfaceSpeedTestDurationSeconds, 10) || 10
|
||||
)
|
||||
);
|
||||
durationSeconds = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
600,
|
||||
parseInt(
|
||||
durationSeconds != null ? durationSeconds : defaultDuration,
|
||||
10
|
||||
) || defaultDuration
|
||||
)
|
||||
);
|
||||
|
||||
const cacheMinutes = Math.max(
|
||||
0,
|
||||
parseInt(uiSettings.interfaceSpeedTestCacheMinutes, 10) || 0
|
||||
);
|
||||
const cacheKey =
|
||||
cacheMinutes > 0
|
||||
? speedTestCacheKey(serverId, interfaceName)
|
||||
: null;
|
||||
|
||||
if (cacheKey) {
|
||||
try {
|
||||
const raw = await readS3TextObject(cacheKey).catch(() => null);
|
||||
if (raw?.body) {
|
||||
const cached = JSON.parse(raw.body);
|
||||
const cachedAt =
|
||||
typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
|
||||
const ttlMs = cacheMinutes * 60 * 1000;
|
||||
if (cachedAt && Date.now() - cachedAt < ttlMs) {
|
||||
return res.json({ ...cached, cached: true });
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Если кеш не прочитался — продолжаем без него
|
||||
}
|
||||
}
|
||||
|
||||
const config = await loadNetworkConfig();
|
||||
const tunnelIfaces = Array.isArray(config.tunnelInterfaces)
|
||||
? config.tunnelInterfaces
|
||||
: [];
|
||||
|
||||
const tun = tunnelIfaces.find(
|
||||
(i) =>
|
||||
i &&
|
||||
i.name === interfaceName &&
|
||||
(i.serverId === serverId || i.serverId2 === serverId)
|
||||
);
|
||||
|
||||
if (!tun) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
`Интерфейс "${interfaceName}" не найден в tunnelInterfaces для сервера ${serverId}`,
|
||||
'E_TUN_IFACE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const servers = await readServersFromS3();
|
||||
const allServers = Array.isArray(servers) ? servers : [];
|
||||
|
||||
const localServer =
|
||||
allServers.find((s) => (s.id || s.dns || s.ip) === serverId) || null;
|
||||
if (
|
||||
!localServer ||
|
||||
(localServer.type !== 'jumphost' &&
|
||||
String(localServer.type || '').toLowerCase() !== 'home')
|
||||
) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'Сервер (jumphost или home) не найден',
|
||||
'E_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const remoteKey = tun.serverId === serverId ? tun.serverId2 : tun.serverId;
|
||||
const remoteServer =
|
||||
allServers.find((s) => (s.id || s.dns || s.ip) === remoteKey) || null;
|
||||
|
||||
if (!remoteServer) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'Удалённый сервер туннеля не найден',
|
||||
'E_REMOTE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const localCreds = getMikrotikCredentials(localServer);
|
||||
const remoteCreds = getMikrotikCredentials(remoteServer);
|
||||
|
||||
if (!localCreds) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'MikroTik credentials not configured for local server',
|
||||
'E_LOCAL_CREDENTIALS'
|
||||
);
|
||||
}
|
||||
if (!remoteCreds) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'MikroTik credentials not configured for remote server',
|
||||
'E_REMOTE_CREDENTIALS'
|
||||
);
|
||||
}
|
||||
|
||||
const client = createRosClient(localCreds);
|
||||
|
||||
const address =
|
||||
tun.serverId === serverId
|
||||
? tun.remoteIp ||
|
||||
remoteServer.mikrotikHost ||
|
||||
remoteServer.ip ||
|
||||
remoteServer.dns
|
||||
: tun.localIp ||
|
||||
remoteServer.mikrotikHost ||
|
||||
remoteServer.ip ||
|
||||
remoteServer.dns;
|
||||
|
||||
if (!address) {
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'Не удалось определить адрес удалённого конца туннеля для speed-test',
|
||||
'E_NO_ADDRESS'
|
||||
);
|
||||
}
|
||||
|
||||
const body = {
|
||||
address,
|
||||
user: remoteCreds.user,
|
||||
password: remoteCreds.password || '',
|
||||
protocol: 'tcp',
|
||||
direction: 'both',
|
||||
'test-duration': `${durationSeconds}s`,
|
||||
};
|
||||
|
||||
try {
|
||||
const cliParts = [
|
||||
'/tool/speed-test',
|
||||
`address=${body.address}`,
|
||||
`user=${body.user}`,
|
||||
'protocol=tcp',
|
||||
'direction=both',
|
||||
`test-duration=${durationSeconds}s`,
|
||||
];
|
||||
console.log('[mikrotik][speedTestViaTunnel]', {
|
||||
serverId,
|
||||
interfaceName,
|
||||
remoteServerId: remoteKey,
|
||||
body: { ...body, password: '***' },
|
||||
cli: cliParts.join(' '),
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
const stRes = await client.command('tool/speed-test', body);
|
||||
const raw = stRes?.data;
|
||||
const rows = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
if (!rows.length) {
|
||||
return sendError(
|
||||
res,
|
||||
502,
|
||||
'RouterOS speed-test не вернул данных',
|
||||
'E_SPEEDTEST_EMPTY'
|
||||
);
|
||||
}
|
||||
|
||||
const summary =
|
||||
[...rows]
|
||||
.reverse()
|
||||
.find((r) => r['tcp-download'] || r['tcp-upload']) ||
|
||||
rows[rows.length - 1];
|
||||
|
||||
const tcpDownloadStr = summary['tcp-download'] || null;
|
||||
const tcpUploadStr = summary['tcp-upload'] || null;
|
||||
const tcpDownloadBps = parseSpeedToBps(tcpDownloadStr);
|
||||
const tcpUploadBps = parseSpeedToBps(tcpUploadStr);
|
||||
const totalBps =
|
||||
(tcpDownloadBps || 0) + (tcpUploadBps || 0);
|
||||
|
||||
const payload = {
|
||||
ok: true,
|
||||
serverId,
|
||||
remoteServerId: remoteKey || null,
|
||||
interfaceName,
|
||||
address,
|
||||
durationSeconds,
|
||||
tcpDownload: tcpDownloadStr,
|
||||
tcpUpload: tcpUploadStr,
|
||||
tcpDownloadBps,
|
||||
tcpUploadBps,
|
||||
totalBps,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (cacheKey) {
|
||||
writeS3JsonObject(cacheKey, {
|
||||
...payload,
|
||||
cached: false,
|
||||
cachedAt: Date.now(),
|
||||
}).catch((err) =>
|
||||
console.warn(
|
||||
'[mikrotik][speedTestViaTunnel] cache write failed:',
|
||||
err?.message || err
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Speed-test failed';
|
||||
const status = error.response?.status;
|
||||
console.error('speedTestViaTunnel:', error);
|
||||
return sendError(
|
||||
res,
|
||||
status && status >= 400 ? status : 502,
|
||||
msg,
|
||||
'E_SPEEDTEST'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mikrotik/run-script
|
||||
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
|
||||
@@ -840,4 +1129,5 @@ module.exports = {
|
||||
tracerouteViaGateway,
|
||||
pingViaInterface,
|
||||
runPingViaRouter,
|
||||
speedTestViaTunnel,
|
||||
};
|
||||
|
||||
@@ -461,6 +461,8 @@ app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now()
|
||||
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
|
||||
// Traceroute через RouterOS с выбором сервера и шлюза
|
||||
app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
|
||||
// Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу
|
||||
app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel);
|
||||
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function InterfaceSpeedTest() {
|
||||
interfaceName: selectedInterfaceName,
|
||||
durationSeconds: speedSettings.durationSeconds,
|
||||
};
|
||||
const res = await api.post('/traffic/interface-speed-test', body);
|
||||
const res = await api.post('/mikrotik/speed-test', body);
|
||||
const data = res?.data || {};
|
||||
if (data.ok === false) {
|
||||
notify.error(
|
||||
@@ -197,7 +197,9 @@ export default function InterfaceSpeedTest() {
|
||||
setSpeedResult(data);
|
||||
} catch (e) {
|
||||
console.error('[InterfaceSpeedTest] test failed', e);
|
||||
notify.error(e?.response?.data?.message || e?.message || 'Ошибка замера скорости');
|
||||
notify.error(
|
||||
e?.response?.data?.message || e?.message || 'Ошибка замера скорости'
|
||||
);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
@@ -446,14 +448,17 @@ export default function InterfaceSpeedTest() {
|
||||
<div className="card bg-azure-lt">
|
||||
<div className="card-body py-2">
|
||||
<div className="text-muted small mb-1">
|
||||
Суммарная скорость
|
||||
TCP скорость (download + upload)
|
||||
</div>
|
||||
<div className="fw-bold fs-4">
|
||||
{formatMbps(speedResult.totalBps)}
|
||||
{formatMbps(
|
||||
(speedResult.tcpDownloadBps || 0) +
|
||||
(speedResult.tcpUploadBps || 0)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted small">
|
||||
RX: {formatMbps(speedResult.rxBps)} · TX:{' '}
|
||||
{formatMbps(speedResult.txBps)}
|
||||
Download: {formatMbps(speedResult.tcpDownloadBps)} ·
|
||||
Upload: {formatMbps(speedResult.tcpUploadBps)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user