From d9667a06e60fa65babfb37eaec798b9b61d67119 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 17 Feb 2026 22:25:01 +0700 Subject: [PATCH] feat(TrafficStats, Settings): add interface speed test functionality with configurable settings for protocol, duration, and caching; enhance UI for speed test integration --- backend/routes/trafficRoutes.js | 217 +++++++++++++++++ backend/server.js | 5 + frontend/src/MikrotikTools.jsx | 272 ++++++++++++++++++++-- frontend/src/SettingsPage.jsx | 77 ++++++ frontend/src/components/SettingsModal.jsx | 95 +++++++- 5 files changed, 639 insertions(+), 27 deletions(-) diff --git a/backend/routes/trafficRoutes.js b/backend/routes/trafficRoutes.js index 2fa6089..7ff1579 100644 --- a/backend/routes/trafficRoutes.js +++ b/backend/routes/trafficRoutes.js @@ -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, }; diff --git a/backend/server.js b/backend/server.js index 79519a0..6f05bda 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/frontend/src/MikrotikTools.jsx b/frontend/src/MikrotikTools.jsx index f615903..31862c5 100644 --- a/frontend/src/MikrotikTools.jsx +++ b/frontend/src/MikrotikTools.jsx @@ -40,28 +40,63 @@ function MikrotikTools() { const [target, setTarget] = useState(''); const [maxHops, setMaxHops] = useState(30); - const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' + const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' | 'speed' const [running, setRunning] = useState(false); const [hops, setHops] = useState([]); const [analysis, setAnalysis] = useState([]); const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis' const [pingResult, setPingResult] = useState(null); + const [speedResult, setSpeedResult] = useState(null); const [useDns, setUseDns] = useState(true); const [serverSearch, setServerSearch] = useState(''); + const [selectedInterfaceName, setSelectedInterfaceName] = useState(''); + const [speedSettings, setSpeedSettings] = useState({ + protocol: 'tcp', + durationSeconds: 10, + cacheMinutes: 0, + }); - // Загрузка серверов и сетевого конфига + // Загрузка серверов, сетевого конфига и UI-настроек useEffect(() => { const load = async () => { setLoading(true); try { - const [serversRes, netRes] = await Promise.all([ + const [serversRes, netRes, uiRes] = await Promise.all([ api.get('/servers'), api.get('/network-config'), + api.get('/ui-settings').catch(() => ({ data: {} })), ]); setServers(Array.isArray(serversRes.data) ? serversRes.data : []); - setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] }); + setNetworkConfig( + netRes.data && typeof netRes.data === 'object' + ? netRes.data + : { gateways: [], tunnelInterfaces: [] } + ); + + const ui = uiRes?.data || {}; + const proto = + String(ui.interfaceSpeedTestProtocol || 'tcp').toLowerCase() === + 'udp' + ? 'udp' + : 'tcp'; + const duration = Math.max( + 1, + Math.min( + 600, + parseInt(ui.interfaceSpeedTestDurationSeconds, 10) || 10 + ) + ); + const cacheMinutes = Math.max( + 0, + parseInt(ui.interfaceSpeedTestCacheMinutes, 10) || 0 + ); + setSpeedSettings({ + protocol: proto, + durationSeconds: duration, + cacheMinutes, + }); } catch (error) { console.error('[MikrotikTools] failed to load initial data', error); notify.error('Не удалось загрузить данные для инструментов MikroTik'); @@ -78,10 +113,33 @@ function MikrotikTools() { ); const interfaces = useMemo( - () => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []), + () => + networkConfig?.tunnelInterfaces && + Array.isArray(networkConfig.tunnelInterfaces) + ? networkConfig.tunnelInterfaces + : [], [networkConfig] ); + const interfacesForServer = useMemo(() => { + if (!serverId || !interfaces.length) return []; + const ids = new Set( + [serverId, currentServer?.id, currentServer?.ip, currentServer?.dns] + .filter(Boolean) + .map(String) + ); + return interfaces.filter( + (i) => i && i.name && (ids.has(String(i.serverId)) || ids.has(String(i.serverId2))) + ); + }, [interfaces, serverId, currentServer]); + + const formatMbps = (bps) => { + if (bps == null || Number.isNaN(bps)) return '—'; + const mbps = bps / 1_000_000; + if (!Number.isFinite(mbps)) return '—'; + return `${mbps.toFixed(2)} Мбит/с`; + }; + const handleSelectGatewayMeta = (meta) => { setGatewayMeta(meta); // Если цель не задана — подставляем IP gateway как target @@ -91,24 +149,23 @@ function MikrotikTools() { }; const handleRunCheck = async () => { - if (!serverId) { - notify.error('Выберите сервер (jumphost)'); - return; - } - - const trimmedTarget = String(target || '').trim(); - if (!trimmedTarget) { - notify.error('Укажите домен или IP назначения'); - return; - } - setRunning(true); setHops([]); setAnalysis([]); setPingResult(null); + setSpeedResult(null); try { if (mode === 'traceroute') { + if (!serverId) { + notify.error('Выберите сервер (jumphost)'); + return; + } + const trimmedTarget = String(target || '').trim(); + if (!trimmedTarget) { + notify.error('Укажите домен или IP назначения'); + return; + } const body = { serverId, target: trimmedTarget, @@ -132,8 +189,16 @@ function MikrotikTools() { const hopsList = Array.isArray(data.hops) ? data.hops : []; setHops(hopsList); setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target)); - } else { - // mode === 'ping' + } else if (mode === 'ping') { + if (!serverId) { + notify.error('Выберите сервер (jumphost)'); + return; + } + const trimmedTarget = String(target || '').trim(); + if (!trimmedTarget) { + notify.error('Укажите домен или IP назначения'); + return; + } const body = { serverId, target: trimmedTarget, @@ -153,6 +218,31 @@ function MikrotikTools() { target: trimmedTarget, ...data, }); + } else if (mode === 'speed') { + if (!serverId) { + notify.error('Выберите сервер (jumphost)'); + return; + } + if (!selectedInterfaceName) { + notify.error('Выберите туннельный интерфейс для замера скорости'); + return; + } + + const body = { + serverId, + interfaceName: selectedInterfaceName, + durationSeconds: speedSettings.durationSeconds, + }; + + const res = await api.post('/traffic/interface-speed-test', body); + const data = res?.data || {}; + if (data.ok === false) { + notify.error( + data.error || 'Замер скорости по интерфейсу завершился с ошибкой' + ); + return; + } + setSpeedResult(data); } } catch (error) { console.error('[MikrotikTools] check failed', error); @@ -169,6 +259,8 @@ function MikrotikTools() { setHops([]); setAnalysis([]); setPingResult(null); + setSpeedResult(null); + setSelectedInterfaceName(''); }; const jumphostServers = useMemo( @@ -402,8 +494,16 @@ function MikrotikTools() {
  • +
  • + +
  • )} @@ -780,6 +952,58 @@ function MikrotikTools() { )} )} + {mode === 'speed' && ( +
    +
    +

    + Скорость по интерфейсу MikroTik +

    +
    +
    + {!speedResult ? ( +
    + Запустите замер — здесь появятся средние скорости приёма и + передачи по выбранному интерфейсу. +
    + ) : ( +
    +
    +
    +
    +
    + Интерфейс +
    +
    + {speedResult.interfaceName || selectedInterfaceName} +
    +
    + Замер за {speedResult.durationSeconds} сек + {speedResult.cached ? ' (из кеша)' : ''} +
    +
    +
    +
    +
    +
    +
    +
    + Суммарная скорость +
    +
    + {formatMbps(speedResult.totalBps)} +
    +
    + RX: {formatMbps(speedResult.rxBps)} · TX:{' '} + {formatMbps(speedResult.txBps)} +
    +
    +
    +
    +
    + )} +
    +
    + )} diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index 06ab687..3100d16 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -75,6 +75,9 @@ export default function SettingsPage() { const [pingServicesServerId, setPingServicesServerId] = useState(''); const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState(''); const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState(''); + const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp'); + const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10'); + const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState(''); const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]); const [trafficJumphosts, setTrafficJumphosts] = useState([]); const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false); @@ -228,6 +231,22 @@ export default function SettingsPage() { ? String(data.pingServicesCacheSeconds) : '' ); + setInterfaceSpeedTestProtocol( + String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() === + 'udp' + ? 'udp' + : 'tcp' + ); + setInterfaceSpeedTestDurationSeconds( + data?.interfaceSpeedTestDurationSeconds != null + ? String(data.interfaceSpeedTestDurationSeconds) + : '10' + ); + setInterfaceSpeedTestCacheMinutes( + data?.interfaceSpeedTestCacheMinutes != null + ? String(data.interfaceSpeedTestCacheMinutes) + : '' + ); const raw = data?.trafficInterfaces; setTrafficInterfacesSelected( Array.isArray(raw) @@ -320,6 +339,16 @@ export default function SettingsPage() { 0, parseInt(pingServicesCacheSeconds, 10) || 0 ), + interfaceSpeedTestProtocol: + interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp', + interfaceSpeedTestDurationSeconds: Math.max( + 1, + parseInt(interfaceSpeedTestDurationSeconds, 10) || 10 + ), + interfaceSpeedTestCacheMinutes: Math.max( + 0, + parseInt(interfaceSpeedTestCacheMinutes, 10) || 0 + ), trafficInterfaces: Array.isArray(trafficInterfacesSelected) ? trafficInterfacesSelected.map((p) => ({ serverKey: p.serverKey, @@ -551,6 +580,54 @@ export default function SettingsPage() { min={0} /> +
    +

    Измерение скорости (интерфейсы)

    +
    +
    + + +
    + Тип теста скорости. Сейчас используется как настройка по + умолчанию для инструментов RouterOS. +
    +
    +
    + +
    +
    + +
    )} diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index 8cacb65..5cbd258 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -28,6 +28,9 @@ export default function SettingsModal({ open, onClose }) { const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState(''); const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState(''); const [serversList, setServersList] = useState([]); + const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp'); + const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10'); + const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState(''); const routerServersForPing = useMemo(() => { return (serversList || []).filter( @@ -53,13 +56,35 @@ export default function SettingsModal({ open, onClose }) { setWsUrl(String(data?.wsUpdateUrl || '')); setBaseAS(String(data?.baseAS || '65001')); setPingDomain(String(data?.pingDomain || '').trim()); - setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''); + setPingCacheMinutes( + data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '' + ); setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || '')); setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || '')); setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web'); setPingServicesServerId(String(data?.pingServicesServerId || '').trim()); setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').trim()); - setPingServicesCacheSeconds(data?.pingServicesCacheSeconds != null ? String(data.pingServicesCacheSeconds) : ''); + setPingServicesCacheSeconds( + data?.pingServicesCacheSeconds != null + ? String(data.pingServicesCacheSeconds) + : '' + ); + setInterfaceSpeedTestProtocol( + String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() === + 'udp' + ? 'udp' + : 'tcp' + ); + setInterfaceSpeedTestDurationSeconds( + data?.interfaceSpeedTestDurationSeconds != null + ? String(data.interfaceSpeedTestDurationSeconds) + : '10' + ); + setInterfaceSpeedTestCacheMinutes( + data?.interfaceSpeedTestCacheMinutes != null + ? String(data.interfaceSpeedTestCacheMinutes) + : '' + ); const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || ''; setEtag(e ? String(e) : ''); setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []); @@ -128,7 +153,20 @@ export default function SettingsModal({ open, onClose }) { pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web', pingServicesServerId: String(pingServicesServerId || '').trim(), pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(), - pingServicesCacheSeconds: Math.max(0, parseInt(pingServicesCacheSeconds, 10) || 0), + pingServicesCacheSeconds: Math.max( + 0, + parseInt(pingServicesCacheSeconds, 10) || 0 + ), + interfaceSpeedTestProtocol: + interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp', + interfaceSpeedTestDurationSeconds: Math.max( + 1, + parseInt(interfaceSpeedTestDurationSeconds, 10) || 10 + ), + interfaceSpeedTestCacheMinutes: Math.max( + 0, + parseInt(interfaceSpeedTestCacheMinutes, 10) || 0 + ), }; const payload = { settings: mergedSettings, @@ -251,6 +289,57 @@ export default function SettingsModal({ open, onClose }) { min={0} /> +
    +
    Измерение скорости (интерфейсы)
    +
    + + +
    + Используется как настройка по умолчанию для инструментов + измерения скорости RouterOS. +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    Пинг сервисов на главной