feat(TrafficStats, Settings): add interface speed test functionality with configurable settings for protocol, duration, and caching; enhance UI for speed test integration
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s

This commit is contained in:
2026-02-17 22:25:01 +07:00
parent baa2fc8044
commit d9667a06e6
5 changed files with 639 additions and 27 deletions
+248 -24
View File
@@ -40,28 +40,63 @@ function MikrotikTools() {
const [target, setTarget] = useState('');
const [maxHops, setMaxHops] = useState(30);
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping'
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' | 'speed'
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] = await Promise.all([
const [serversRes, netRes, uiRes] = 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: [] });
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 (error) {
console.error('[MikrotikTools] failed to load initial data', error);
notify.error('Не удалось загрузить данные для инструментов MikroTik');
@@ -78,10 +113,33 @@ function MikrotikTools() {
);
const interfaces = useMemo(
() => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
() =>
networkConfig?.tunnelInterfaces &&
Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [],
[networkConfig]
);
const interfacesForServer = useMemo(() => {
if (!serverId || !interfaces.length) return [];
const ids = new Set(
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
.filter(Boolean)
.map(String)
);
return interfaces.filter(
(i) => i && i.name && (ids.has(String(i.serverId)) || ids.has(String(i.serverId2)))
);
}, [interfaces, serverId, currentServer]);
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
@@ -91,24 +149,23 @@ function MikrotikTools() {
};
const handleRunCheck = async () => {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
setRunning(true);
setHops([]);
setAnalysis([]);
setPingResult(null);
setSpeedResult(null);
try {
if (mode === 'traceroute') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
@@ -132,8 +189,16 @@ function MikrotikTools() {
const hopsList = Array.isArray(data.hops) ? data.hops : [];
setHops(hopsList);
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
} else {
// mode === 'ping'
} else if (mode === 'ping') {
if (!serverId) {
notify.error('Выберите сервер (jumphost)');
return;
}
const trimmedTarget = String(target || '').trim();
if (!trimmedTarget) {
notify.error('Укажите домен или IP назначения');
return;
}
const body = {
serverId,
target: trimmedTarget,
@@ -153,6 +218,31 @@ 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);
@@ -169,6 +259,8 @@ function MikrotikTools() {
setHops([]);
setAnalysis([]);
setPingResult(null);
setSpeedResult(null);
setSelectedInterfaceName('');
};
const jumphostServers = useMemo(
@@ -402,8 +494,16 @@ function MikrotikTools() {
<li className="nav-item">
<button
type="button"
className={`nav-link ${mode === 'traceroute' ? 'active' : ''}`}
onClick={() => { setMode('traceroute'); setHops([]); setAnalysis([]); setPingResult(null); }}
className={`nav-link ${
mode === 'traceroute' ? 'active' : ''
}`}
onClick={() => {
setMode('traceroute');
setHops([]);
setAnalysis([]);
setPingResult(null);
setSpeedResult(null);
}}
role="tab"
>
Traceroute
@@ -412,13 +512,39 @@ function MikrotikTools() {
<li className="nav-item">
<button
type="button"
className={`nav-link ${mode === 'ping' ? 'active' : ''}`}
onClick={() => { setMode('ping'); setHops([]); setAnalysis([]); setPingResult(null); }}
className={`nav-link ${
mode === 'ping' ? 'active' : ''
}`}
onClick={() => {
setMode('ping');
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}>
@@ -475,10 +601,56 @@ function MikrotikTools() {
)}
<div className="col-12">
<div className="form-check form-switch form-check-inline">
<input className="form-check-input" type="checkbox" id="useDnsToggle" checked={useDns} onChange={(e) => setUseDns(e.target.checked)} />
<label className="form-check-label small" htmlFor="useDnsToggle">{useDns ? 'DNS+IP' : 'Только IP'}</label>
<input
className="form-check-input"
type="checkbox"
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
disabled={mode === 'speed'}
/>
<label
className="form-check-label small"
htmlFor="useDnsToggle"
>
{useDns ? 'DNS+IP' : 'Только IP'}
</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>
@@ -780,6 +952,58 @@ 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>
+77
View File
@@ -75,6 +75,9 @@ export default function SettingsPage() {
const [pingServicesServerId, setPingServicesServerId] = useState('');
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]);
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
@@ -228,6 +231,22 @@ export default function SettingsPage() {
? String(data.pingServicesCacheSeconds)
: ''
);
setInterfaceSpeedTestProtocol(
String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
'udp'
? 'udp'
: 'tcp'
);
setInterfaceSpeedTestDurationSeconds(
data?.interfaceSpeedTestDurationSeconds != null
? String(data.interfaceSpeedTestDurationSeconds)
: '10'
);
setInterfaceSpeedTestCacheMinutes(
data?.interfaceSpeedTestCacheMinutes != null
? String(data.interfaceSpeedTestCacheMinutes)
: ''
);
const raw = data?.trafficInterfaces;
setTrafficInterfacesSelected(
Array.isArray(raw)
@@ -320,6 +339,16 @@ export default function SettingsPage() {
0,
parseInt(pingServicesCacheSeconds, 10) || 0
),
interfaceSpeedTestProtocol:
interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp',
interfaceSpeedTestDurationSeconds: Math.max(
1,
parseInt(interfaceSpeedTestDurationSeconds, 10) || 10
),
interfaceSpeedTestCacheMinutes: Math.max(
0,
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
),
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
? trafficInterfacesSelected.map((p) => ({
serverKey: p.serverKey,
@@ -551,6 +580,54 @@ export default function SettingsPage() {
min={0}
/>
</div>
<div className="col-12 mt-3">
<h4 className="subheader">Измерение скорости (интерфейсы)</h4>
</div>
<div className="col-md-4">
<label className="form-label">Протокол измерения</label>
<select
className="form-select"
value={interfaceSpeedTestProtocol}
onChange={(e) =>
setInterfaceSpeedTestProtocol(e.target.value)
}
disabled={saving}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<div className="form-text">
Тип теста скорости. Сейчас используется как настройка по
умолчанию для инструментов RouterOS.
</div>
</div>
<div className="col-md-4">
<FormField
label="Время замера (сек)"
name="interfaceSpeedTestDurationSeconds"
type="number"
value={interfaceSpeedTestDurationSeconds}
onChange={setInterfaceSpeedTestDurationSeconds}
placeholder="10"
helpText="Интервал, за который измеряется средняя скорость по интерфейсу."
disabled={saving}
min={1}
max={600}
/>
</div>
<div className="col-md-4">
<FormField
label="Кеш результата замера (мин)"
name="interfaceSpeedTestCacheMinutes"
type="number"
value={interfaceSpeedTestCacheMinutes}
onChange={setInterfaceSpeedTestCacheMinutes}
placeholder="0"
helpText="0 — без кеша. При значении больше 0 результаты замеров скорости по интерфейсу кешируются в S3."
disabled={saving}
min={0}
/>
</div>
</div>
</>
)}
+92 -3
View File
@@ -28,6 +28,9 @@ export default function SettingsModal({ open, onClose }) {
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [serversList, setServersList] = useState([]);
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
const routerServersForPing = useMemo(() => {
return (serversList || []).filter(
@@ -53,13 +56,35 @@ export default function SettingsModal({ open, onClose }) {
setWsUrl(String(data?.wsUpdateUrl || ''));
setBaseAS(String(data?.baseAS || '65001'));
setPingDomain(String(data?.pingDomain || '').trim());
setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '');
setPingCacheMinutes(
data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''
);
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').trim());
setPingServicesCacheSeconds(data?.pingServicesCacheSeconds != null ? String(data.pingServicesCacheSeconds) : '');
setPingServicesCacheSeconds(
data?.pingServicesCacheSeconds != null
? String(data.pingServicesCacheSeconds)
: ''
);
setInterfaceSpeedTestProtocol(
String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
'udp'
? 'udp'
: 'tcp'
);
setInterfaceSpeedTestDurationSeconds(
data?.interfaceSpeedTestDurationSeconds != null
? String(data.interfaceSpeedTestDurationSeconds)
: '10'
);
setInterfaceSpeedTestCacheMinutes(
data?.interfaceSpeedTestCacheMinutes != null
? String(data.interfaceSpeedTestCacheMinutes)
: ''
);
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
@@ -128,7 +153,20 @@ export default function SettingsModal({ open, onClose }) {
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
pingServicesServerId: String(pingServicesServerId || '').trim(),
pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(),
pingServicesCacheSeconds: Math.max(0, parseInt(pingServicesCacheSeconds, 10) || 0),
pingServicesCacheSeconds: Math.max(
0,
parseInt(pingServicesCacheSeconds, 10) || 0
),
interfaceSpeedTestProtocol:
interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp',
interfaceSpeedTestDurationSeconds: Math.max(
1,
parseInt(interfaceSpeedTestDurationSeconds, 10) || 10
),
interfaceSpeedTestCacheMinutes: Math.max(
0,
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
),
};
const payload = {
settings: mergedSettings,
@@ -251,6 +289,57 @@ export default function SettingsModal({ open, onClose }) {
min={0}
/>
<div className="mt-3 pt-3 border-top">
<h6 className="mb-2">Измерение скорости (интерфейсы)</h6>
<div className="mb-2">
<label className="form-label small">Протокол измерения</label>
<select
className="form-select form-select-sm"
value={interfaceSpeedTestProtocol}
onChange={(e) =>
setInterfaceSpeedTestProtocol(e.target.value)
}
disabled={loading || saving}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<div className="form-text small">
Используется как настройка по умолчанию для инструментов
измерения скорости RouterOS.
</div>
</div>
<div className="row g-2">
<div className="col-md-6">
<FormField
label="Время замера (сек)"
name="interfaceSpeedTestDurationSeconds"
type="number"
value={interfaceSpeedTestDurationSeconds}
onChange={setInterfaceSpeedTestDurationSeconds}
placeholder="10"
helpText="Интервал, за который измеряется средняя скорость по интерфейсу."
disabled={loading || saving}
min={1}
max={600}
/>
</div>
<div className="col-md-6">
<FormField
label="Кеш результата замера (мин)"
name="interfaceSpeedTestCacheMinutes"
type="number"
value={interfaceSpeedTestCacheMinutes}
onChange={setInterfaceSpeedTestCacheMinutes}
placeholder="0"
helpText="0 — без кеша. При значении больше 0 результаты замеров скорости по интерфейсу кешируются в S3."
disabled={loading || saving}
min={0}
/>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-top">
<h6 className="mb-2">Пинг сервисов на главной</h6>
<div className="mb-2">