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. Добавьте интерфейсы в разделе
+ «Сетевые настройки».
+
+ ) : (
+ <>
+
+ setSelectedInterfaceName(e.target.value)}
+ >
+ Не выбран
+ {interfacesForServer.map((iface) => (
+
+ {iface.name} ({iface.localIp} ⇄ {iface.remoteIp})
+
+ ))}
+
+
+
+ Для сопоставления используется имя интерфейса из
+ /network-config.tunnelInterfaces. В RouterOS
+ имя должно совпадать.
+
+ >
+ )}
+
+
+
+
+
+
+
+
Параметры замера
+
+
+
+ Сбросить
+
+
+ {running ? 'Замер…' : 'Запустить замер'}
+
+
+
+
+
+
+
Протокол
+
+
+
+ {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
-
- {
- setMode('speed');
- setHops([]);
- setAnalysis([]);
- setPingResult(null);
- setSpeedResult(null);
- }}
- role="tab"
- >
- Скорость (интерфейс)
-
-
@@ -610,7 +516,6 @@ function MikrotikTools() {
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
- disabled={mode === 'speed'}
/>
- {mode === 'speed' && (
-
-
- Туннельный интерфейс для замера скорости
-
- {interfacesForServer.length === 0 ? (
-
- Для выбранного сервера нет туннельных интерфейсов в
- /network-config.
-
- ) : (
-
- setSelectedInterfaceName(e.target.value)
- }
- >
- Не выбран
- {interfacesForServer.map((iface) => (
-
- {iface.name} ({iface.localIp} ⇄ {iface.remoteIp})
-
- ))}
-
- )}
-
- Измерение скорости выполняется по счётчикам 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)}
-
-
-
-
-
- )}
-
-
- )}