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()
|
String(i.name).trim() === String(interfaceName).trim()
|
||||||
);
|
);
|
||||||
if (!iface) {
|
if (!iface) {
|
||||||
throw new Error(
|
return null;
|
||||||
`Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const rx =
|
const rx =
|
||||||
iface['rx-byte'] != null
|
iface['rx-byte'] != null
|
||||||
@@ -253,9 +251,17 @@ async function measureInterfaceSpeed(req, res) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notFoundMessage = `Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`;
|
||||||
|
|
||||||
const start = await readOne();
|
const start = await readOne();
|
||||||
|
if (!start) {
|
||||||
|
return sendError(res, 400, notFoundMessage, 'E_IFACE_NOT_FOUND');
|
||||||
|
}
|
||||||
await sleep(durationSeconds * 1000);
|
await sleep(durationSeconds * 1000);
|
||||||
const end = await readOne();
|
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 rxBytesDelta = Math.max(0, (end.rxBytes || 0) - (start.rxBytes || 0));
|
||||||
const txBytesDelta = Math.max(0, (end.txBytes || 0) - (start.txBytes || 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 CommunitiesManager from './CommunitiesManager';
|
||||||
import NetworkConfigManager from './NetworkConfigManager';
|
import NetworkConfigManager from './NetworkConfigManager';
|
||||||
import MikrotikTools from './MikrotikTools.jsx';
|
import MikrotikTools from './MikrotikTools.jsx';
|
||||||
|
import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import TrafficDashboard from './TrafficDashboard.jsx';
|
import TrafficDashboard from './TrafficDashboard.jsx';
|
||||||
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
||||||
@@ -228,6 +229,7 @@ function MainLayout() {
|
|||||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
||||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
||||||
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
{ 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 }
|
{ 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="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
@@ -553,6 +556,7 @@ function MainLayout() {
|
|||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<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 [target, setTarget] = useState('');
|
||||||
const [maxHops, setMaxHops] = useState(30);
|
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 [running, setRunning] = useState(false);
|
||||||
const [hops, setHops] = useState([]);
|
const [hops, setHops] = useState([]);
|
||||||
const [analysis, setAnalysis] = useState([]);
|
const [analysis, setAnalysis] = useState([]);
|
||||||
const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis'
|
const [tracerouteTab, setTracerouteTab] = useState('table'); // 'table' | 'analysis'
|
||||||
const [pingResult, setPingResult] = useState(null);
|
const [pingResult, setPingResult] = useState(null);
|
||||||
const [speedResult, setSpeedResult] = useState(null);
|
|
||||||
const [useDns, setUseDns] = useState(true);
|
const [useDns, setUseDns] = useState(true);
|
||||||
|
|
||||||
const [serverSearch, setServerSearch] = useState('');
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
const [selectedInterfaceName, setSelectedInterfaceName] = useState('');
|
|
||||||
const [speedSettings, setSpeedSettings] = useState({
|
|
||||||
protocol: 'tcp',
|
|
||||||
durationSeconds: 10,
|
|
||||||
cacheMinutes: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Загрузка серверов, сетевого конфига и UI-настроек
|
// Загрузка серверов и сетевого конфига
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [serversRes, netRes, uiRes] = await Promise.all([
|
const [serversRes, netRes] = await Promise.all([
|
||||||
api.get('/servers'),
|
api.get('/servers'),
|
||||||
api.get('/network-config'),
|
api.get('/network-config'),
|
||||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
|
||||||
]);
|
]);
|
||||||
setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
|
setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
|
||||||
setNetworkConfig(
|
setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] });
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('[MikrotikTools] failed to load initial data', error);
|
console.error('[MikrotikTools] failed to load initial data', error);
|
||||||
notify.error('Не удалось загрузить данные для инструментов MikroTik');
|
notify.error('Не удалось загрузить данные для инструментов MikroTik');
|
||||||
@@ -113,21 +78,10 @@ function MikrotikTools() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const interfaces = useMemo(
|
const interfaces = useMemo(
|
||||||
() =>
|
() => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
|
||||||
networkConfig?.tunnelInterfaces &&
|
|
||||||
Array.isArray(networkConfig.tunnelInterfaces)
|
|
||||||
? networkConfig.tunnelInterfaces
|
|
||||||
: [],
|
|
||||||
[networkConfig]
|
[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) => {
|
const handleSelectGatewayMeta = (meta) => {
|
||||||
setGatewayMeta(meta);
|
setGatewayMeta(meta);
|
||||||
// Если цель не задана — подставляем IP gateway как target
|
// Если цель не задана — подставляем IP gateway как target
|
||||||
@@ -141,7 +95,6 @@ function MikrotikTools() {
|
|||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
setSpeedResult(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === 'traceroute') {
|
if (mode === 'traceroute') {
|
||||||
@@ -206,31 +159,6 @@ function MikrotikTools() {
|
|||||||
target: trimmedTarget,
|
target: trimmedTarget,
|
||||||
...data,
|
...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) {
|
} catch (error) {
|
||||||
console.error('[MikrotikTools] check failed', error);
|
console.error('[MikrotikTools] check failed', error);
|
||||||
@@ -247,8 +175,6 @@ function MikrotikTools() {
|
|||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
setSpeedResult(null);
|
|
||||||
setSelectedInterfaceName('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const jumphostServers = useMemo(
|
const jumphostServers = useMemo(
|
||||||
@@ -505,7 +431,6 @@ function MikrotikTools() {
|
|||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
setSpeedResult(null);
|
|
||||||
}}
|
}}
|
||||||
role="tab"
|
role="tab"
|
||||||
>
|
>
|
||||||
@@ -523,31 +448,12 @@ function MikrotikTools() {
|
|||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
setSpeedResult(null);
|
|
||||||
}}
|
}}
|
||||||
role="tab"
|
role="tab"
|
||||||
>
|
>
|
||||||
Ping
|
Ping
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
|
<button type="button" className="btn btn-outline-secondary" onClick={handleReset} disabled={running}>
|
||||||
@@ -610,7 +516,6 @@ function MikrotikTools() {
|
|||||||
id="useDnsToggle"
|
id="useDnsToggle"
|
||||||
checked={useDns}
|
checked={useDns}
|
||||||
onChange={(e) => setUseDns(e.target.checked)}
|
onChange={(e) => setUseDns(e.target.checked)}
|
||||||
disabled={mode === 'speed'}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
className="form-check-label small"
|
className="form-check-label small"
|
||||||
@@ -620,40 +525,6 @@ function MikrotikTools() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -955,58 +826,6 @@ function MikrotikTools() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user