feat(TrafficStats, Settings): add interface speed test functionality with configurable settings for protocol, duration, and caching; enhance UI for speed test integration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s

This commit is contained in:
2026-02-17 22:25:01 +07:00
parent baa2fc8044
commit d9667a06e6
5 changed files with 639 additions and 27 deletions
+217
View File
@@ -7,6 +7,7 @@ const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
/** Получить MikroTik credentials из сервера (jumphost или home) */
function getMikrotikCredentials(server) {
@@ -27,6 +28,36 @@ function getMikrotikCredentials(server) {
return { host, port: restPort, user, password, secure: false };
}
// UI-настройки (общие с остальным UI)
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
const parsed = JSON.parse(data?.body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function interfaceSpeedCacheKey(serverId, interfaceName) {
const key = `${serverId || ''}|${interfaceName || ''}`;
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
return (
INTERFACE_SPEED_CACHE_PREFIX +
String(key)
.trim()
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
'.json'
);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* GET /api/traffic/interface-stats
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
@@ -93,6 +124,192 @@ async function getInterfaceStats(req, res) {
}
}
/**
* POST /api/traffic/interface-speed-test
* Замеряет среднюю скорость по выбранному интерфейсу RouterOS на jumphost/home.
*
* Body:
* - serverId (обязательный) — id/dns/ip сервера из servers.json
* - interfaceName (обязательный) — имя интерфейса в RouterOS (как в /interface)
* - durationSeconds? — время замера (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек)
*
* Использует счётчики rx-byte/tx-byte: берёт значение в начале и в конце интервала
* и делит дельту на время замера.
*/
async function measureInterfaceSpeed(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
? interfaceSpeedCacheKey(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 servers = await readServersFromS3();
const server =
(Array.isArray(servers) ? servers : []).find(
(s) => (s.id || s.dns || s.ip) === serverId
) || null;
if (
!server ||
(server.type !== 'jumphost' &&
String(server.type || '').toLowerCase() !== 'home')
) {
return sendError(
res,
400,
'Сервер (jumphost или home) не найден',
'E_NOT_FOUND'
);
}
const creds = getMikrotikCredentials(server);
if (!creds) {
return sendError(
res,
400,
'MikroTik API не настроен или нет пароля',
'E_CREDENTIALS'
);
}
const client = createRosClient(creds);
async function readOne() {
const apiRes = await client.print('interface');
const raw = apiRes?.data;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const iface = list.find(
(i) =>
i &&
i.name != null &&
String(i.name).trim() === String(interfaceName).trim()
);
if (!iface) {
throw new Error(
`Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`
);
}
const rx =
iface['rx-byte'] != null
? parseInt(String(iface['rx-byte']), 10)
: 0;
const tx =
iface['tx-byte'] != null
? parseInt(String(iface['tx-byte']), 10)
: 0;
return {
rxBytes: Number.isNaN(rx) ? 0 : rx,
txBytes: Number.isNaN(tx) ? 0 : tx,
};
}
const start = await readOne();
await sleep(durationSeconds * 1000);
const end = await readOne();
const rxBytesDelta = Math.max(0, (end.rxBytes || 0) - (start.rxBytes || 0));
const txBytesDelta = Math.max(0, (end.txBytes || 0) - (start.txBytes || 0));
const totalBytesDelta = rxBytesDelta + txBytesDelta;
const duration = durationSeconds || 1;
const rxBps = rxBytesDelta * 8 / duration;
const txBps = txBytesDelta * 8 / duration;
const totalBps = totalBytesDelta * 8 / duration;
const payload = {
ok: true,
serverId,
interfaceName,
durationSeconds: duration,
rxBytesStart: start.rxBytes,
txBytesStart: start.txBytes,
rxBytesEnd: end.rxBytes,
txBytesEnd: end.txBytes,
rxBytesDelta,
txBytesDelta,
totalBytesDelta,
rxBps,
txBps,
totalBps,
cached: false,
};
if (cacheKey) {
writeS3JsonObject(cacheKey, {
...payload,
cached: false,
cachedAt: Date.now(),
}).catch((err) =>
console.warn(
'[traffic][interface-speed-test] cache write failed:',
err?.message || err
)
);
}
return res.json(payload);
} catch (error) {
console.error('measureInterfaceSpeed:', error);
return sendError(
res,
500,
error?.message || 'Ошибка замера скорости по интерфейсу',
'E_IFACE_SPEED'
);
}
}
module.exports = {
getInterfaceStats,
measureInterfaceSpeed,
};
+5
View File
@@ -466,6 +466,11 @@ app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
app.post(
'/api/traffic/interface-speed-test',
writeLimiter,
trafficRoutes.measureInterfaceSpeed
);
// === MIKROTIK BACKUPS (S3) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);