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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
This commit is contained in:
@@ -7,6 +7,7 @@ const { sendError } = require('../middleware/errorHandler');
|
|||||||
const { decrypt } = require('../utils/encryption');
|
const { decrypt } = require('../utils/encryption');
|
||||||
const { readServersFromS3 } = require('./serversRoutes');
|
const { readServersFromS3 } = require('./serversRoutes');
|
||||||
const { createRosClient } = require('../services/mikrotikApplyService');
|
const { createRosClient } = require('../services/mikrotikApplyService');
|
||||||
|
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||||
|
|
||||||
/** Получить MikroTik credentials из сервера (jumphost или home) */
|
/** Получить MikroTik credentials из сервера (jumphost или home) */
|
||||||
function getMikrotikCredentials(server) {
|
function getMikrotikCredentials(server) {
|
||||||
@@ -27,6 +28,36 @@ function getMikrotikCredentials(server) {
|
|||||||
return { host, port: restPort, user, password, secure: false };
|
return { host, port: restPort, user, password, secure: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI-настройки (общие с остальным UI)
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
|
||||||
|
|
||||||
|
async function loadUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interfaceSpeedCacheKey(serverId, interfaceName) {
|
||||||
|
const key = `${serverId || ''}|${interfaceName || ''}`;
|
||||||
|
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
|
||||||
|
return (
|
||||||
|
INTERFACE_SPEED_CACHE_PREFIX +
|
||||||
|
String(key)
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
|
||||||
|
'.json'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/traffic/interface-stats
|
* GET /api/traffic/interface-stats
|
||||||
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
|
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
|
||||||
@@ -93,6 +124,192 @@ async function getInterfaceStats(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/traffic/interface-speed-test
|
||||||
|
* Замеряет среднюю скорость по выбранному интерфейсу RouterOS на jumphost/home.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - serverId (обязательный) — id/dns/ip сервера из servers.json
|
||||||
|
* - interfaceName (обязательный) — имя интерфейса в RouterOS (как в /interface)
|
||||||
|
* - durationSeconds? — время замера (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек)
|
||||||
|
*
|
||||||
|
* Использует счётчики rx-byte/tx-byte: берёт значение в начале и в конце интервала
|
||||||
|
* и делит дельту на время замера.
|
||||||
|
*/
|
||||||
|
async function measureInterfaceSpeed(req, res) {
|
||||||
|
try {
|
||||||
|
const { serverId, interfaceName } = req.body || {};
|
||||||
|
let { durationSeconds } = req.body || {};
|
||||||
|
|
||||||
|
if (!serverId || !interfaceName) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
'serverId и interfaceName обязательны',
|
||||||
|
'E_BAD_REQUEST'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiSettings = await loadUiSettings();
|
||||||
|
const defaultDuration = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
600,
|
||||||
|
parseInt(uiSettings.interfaceSpeedTestDurationSeconds, 10) || 10
|
||||||
|
)
|
||||||
|
);
|
||||||
|
durationSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
600,
|
||||||
|
parseInt(durationSeconds != null ? durationSeconds : defaultDuration, 10) ||
|
||||||
|
defaultDuration
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cacheMinutes = Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(uiSettings.interfaceSpeedTestCacheMinutes, 10) || 0
|
||||||
|
);
|
||||||
|
const cacheKey =
|
||||||
|
cacheMinutes > 0
|
||||||
|
? interfaceSpeedCacheKey(serverId, interfaceName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cacheKey) {
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(cacheKey).catch(() => null);
|
||||||
|
if (raw?.body) {
|
||||||
|
const cached = JSON.parse(raw.body);
|
||||||
|
const cachedAt =
|
||||||
|
typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
|
||||||
|
const ttlMs = cacheMinutes * 60 * 1000;
|
||||||
|
if (cachedAt && Date.now() - cachedAt < ttlMs) {
|
||||||
|
return res.json({ ...cached, cached: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Если кеш не прочитался — просто продолжаем без него
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const server =
|
||||||
|
(Array.isArray(servers) ? servers : []).find(
|
||||||
|
(s) => (s.id || s.dns || s.ip) === serverId
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!server ||
|
||||||
|
(server.type !== 'jumphost' &&
|
||||||
|
String(server.type || '').toLowerCase() !== 'home')
|
||||||
|
) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
'Сервер (jumphost или home) не найден',
|
||||||
|
'E_NOT_FOUND'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
'MikroTik API не настроен или нет пароля',
|
||||||
|
'E_CREDENTIALS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
|
||||||
|
async function readOne() {
|
||||||
|
const apiRes = await client.print('interface');
|
||||||
|
const raw = apiRes?.data;
|
||||||
|
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||||
|
const iface = list.find(
|
||||||
|
(i) =>
|
||||||
|
i &&
|
||||||
|
i.name != null &&
|
||||||
|
String(i.name).trim() === String(interfaceName).trim()
|
||||||
|
);
|
||||||
|
if (!iface) {
|
||||||
|
throw new Error(
|
||||||
|
`Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rx =
|
||||||
|
iface['rx-byte'] != null
|
||||||
|
? parseInt(String(iface['rx-byte']), 10)
|
||||||
|
: 0;
|
||||||
|
const tx =
|
||||||
|
iface['tx-byte'] != null
|
||||||
|
? parseInt(String(iface['tx-byte']), 10)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
rxBytes: Number.isNaN(rx) ? 0 : rx,
|
||||||
|
txBytes: Number.isNaN(tx) ? 0 : tx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = await readOne();
|
||||||
|
await sleep(durationSeconds * 1000);
|
||||||
|
const end = await readOne();
|
||||||
|
|
||||||
|
const rxBytesDelta = Math.max(0, (end.rxBytes || 0) - (start.rxBytes || 0));
|
||||||
|
const txBytesDelta = Math.max(0, (end.txBytes || 0) - (start.txBytes || 0));
|
||||||
|
const totalBytesDelta = rxBytesDelta + txBytesDelta;
|
||||||
|
const duration = durationSeconds || 1;
|
||||||
|
|
||||||
|
const rxBps = rxBytesDelta * 8 / duration;
|
||||||
|
const txBps = txBytesDelta * 8 / duration;
|
||||||
|
const totalBps = totalBytesDelta * 8 / duration;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ok: true,
|
||||||
|
serverId,
|
||||||
|
interfaceName,
|
||||||
|
durationSeconds: duration,
|
||||||
|
rxBytesStart: start.rxBytes,
|
||||||
|
txBytesStart: start.txBytes,
|
||||||
|
rxBytesEnd: end.rxBytes,
|
||||||
|
txBytesEnd: end.txBytes,
|
||||||
|
rxBytesDelta,
|
||||||
|
txBytesDelta,
|
||||||
|
totalBytesDelta,
|
||||||
|
rxBps,
|
||||||
|
txBps,
|
||||||
|
totalBps,
|
||||||
|
cached: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cacheKey) {
|
||||||
|
writeS3JsonObject(cacheKey, {
|
||||||
|
...payload,
|
||||||
|
cached: false,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
}).catch((err) =>
|
||||||
|
console.warn(
|
||||||
|
'[traffic][interface-speed-test] cache write failed:',
|
||||||
|
err?.message || err
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('measureInterfaceSpeed:', error);
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
500,
|
||||||
|
error?.message || 'Ошибка замера скорости по интерфейсу',
|
||||||
|
'E_IFACE_SPEED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getInterfaceStats,
|
getInterfaceStats,
|
||||||
|
measureInterfaceSpeed,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -466,6 +466,11 @@ app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
|||||||
|
|
||||||
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
||||||
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
||||||
|
app.post(
|
||||||
|
'/api/traffic/interface-speed-test',
|
||||||
|
writeLimiter,
|
||||||
|
trafficRoutes.measureInterfaceSpeed
|
||||||
|
);
|
||||||
|
|
||||||
// === MIKROTIK BACKUPS (S3) ===
|
// === MIKROTIK BACKUPS (S3) ===
|
||||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||||
|
|||||||
+248
-24
@@ -40,28 +40,63 @@ 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'
|
const [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping' | 'speed'
|
||||||
|
|
||||||
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] = await Promise.all([
|
const [serversRes, netRes, uiRes] = 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(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) {
|
} 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');
|
||||||
@@ -78,10 +113,33 @@ 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 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) => {
|
const handleSelectGatewayMeta = (meta) => {
|
||||||
setGatewayMeta(meta);
|
setGatewayMeta(meta);
|
||||||
// Если цель не задана — подставляем IP gateway как target
|
// Если цель не задана — подставляем IP gateway как target
|
||||||
@@ -91,24 +149,23 @@ function MikrotikTools() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRunCheck = async () => {
|
const handleRunCheck = async () => {
|
||||||
if (!serverId) {
|
|
||||||
notify.error('Выберите сервер (jumphost)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedTarget = String(target || '').trim();
|
|
||||||
if (!trimmedTarget) {
|
|
||||||
notify.error('Укажите домен или IP назначения');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
|
setSpeedResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === 'traceroute') {
|
if (mode === 'traceroute') {
|
||||||
|
if (!serverId) {
|
||||||
|
notify.error('Выберите сервер (jumphost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedTarget = String(target || '').trim();
|
||||||
|
if (!trimmedTarget) {
|
||||||
|
notify.error('Укажите домен или IP назначения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
serverId,
|
serverId,
|
||||||
target: trimmedTarget,
|
target: trimmedTarget,
|
||||||
@@ -132,8 +189,16 @@ function MikrotikTools() {
|
|||||||
const hopsList = Array.isArray(data.hops) ? data.hops : [];
|
const hopsList = Array.isArray(data.hops) ? data.hops : [];
|
||||||
setHops(hopsList);
|
setHops(hopsList);
|
||||||
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
|
setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
|
||||||
} else {
|
} else if (mode === 'ping') {
|
||||||
// mode === 'ping'
|
if (!serverId) {
|
||||||
|
notify.error('Выберите сервер (jumphost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedTarget = String(target || '').trim();
|
||||||
|
if (!trimmedTarget) {
|
||||||
|
notify.error('Укажите домен или IP назначения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
serverId,
|
serverId,
|
||||||
target: trimmedTarget,
|
target: trimmedTarget,
|
||||||
@@ -153,6 +218,31 @@ 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);
|
||||||
@@ -169,6 +259,8 @@ function MikrotikTools() {
|
|||||||
setHops([]);
|
setHops([]);
|
||||||
setAnalysis([]);
|
setAnalysis([]);
|
||||||
setPingResult(null);
|
setPingResult(null);
|
||||||
|
setSpeedResult(null);
|
||||||
|
setSelectedInterfaceName('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const jumphostServers = useMemo(
|
const jumphostServers = useMemo(
|
||||||
@@ -402,8 +494,16 @@ function MikrotikTools() {
|
|||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`nav-link ${mode === 'traceroute' ? 'active' : ''}`}
|
className={`nav-link ${
|
||||||
onClick={() => { setMode('traceroute'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
mode === 'traceroute' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('traceroute');
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
setPingResult(null);
|
||||||
|
setSpeedResult(null);
|
||||||
|
}}
|
||||||
role="tab"
|
role="tab"
|
||||||
>
|
>
|
||||||
Traceroute
|
Traceroute
|
||||||
@@ -412,13 +512,39 @@ function MikrotikTools() {
|
|||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`nav-link ${mode === 'ping' ? 'active' : ''}`}
|
className={`nav-link ${
|
||||||
onClick={() => { setMode('ping'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
mode === 'ping' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('ping');
|
||||||
|
setHops([]);
|
||||||
|
setAnalysis([]);
|
||||||
|
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}>
|
||||||
@@ -475,10 +601,56 @@ function MikrotikTools() {
|
|||||||
)}
|
)}
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<div className="form-check form-switch form-check-inline">
|
<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)} />
|
<input
|
||||||
<label className="form-check-label small" htmlFor="useDnsToggle">{useDns ? 'DNS+IP' : 'Только IP'}</label>
|
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>
|
||||||
</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>
|
||||||
@@ -780,6 +952,58 @@ 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>
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export default function SettingsPage() {
|
|||||||
const [pingServicesServerId, setPingServicesServerId] = useState('');
|
const [pingServicesServerId, setPingServicesServerId] = useState('');
|
||||||
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||||
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
||||||
|
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
|
||||||
|
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
|
||||||
|
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
|
||||||
const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]);
|
const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]);
|
||||||
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
||||||
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
||||||
@@ -228,6 +231,22 @@ export default function SettingsPage() {
|
|||||||
? String(data.pingServicesCacheSeconds)
|
? 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;
|
const raw = data?.trafficInterfaces;
|
||||||
setTrafficInterfacesSelected(
|
setTrafficInterfacesSelected(
|
||||||
Array.isArray(raw)
|
Array.isArray(raw)
|
||||||
@@ -320,6 +339,16 @@ export default function SettingsPage() {
|
|||||||
0,
|
0,
|
||||||
parseInt(pingServicesCacheSeconds, 10) || 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)
|
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
|
||||||
? trafficInterfacesSelected.map((p) => ({
|
? trafficInterfacesSelected.map((p) => ({
|
||||||
serverKey: p.serverKey,
|
serverKey: p.serverKey,
|
||||||
@@ -551,6 +580,54 @@ export default function SettingsPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||||
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
||||||
const [serversList, setServersList] = useState([]);
|
const [serversList, setServersList] = useState([]);
|
||||||
|
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
|
||||||
|
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
|
||||||
|
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
|
||||||
|
|
||||||
const routerServersForPing = useMemo(() => {
|
const routerServersForPing = useMemo(() => {
|
||||||
return (serversList || []).filter(
|
return (serversList || []).filter(
|
||||||
@@ -53,13 +56,35 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
setWsUrl(String(data?.wsUpdateUrl || ''));
|
setWsUrl(String(data?.wsUpdateUrl || ''));
|
||||||
setBaseAS(String(data?.baseAS || '65001'));
|
setBaseAS(String(data?.baseAS || '65001'));
|
||||||
setPingDomain(String(data?.pingDomain || '').trim());
|
setPingDomain(String(data?.pingDomain || '').trim());
|
||||||
setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '');
|
setPingCacheMinutes(
|
||||||
|
data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''
|
||||||
|
);
|
||||||
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
||||||
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
||||||
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
|
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
|
||||||
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
|
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
|
||||||
setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').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 || '';
|
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||||
@@ -128,7 +153,20 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
|
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
|
||||||
pingServicesServerId: String(pingServicesServerId || '').trim(),
|
pingServicesServerId: String(pingServicesServerId || '').trim(),
|
||||||
pingServicesGatewayIp: String(pingServicesGatewayIp || '').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 = {
|
const payload = {
|
||||||
settings: mergedSettings,
|
settings: mergedSettings,
|
||||||
@@ -251,6 +289,57 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
min={0}
|
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">
|
<div className="mt-3 pt-3 border-top">
|
||||||
<h6 className="mb-2">Пинг сервисов на главной</h6>
|
<h6 className="mb-2">Пинг сервисов на главной</h6>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user