diff --git a/backend/routes/trafficRoutes.js b/backend/routes/trafficRoutes.js index 7ff1579..cb8a959 100644 --- a/backend/routes/trafficRoutes.js +++ b/backend/routes/trafficRoutes.js @@ -235,9 +235,7 @@ async function measureInterfaceSpeed(req, res) { String(i.name).trim() === String(interfaceName).trim() ); if (!iface) { - throw new Error( - `Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})` - ); + return null; } const rx = iface['rx-byte'] != null @@ -253,9 +251,17 @@ async function measureInterfaceSpeed(req, res) { }; } + const notFoundMessage = `Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`; + const start = await readOne(); + if (!start) { + return sendError(res, 400, notFoundMessage, 'E_IFACE_NOT_FOUND'); + } await sleep(durationSeconds * 1000); const end = await readOne(); + if (!end) { + return sendError(res, 400, notFoundMessage, 'E_IFACE_NOT_FOUND'); + } const rxBytesDelta = Math.max(0, (end.rxBytes || 0) - (start.rxBytes || 0)); const txBytesDelta = Math.max(0, (end.txBytes || 0) - (start.txBytes || 0)); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5e1e733..d410d84 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -36,6 +36,7 @@ import BillingManager from './BillingManager'; import CommunitiesManager from './CommunitiesManager'; import NetworkConfigManager from './NetworkConfigManager'; import MikrotikTools from './MikrotikTools.jsx'; +import InterfaceSpeedTest from './InterfaceSpeedTest.jsx'; import Dashboard from './Dashboard'; import TrafficDashboard from './TrafficDashboard.jsx'; import NetworkMapDashboard from './NetworkMapDashboard.jsx'; @@ -228,6 +229,7 @@ function MainLayout() { { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }, { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }, { id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork }, + { id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork }, { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork } ] }, @@ -395,6 +397,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> @@ -553,6 +556,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/InterfaceSpeedTest.jsx b/frontend/src/InterfaceSpeedTest.jsx new file mode 100644 index 0000000..8983d78 --- /dev/null +++ b/frontend/src/InterfaceSpeedTest.jsx @@ -0,0 +1,470 @@ +import { useEffect, useMemo, useState } from 'react'; +import api from './lib/api.js'; +import { useNotify } from './components/NotifyProvider.jsx'; +import PageHeader from './components/PageHeader.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; +import { countryToFlag } from './utils/serverUtils.js'; +import { + IconTopologyRing, + IconNetwork, + IconSearch, + IconRefresh, +} from '@tabler/icons-react'; + +export default function InterfaceSpeedTest() { + const notify = useNotify(); + + const [servers, setServers] = useState([]); + const [networkConfig, setNetworkConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [serverSearch, setServerSearch] = useState(''); + + const [serverId, setServerId] = useState(''); + const [selectedInterfaceName, setSelectedInterfaceName] = useState(''); + const [running, setRunning] = useState(false); + const [speedResult, setSpeedResult] = useState(null); + const [speedSettings, setSpeedSettings] = useState({ + protocol: 'tcp', + durationSeconds: 10, + cacheMinutes: 0, + }); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const [serversRes, netRes, uiRes] = await Promise.all([ + api.get('/servers'), + api.get('/network-config').catch(() => ({ data: null })), + api.get('/ui-settings').catch(() => ({ data: {} })), + ]); + + setServers(Array.isArray(serversRes?.data) ? serversRes.data : []); + 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 (e) { + console.error('[InterfaceSpeedTest] load failed', e); + notify.error('Не удалось загрузить данные для замера скорости'); + } finally { + setLoading(false); + } + }; + load(); + }, [notify]); + + const gateways = useMemo( + () => + networkConfig?.gateways && Array.isArray(networkConfig.gateways) + ? networkConfig.gateways + : [], + [networkConfig] + ); + + const tunnelInterfaces = useMemo( + () => + networkConfig?.tunnelInterfaces && + Array.isArray(networkConfig.tunnelInterfaces) + ? networkConfig.tunnelInterfaces + : [], + [networkConfig] + ); + + const jumphostServers = useMemo( + () => + (servers || []).filter((s) => + ['jumphost', 'home'].includes( + String(s.type || '').toLowerCase() + ) + ), + [servers] + ); + + const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString(); + + const jumphostInputServers = useMemo( + () => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })), + [jumphostServers] + ); + + const visibleJumphostServers = useMemo(() => { + if (!serverSearch.trim()) return jumphostInputServers; + const q = serverSearch.trim().toLowerCase(); + return jumphostInputServers.filter((s) => { + const fields = [ + s.id, + s.ip, + s.dns, + s.extIp, + s.internalIp, + s.country, + s.provider, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return fields.includes(q); + }); + }, [jumphostInputServers, serverSearch]); + + const currentServer = useMemo( + () => jumphostServers.find((s) => makeServerId(s) === serverId) || null, + [jumphostServers, serverId] + ); + + const interfacesForServer = useMemo(() => { + if (!serverId || !tunnelInterfaces.length) return []; + const ids = new Set( + [serverId, currentServer?.id, currentServer?.ip, currentServer?.dns] + .filter(Boolean) + .map(String) + ); + return tunnelInterfaces.filter( + (i) => + i && + i.name && + (ids.has(String(i.serverId)) || ids.has(String(i.serverId2))) + ); + }, [tunnelInterfaces, serverId, currentServer]); + + const currentServerLabel = + currentServer?.dns || + currentServer?.ip || + makeServerId(currentServer || {}) || + 'Не выбран'; + + const isInitialLoading = loading && servers.length === 0; + + 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 handleRunTest = async () => { + if (!serverId) { + notify.error('Выберите сервер (jumphost/home) для замера скорости'); + return; + } + if (!selectedInterfaceName) { + notify.error('Выберите туннельный интерфейс для замера скорости'); + return; + } + + setRunning(true); + setSpeedResult(null); + + try { + 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 (e) { + console.error('[InterfaceSpeedTest] test failed', e); + notify.error(e?.response?.data?.message || e?.message || 'Ошибка замера скорости'); + } finally { + setRunning(false); + } + }; + + const handleReset = () => { + setServerId(''); + setSelectedInterfaceName(''); + setSpeedResult(null); + }; + + return ( +
+ } + meta={`Текущий сервер: ${currentServerLabel}`} + /> + +
+
+
+
+
+

Серверы (jumphost / home)

+

+ Выберите роутер с настроенным MikroTik API +

+
+
+ setServerSearch(e.target.value)} + /> + + + +
+
+
+ {isInitialLoading ? ( + + ) : jumphostServers.length === 0 ? ( +
+ Нет серверов типа jumphost или home. + Добавьте их в разделе «Серверы», чтобы использовать замер + скорости. +
+ ) : visibleJumphostServers.length === 0 ? ( +
+ По вашему запросу серверы не найдены. Попробуйте изменить фильтр + поиска. +
+ ) : ( +
+ {visibleJumphostServers.map((s) => { + const id = s.id; + const isActive = id === serverId; + const flag = s.country ? countryToFlag(s.country) : ''; + const line = [s.dns, s.ip].filter(Boolean).join(' · '); + return ( +
+
setServerId(id)} + > +
+ + {flag || s.ip?.slice(0, 2) || '?'} + +
+
+ {s.dns || s.ip || id} +
+
+ {line || + [s.country, s.provider] + .filter(Boolean) + .join(' · ') || + '—'} +
+
+
+
+
+ ); + })} +
+ )} +
+ + Сервер: {currentServerLabel} + +
+
+
+ +
+
+

Интерфейсы туннелей

+
+
+ {loading && !networkConfig ? ( +
Загрузка сетевых настроек…
+ ) : !serverId ? ( +
+ Выберите сервер слева, затем интерфейс для замера скорости. +
+ ) : interfacesForServer.length === 0 ? ( +
+ Для выбранного сервера нет туннельных интерфейсов в + /network-config. Добавьте интерфейсы в разделе + «Сетевые настройки». +
+ ) : ( + <> +
+ +
+
+ Для сопоставления используется имя интерфейса из + /network-config.tunnelInterfaces. В RouterOS + имя должно совпадать. +
+ + )} +
+
+
+ +
+
+
+

Параметры замера

+
+ + +
+
+
+
+
+ +
+ + + {speedSettings.protocol === 'udp' ? 'UDP' : 'TCP'} + +
+
+ Значение берётся из настроек интерфейса. +
+
+
+ +
+ {speedSettings.durationSeconds} +
+
+
+ +
+ {speedSettings.cacheMinutes} +
+
+
+
+
+ +
+
+

+ Скорость по интерфейсу 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/MikrotikTools.jsx b/frontend/src/MikrotikTools.jsx index 29c2646..12aa5a8 100644 --- a/frontend/src/MikrotikTools.jsx +++ b/frontend/src/MikrotikTools.jsx @@ -40,63 +40,28 @@ function MikrotikTools() { const [target, setTarget] = useState(''); const [maxHops, setMaxHops] = useState(30); - const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' | 'speed' + const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' 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, uiRes] = await Promise.all([ + const [serversRes, netRes] = 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: [] } - ); - - 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, - }); + setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] }); } catch (error) { console.error('[MikrotikTools] failed to load initial data', error); notify.error('Не удалось загрузить данные для инструментов MikroTik'); @@ -113,21 +78,10 @@ function MikrotikTools() { ); const interfaces = useMemo( - () => - networkConfig?.tunnelInterfaces && - Array.isArray(networkConfig.tunnelInterfaces) - ? networkConfig.tunnelInterfaces - : [], + () => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []), [networkConfig] ); - 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 @@ -141,7 +95,6 @@ function MikrotikTools() { setHops([]); setAnalysis([]); setPingResult(null); - setSpeedResult(null); try { if (mode === 'traceroute') { @@ -206,31 +159,6 @@ 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); @@ -247,8 +175,6 @@ function MikrotikTools() { setHops([]); setAnalysis([]); setPingResult(null); - setSpeedResult(null); - setSelectedInterfaceName(''); }; const jumphostServers = useMemo( @@ -505,7 +431,6 @@ function MikrotikTools() { setHops([]); setAnalysis([]); setPingResult(null); - setSpeedResult(null); }} role="tab" > @@ -523,31 +448,12 @@ function MikrotikTools() { setHops([]); setAnalysis([]); setPingResult(null); - setSpeedResult(null); }} role="tab" > Ping -
  • - -
  • - {mode === 'speed' && ( -
    - - {interfacesForServer.length === 0 ? ( -
    - Для выбранного сервера нет туннельных интерфейсов в - /network-config. -
    - ) : ( - - )} -
    - Измерение скорости выполняется по счётчикам MikroTik - (rx/tx-byte) на выбранном интерфейсе в течение{' '} - {speedSettings.durationSeconds} сек. Результат может - кешироваться до {speedSettings.cacheMinutes} мин. -
    -
    - )} )} @@ -955,58 +826,6 @@ function MikrotikTools() { )} )} - {mode === 'speed' && ( -
    -
    -

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

    -
    -
    - {!speedResult ? ( -
    - Запустите замер — здесь появятся средние скорости приёма и - передачи по выбранному интерфейсу. -
    - ) : ( -
    -
    -
    -
    -
    - Интерфейс -
    -
    - {speedResult.interfaceName || selectedInterfaceName} -
    -
    - Замер за {speedResult.durationSeconds} сек - {speedResult.cached ? ' (из кеша)' : ''} -
    -
    -
    -
    -
    -
    -
    -
    - Суммарная скорость -
    -
    - {formatMbps(speedResult.totalBps)} -
    -
    - RX: {formatMbps(speedResult.rxBps)} · TX:{' '} - {formatMbps(speedResult.txBps)} -
    -
    -
    -
    -
    - )} -
    -
    - )}