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 NETWORK_CONFIG_KEY = 'network-config.json';
|
||||||
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
const PING_CACHE_PREFIX = 'ping-cache/';
|
const PING_CACHE_PREFIX = 'ping-cache/';
|
||||||
|
const SPEEDTEST_CACHE_PREFIX = 'speed-test-cache/';
|
||||||
|
|
||||||
/** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */
|
/** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */
|
||||||
async function loadUiSettings() {
|
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';
|
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 */
|
/** Загрузить network-config из S3 */
|
||||||
async function loadNetworkConfig() {
|
async function loadNetworkConfig() {
|
||||||
try {
|
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
|
* POST /api/mikrotik/run-script
|
||||||
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
|
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
|
||||||
@@ -840,4 +1129,5 @@ module.exports = {
|
|||||||
tracerouteViaGateway,
|
tracerouteViaGateway,
|
||||||
pingViaInterface,
|
pingViaInterface,
|
||||||
runPingViaRouter,
|
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);
|
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
|
||||||
// Traceroute через RouterOS с выбором сервера и шлюза
|
// Traceroute через RouterOS с выбором сервера и шлюза
|
||||||
app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
|
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/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function InterfaceSpeedTest() {
|
|||||||
interfaceName: selectedInterfaceName,
|
interfaceName: selectedInterfaceName,
|
||||||
durationSeconds: speedSettings.durationSeconds,
|
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 || {};
|
const data = res?.data || {};
|
||||||
if (data.ok === false) {
|
if (data.ok === false) {
|
||||||
notify.error(
|
notify.error(
|
||||||
@@ -197,7 +197,9 @@ export default function InterfaceSpeedTest() {
|
|||||||
setSpeedResult(data);
|
setSpeedResult(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[InterfaceSpeedTest] test failed', e);
|
console.error('[InterfaceSpeedTest] test failed', e);
|
||||||
notify.error(e?.response?.data?.message || e?.message || 'Ошибка замера скорости');
|
notify.error(
|
||||||
|
e?.response?.data?.message || e?.message || 'Ошибка замера скорости'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
@@ -446,14 +448,17 @@ export default function InterfaceSpeedTest() {
|
|||||||
<div className="card bg-azure-lt">
|
<div className="card bg-azure-lt">
|
||||||
<div className="card-body py-2">
|
<div className="card-body py-2">
|
||||||
<div className="text-muted small mb-1">
|
<div className="text-muted small mb-1">
|
||||||
Суммарная скорость
|
TCP скорость (download + upload)
|
||||||
</div>
|
</div>
|
||||||
<div className="fw-bold fs-4">
|
<div className="fw-bold fs-4">
|
||||||
{formatMbps(speedResult.totalBps)}
|
{formatMbps(
|
||||||
|
(speedResult.tcpDownloadBps || 0) +
|
||||||
|
(speedResult.tcpUploadBps || 0)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted small">
|
<div className="text-muted small">
|
||||||
RX: {formatMbps(speedResult.rxBps)} · TX:{' '}
|
Download: {formatMbps(speedResult.tcpDownloadBps)} ·
|
||||||
{formatMbps(speedResult.txBps)}
|
Upload: {formatMbps(speedResult.tcpUploadBps)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user