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

This commit is contained in:
2026-02-17 23:00:49 +07:00
parent d29f1aef51
commit 9dc931a10b
3 changed files with 303 additions and 6 deletions
+290
View File
@@ -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,
};
+2
View File
@@ -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);
+11 -6
View File
@@ -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>