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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user