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
+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>
);
}