feat(TrafficRoutes, App): implement interface speed test route and UI integration; update error handling for interface measurement
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s

This commit is contained in:
2026-02-17 22:41:02 +07:00
parent ab4ba37d53
commit d29f1aef51
4 changed files with 488 additions and 189 deletions
+9 -3
View File
@@ -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));
+4
View File
@@ -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() {
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
@@ -553,6 +556,7 @@ function MainLayout() {
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
+470
View File
@@ -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 (
<div className="page">
<PageHeader
title="Скорость интерфейсов MikroTik"
icon={<IconTopologyRing size={24} />}
meta={`Текущий сервер: ${currentServerLabel}`}
/>
<div className="row g-2">
<div className="col-md-5">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<div>
<h3 className="card-title mb-0">Серверы (jumphost / home)</h3>
<p className="text-muted small mb-0 mt-0">
Выберите роутер с настроенным MikroTik API
</p>
</div>
<div className="input-icon input-icon-sm" style={{ minWidth: 200 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={14} className="text-muted" />
</span>
</div>
</div>
<div className="card-body py-2">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code> или <code>home</code>.
Добавьте их в разделе «Серверы», чтобы использовать замер
скорости.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр
поиска.
</div>
) : (
<div className="row row-cards g-1">
{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 (
<div
className="col-12 col-sm-6 col-md-6"
key={id}
>
<div
className={`card card-sm h-100 cursor-pointer ${
isActive ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => setServerId(id)}
>
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
<span
className="avatar avatar-sm bg-primary-lt"
title={s.country || ''}
>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div
className="fw-semibold text-truncate small"
title={s.dns || s.ip || id}
>
{s.dns || s.ip || id}
</div>
<div
className="text-muted small text-truncate"
title={line}
>
{line ||
[s.country, s.provider]
.filter(Boolean)
.join(' · ') ||
'—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2 mt-2">
<span className="badge bg-primary-lt text-primary">
Сервер: {currentServerLabel}
</span>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">Интерфейсы туннелей</h3>
</div>
<div className="card-body py-2">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек</div>
) : !serverId ? (
<div className="text-muted small">
Выберите сервер слева, затем интерфейс для замера скорости.
</div>
) : interfacesForServer.length === 0 ? (
<div className="text-muted small">
Для выбранного сервера нет туннельных интерфейсов в
<code> /network-config</code>. Добавьте интерфейсы в разделе
«Сетевые настройки».
</div>
) : (
<>
<div className="mb-2">
<select
className="form-select form-select-sm"
value={selectedInterfaceName}
onChange={(e) => setSelectedInterfaceName(e.target.value)}
>
<option value="">Не выбран</option>
{interfacesForServer.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.localIp} {iface.remoteIp})
</option>
))}
</select>
</div>
<div className="text-muted small">
Для сопоставления используется имя интерфейса из
<code> /network-config.tunnelInterfaces</code>. В RouterOS
имя должно совпадать.
</div>
</>
)}
</div>
</div>
</div>
<div className="col-md-7">
<div className="card mb-2">
<div className="card-header py-2 d-flex align-items-center justify-content-between">
<h3 className="card-title mb-0">Параметры замера</h3>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={handleReset}
disabled={running}
>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleRunTest}
disabled={running || !serverId || !selectedInterfaceName}
>
{running ? 'Замер…' : 'Запустить замер'}
</button>
</div>
</div>
<div className="card-body py-2">
<div className="row g-2">
<div className="col-md-4">
<label className="form-label small mb-0">Протокол</label>
<div className="d-flex align-items-center gap-1">
<IconNetwork size={16} className="text-muted" />
<span className="small text-uppercase">
{speedSettings.protocol === 'udp' ? 'UDP' : 'TCP'}
</span>
</div>
<div className="text-muted small">
Значение берётся из настроек интерфейса.
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Время замера (сек)
</label>
<div className="fw-semibold">
{speedSettings.durationSeconds}
</div>
</div>
<div className="col-md-4">
<label className="form-label small mb-0">
Кеш результата (мин)
</label>
<div className="fw-semibold">
{speedSettings.cacheMinutes}
</div>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header py-2">
<h3 className="card-title mb-0">
Скорость по интерфейсу MikroTik
</h3>
</div>
<div className="card-body">
{!speedResult ? (
<div className="text-muted small">
Выберите сервер и интерфейс, затем запустите замер. Средние
скорости приёма и передачи появятся здесь.
</div>
) : (
<div className="row g-3">
<div className="col-12 col-md-6">
<div className="card bg-blue-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">Интерфейс</div>
<div className="fw-semibold">
{speedResult.interfaceName || selectedInterfaceName}
</div>
<div className="text-muted small mt-1">
Замер за {speedResult.durationSeconds} сек
{speedResult.cached ? ' (из кеша)' : ''}
</div>
</div>
</div>
</div>
<div className="col-12 col-md-6">
<div className="card bg-azure-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">
Суммарная скорость
</div>
<div className="fw-bold fs-4">
{formatMbps(speedResult.totalBps)}
</div>
<div className="text-muted small">
RX: {formatMbps(speedResult.rxBps)} · TX:{' '}
{formatMbps(speedResult.txBps)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+5 -186
View File
@@ -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
</button>
</li>
<li className="nav-item">
<button
type="button"
className={`nav-link ${
mode === 'speed' ? 'active' : ''
}`}
onClick={() => {
setMode('speed');
setHops([]);
setAnalysis([]);
setPingResult(null);
setSpeedResult(null);
}}
role="tab"
>
Скорость (интерфейс)
</button>
</li>
</ul>
<div className="btn-list">
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
@@ -610,7 +516,6 @@ function MikrotikTools() {
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
disabled={mode === 'speed'}
/>
<label
className="form-check-label small"
@@ -620,40 +525,6 @@ function MikrotikTools() {
</label>
</div>
</div>
{mode === 'speed' && (
<div className="col-12 mt-2">
<label className="form-label small mb-1">
Туннельный интерфейс для замера скорости
</label>
{interfacesForServer.length === 0 ? (
<div className="text-muted small">
Для выбранного сервера нет туннельных интерфейсов в
/network-config.
</div>
) : (
<select
className="form-select form-select-sm"
value={selectedInterfaceName}
onChange={(e) =>
setSelectedInterfaceName(e.target.value)
}
>
<option value="">Не выбран</option>
{interfacesForServer.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.localIp} {iface.remoteIp})
</option>
))}
</select>
)}
<div className="form-text small">
Измерение скорости выполняется по счётчикам MikroTik
(rx/tx-byte) на выбранном интерфейсе в течение{' '}
{speedSettings.durationSeconds} сек. Результат может
кешироваться до {speedSettings.cacheMinutes} мин.
</div>
</div>
)}
</div>
)}
</div>
@@ -955,58 +826,6 @@ function MikrotikTools() {
)}
</div>
)}
{mode === 'speed' && (
<div className="card mb-2">
<div className="card-header py-2">
<h3 className="card-title mb-0">
Скорость по интерфейсу MikroTik
</h3>
</div>
<div className="card-body">
{!speedResult ? (
<div className="text-muted small">
Запустите замер здесь появятся средние скорости приёма и
передачи по выбранному интерфейсу.
</div>
) : (
<div className="row g-2">
<div className="col-12 col-md-6">
<div className="card bg-blue-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">
Интерфейс
</div>
<div className="fw-semibold">
{speedResult.interfaceName || selectedInterfaceName}
</div>
<div className="text-muted small mt-1">
Замер за {speedResult.durationSeconds} сек
{speedResult.cached ? ' (из кеша)' : ''}
</div>
</div>
</div>
</div>
<div className="col-12 col-md-6">
<div className="card bg-azure-lt">
<div className="card-body py-2">
<div className="text-muted small mb-1">
Суммарная скорость
</div>
<div className="fw-bold fs-4">
{formatMbps(speedResult.totalBps)}
</div>
<div className="text-muted small">
RX: {formatMbps(speedResult.rxBps)} · TX:{' '}
{formatMbps(speedResult.txBps)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>