507 lines
18 KiB
React
507 lines
18 KiB
React
import { useCallback, 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 controller = new AbortController();
|
||
const signal = controller.signal;
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [serversRes, netRes, uiRes] = await Promise.all([
|
||
api.get('/servers', { signal }),
|
||
api.get('/network-config', { signal }).catch(() => ({ data: null })),
|
||
api.get('/ui-settings', { signal }).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) || 5
|
||
)
|
||
);
|
||
const cacheMinutes = Math.max(
|
||
0,
|
||
parseInt(ui.interfaceSpeedTestCacheMinutes, 10) || 0
|
||
);
|
||
setSpeedSettings({
|
||
protocol: proto,
|
||
durationSeconds: duration,
|
||
cacheMinutes,
|
||
});
|
||
} catch (e) {
|
||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||
console.error('[InterfaceSpeedTest] load failed', e);
|
||
notify.error('Не удалось загрузить данные для замера скорости');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
return () => controller.abort();
|
||
}, [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 fetchCachedResult = useCallback(async () => {
|
||
if (!serverId || !selectedInterfaceName) {
|
||
setSpeedResult(null);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api.post('/mikrotik/speed-test', {
|
||
serverId,
|
||
interfaceName: selectedInterfaceName,
|
||
cacheOnly: true,
|
||
});
|
||
const data = res?.data || {};
|
||
if (data.ok !== false && !data.notInCache && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
|
||
setSpeedResult(data);
|
||
} else {
|
||
setSpeedResult(null);
|
||
}
|
||
} catch {
|
||
setSpeedResult(null);
|
||
}
|
||
}, [serverId, selectedInterfaceName]);
|
||
|
||
useEffect(() => {
|
||
fetchCachedResult();
|
||
}, [fetchCachedResult]);
|
||
|
||
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,
|
||
forceRefresh: true,
|
||
};
|
||
const res = await api.post('/mikrotik/speed-test', body, {
|
||
timeout: 120000,
|
||
});
|
||
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">
|
||
TCP скорость
|
||
</div>
|
||
<div className="fw-bold">
|
||
Download: {formatMbps(speedResult.tcpDownloadBps)}
|
||
</div>
|
||
<div className="fw-bold mt-1">
|
||
Upload: {formatMbps(speedResult.tcpUploadBps)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|