diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js
index 889b86b..096bbed 100644
--- a/backend/routes/mikrotikConfigRoutes.js
+++ b/backend/routes/mikrotikConfigRoutes.js
@@ -541,6 +541,111 @@ async function pingViaInterface(req, res) {
}
}
+/**
+ * POST /api/mikrotik/traceroute
+ * Трассировка до адреса через указанный MikroTik (jumphost) и, при желании, конкретный gateway.
+ *
+ * Body:
+ * - serverId: ID/имя сервера из servers.json (обязательно)
+ * - target: адрес назначения (обязательно)
+ * - gatewayIp?: IP шлюза, через который выполнять трассировку
+ * - maxHops?: максимальное количество хопов (по умолчанию 30)
+ */
+async function tracerouteViaGateway(req, res) {
+ try {
+ let {
+ serverId,
+ target,
+ gatewayIp,
+ maxHops = 30,
+ } = req.body || {};
+
+ if (!serverId) {
+ return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
+ }
+
+ if (target == null || String(target).trim() === '') {
+ return sendError(res, 400, 'target is required', 'E_BAD_REQUEST');
+ }
+
+ target = String(target).trim();
+
+ const servers = await readServersFromS3();
+ const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
+ if (!server || server.type !== 'jumphost') {
+ return sendError(res, 400, 'Jumphost server not found', 'E_NOT_FOUND');
+ }
+
+ const creds = getMikrotikCredentials(server);
+ if (!creds) {
+ return sendError(res, 400, 'MikroTik credentials not configured for this server', 'E_CREDENTIALS');
+ }
+
+ const client = createRosClient(creds);
+
+ const body = {
+ address: target,
+ };
+
+ if (gatewayIp) {
+ body.gateway = gatewayIp;
+ }
+
+ const hopsNum = Number(maxHops);
+ if (Number.isFinite(hopsNum) && hopsNum > 0) {
+ body['max-hops'] = hopsNum;
+ }
+
+ try {
+ const cliParts = [
+ '/tool/traceroute',
+ `address=${body.address}`,
+ ];
+ if (body.gateway) cliParts.push(`gateway=${body.gateway}`);
+ if (body['max-hops']) cliParts.push(`max-hops=${body['max-hops']}`);
+
+ console.log('[mikrotik][tracerouteViaGateway]', {
+ serverId,
+ gatewayIp,
+ body,
+ cli: cliParts.join(' '),
+ });
+ } catch (_) {}
+
+ const trRes = await client.command('tool/traceroute', body);
+ const raw = trRes?.data;
+ const rows = Array.isArray(raw) ? raw : (raw ? [raw] : []);
+
+ const parseMs = (val) => {
+ if (val == null) return null;
+ const s = String(val).trim();
+ const m = s.match(/([\d.]+)/);
+ return m ? Number(m[1]) : null;
+ };
+
+ const hops = rows.map((r, index) => ({
+ hop: r.hop != null ? Number(r.hop) : index + 1,
+ host: r.host || r.address || '',
+ avgMs: parseMs(r['avg-rtt'] || r.time || r.avg),
+ bestMs: parseMs(r['best-rtt'] || r['min-rtt']),
+ worstMs: parseMs(r['worst-rtt'] || r['max-rtt']),
+ loss:
+ r['packet-loss'] != null
+ ? Number(String(r['packet-loss']).replace('%', ''))
+ : null,
+ status: r.status || '',
+ raw: r,
+ }));
+
+ return res.json({ ok: true, hops });
+ } catch (error) {
+ const msg = error.response?.data?.message || error.message || 'Traceroute failed';
+ const status = error.response?.status;
+ console.error('tracerouteViaGateway:', error);
+ return sendError(res, status && status >= 400 ? status : 502, msg, 'E_TRACEROUTE');
+ }
+}
+
/**
* POST /api/mikrotik/run-script
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
@@ -587,5 +692,6 @@ module.exports = {
testMikrotikConnection,
applyMikrotikConfig,
runScript,
+ tracerouteViaGateway,
pingViaInterface,
};
diff --git a/backend/server.js b/backend/server.js
index 0b70198..ec8ec1f 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -456,6 +456,8 @@ app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConne
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
// Реальный ping через RouterOS по интерфейсу/шлюзу
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
+// Traceroute через RouterOS с выбором сервера и шлюза
+app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway);
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 50ab372..24c2e0a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -32,6 +32,7 @@ import AutoUrlManager from './AutoUrlManager';
import BillingManager from './BillingManager';
import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
+import MikrotikTools from './MikrotikTools.jsx';
import Dashboard from './Dashboard';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import './App.css';
@@ -206,7 +207,8 @@ function MainLayout() {
icon: IconSettings,
items: [
{ 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 }
]
},
// Убрали неиспользуемые/неработающие разделы
@@ -407,6 +409,7 @@ function MainLayout() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/MikrotikTools.jsx b/frontend/src/MikrotikTools.jsx
new file mode 100644
index 0000000..710f8d0
--- /dev/null
+++ b/frontend/src/MikrotikTools.jsx
@@ -0,0 +1,420 @@
+import { useEffect, useMemo, useState } from 'react';
+import api from './lib/api.js';
+import { useNotify } from './components/NotifyProvider.jsx';
+import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
+import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
+import {
+ IconRoute,
+ IconWorld,
+ IconAlertTriangle,
+ IconRefresh,
+} from '@tabler/icons-react';
+
+/**
+ * Раздел "Инструменты → MikroTik": проверка маршрута (traceroute)
+ * с выбором сервера, gateway и домена/IP.
+ */
+function MikrotikTools() {
+ const notify = useNotify();
+
+ const [servers, setServers] = useState([]);
+ const [networkConfig, setNetworkConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const [serverId, setServerId] = useState('');
+ const [gatewayRef, setGatewayRef] = useState('');
+ const [gatewayMeta, setGatewayMeta] = useState(null);
+ const [target, setTarget] = useState('');
+ const [maxHops, setMaxHops] = useState(30);
+
+ const [running, setRunning] = useState(false);
+ const [hops, setHops] = useState([]);
+ const [analysis, setAnalysis] = useState([]);
+
+ // Загрузка серверов и сетевого конфига
+ useEffect(() => {
+ const load = async () => {
+ setLoading(true);
+ try {
+ const [serversRes, netRes] = await Promise.all([
+ api.get('/servers'),
+ api.get('/network-config'),
+ ]);
+ setServers(Array.isArray(serversRes.data) ? serversRes.data : []);
+ setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] });
+ } catch (error) {
+ console.error('[MikrotikTools] failed to load initial data', error);
+ notify.error('Не удалось загрузить данные для инструментов MikroTik');
+ } finally {
+ setLoading(false);
+ }
+ };
+ load();
+ }, [notify]);
+
+ const gateways = useMemo(
+ () => (networkConfig?.gateways && Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []),
+ [networkConfig]
+ );
+
+ const interfaces = useMemo(
+ () => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []),
+ [networkConfig]
+ );
+
+ const handleSelectGatewayMeta = (meta) => {
+ setGatewayMeta(meta);
+ // Если цель не задана — подставляем IP gateway как target
+ if (!target && meta && meta.ip) {
+ setTarget(meta.ip);
+ }
+ };
+
+ const handleRunTraceroute = async () => {
+ if (!serverId) {
+ notify.error('Выберите сервер (jumphost)');
+ return;
+ }
+ if (!target || String(target).trim() === '') {
+ notify.error('Укажите домен или IP для трассировки');
+ return;
+ }
+
+ setRunning(true);
+ setHops([]);
+ setAnalysis([]);
+
+ try {
+ const body = {
+ serverId,
+ target: String(target).trim(),
+ };
+ // gatewayRef может быть как ID gateway, так и IP/строка —
+ // для простоты, если в meta есть ip — используем его как gatewayIp.
+ if (gatewayMeta?.ip) {
+ body.gatewayIp = gatewayMeta.ip;
+ }
+ // Max hops
+ const hopsNum = Number(maxHops);
+ if (Number.isFinite(hopsNum) && hopsNum > 0) {
+ body.maxHops = hopsNum;
+ }
+
+ const res = await api.post('/mikrotik/traceroute', body);
+ const data = res?.data || {};
+ if (!data.ok) {
+ notify.error(data.error || 'Трассировка завершилась с ошибкой');
+ return;
+ }
+
+ const hopsList = Array.isArray(data.hops) ? data.hops : [];
+ setHops(hopsList);
+ setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target));
+ } catch (error) {
+ console.error('[MikrotikTools] traceroute failed', error);
+ notify.error(error?.message || 'Ошибка при выполнении трассировки');
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ const handleReset = () => {
+ setGatewayRef('');
+ setGatewayMeta(null);
+ setTarget('');
+ setHops([]);
+ setAnalysis([]);
+ };
+
+ return (
+
+
+
+
+
+
+ Инструменты MikroTik: трассировка
+
+
+ Проверка маршрута до домена/IP через выбранный jumphost и gateway с расшифровкой хопов.
+
+
+
+
+
+
+
+
+
+
Параметры проверки
+
+
+ {loading ? (
+
Загрузка серверов и сетевых настроек…
+ ) : (
+ <>
+
+
+
String(s.type).toLowerCase() === 'jumphost')}
+ placeholder="Начните вводить IP, DNS или провайдера…"
+ />
+
+ Выберите MikroTik‑jumphost, через который будет выполняться трассировка.
+
+
+
+
+
+
+
+ Можно привязать трассировку к конкретному прямому gateway или туннельному интерфейсу.
+
+
+
+
+
+
+
+
+
+ setTarget(e.target.value)}
+ />
+ {gatewayMeta?.ip && (
+
+ )}
+
+
+ Можно трассировать как до внешнего домена, так и до IP gateway/туннеля.
+
+
+
+
+
+ setMaxHops(e.target.value)}
+ />
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Результаты трассировки
+
+
+ {hops.length === 0 ? (
+
+ Результаты трассировки появятся здесь после запуска проверки.
+
+ ) : (
+
+
+
+
+ | Хоп |
+ Адрес / узел |
+ Среднее, мс |
+ Мин / Макс, мс |
+ Потери |
+ Статус |
+
+
+
+ {hops.map((h) => (
+
+ | {h.hop} |
+
+ {h.host || '—'}
+ {h._label && (
+ {h._label}
+ )}
+ |
+ {h.avgMs != null ? h.avgMs.toFixed(1) : '—'} |
+
+ {h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
+ {h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
+ |
+ {h.loss != null ? `${h.loss}%` : '—'} |
+ {h.status || '—'} |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
+ Расшифровка трассировки
+
+
+
+ {analysis.length === 0 ? (
+
+ После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем.
+
+ ) : (
+
+ {analysis.map((line, idx) => (
+ -
+ {line}
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
+
+function buildTracerouteAnalysis(hops, networkConfig, servers, target) {
+ if (!Array.isArray(hops) || hops.length === 0) return [];
+ const lines = [];
+ const gateways = (networkConfig?.gateways && Array.isArray(networkConfig.gateways)) ? networkConfig.gateways : [];
+ const ifaces = (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces)) ? networkConfig.tunnelInterfaces : [];
+
+ const findServerLabel = (serverId) => {
+ if (!serverId) return null;
+ const s = (servers || []).find((srv) => srv.id === serverId || srv.ip === serverId || srv.dns === serverId);
+ if (!s) return serverId;
+ return s.dns || s.ip || serverId;
+ };
+
+ const targetStr = String(target || '').trim();
+ lines.push(`Цель трассировки: ${targetStr || 'не указана'}. Хопов в маршруте: ${hops.length}.`);
+
+ let firstTimeoutHop = null;
+ let worstLatencyHop = null;
+
+ hops.forEach((hop, index) => {
+ const host = hop.host || hop.address || '';
+ const avg = hop.avgMs;
+ const loss = hop.loss;
+
+ let descr = `Хоп ${hop.hop || index + 1}: ${host || 'неизвестный узел'}`;
+ const details = [];
+
+ if (avg != null) details.push(`среднее время ≈ ${avg.toFixed(1)} мс`);
+ if (hop.bestMs != null && hop.worstMs != null) {
+ details.push(`мин/макс ${hop.bestMs.toFixed(1)}/${hop.worstMs.toFixed(1)} мс`);
+ }
+ if (loss != null) details.push(`потеря пакетов ${loss}%`);
+
+ // Привязка к gateway
+ const gw = gateways.find((g) => g.ip && g.ip === host);
+ if (gw) {
+ const srvLabel = findServerLabel(gw.serverId);
+ details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`);
+ }
+
+ // Привязка к туннельному интерфейсу
+ const iface = ifaces.find((i) => i.localIp === host || i.remoteIp === host);
+ if (iface) {
+ const s1 = findServerLabel(iface.serverId);
+ const s2 = findServerLabel(iface.serverId2);
+ details.push(
+ `туннель ${iface.type || ''} ${iface.name || ''} (${iface.localIp} ⇄ ${iface.remoteIp})` +
+ (s1 || s2 ? ` между ${s1 || 'сервером 1'} и ${s2 || 'сервером 2'}` : '')
+ );
+ }
+
+ // Статус/таймауты
+ if (hop.status) {
+ const st = String(hop.status).toLowerCase();
+ if (st.includes('timeout') || st.includes('no reply') || st.includes('unreachable')) {
+ details.push('узел не отвечает на ICMP (возможная фильтрация или недоступность)');
+ if (!firstTimeoutHop) firstTimeoutHop = hop;
+ }
+ }
+
+ if (details.length > 0) {
+ descr += ` — ${details.join('; ')}`;
+ }
+
+ lines.push(descr);
+
+ if (avg != null) {
+ if (!worstLatencyHop || avg > (worstLatencyHop.avgMs || 0)) {
+ worstLatencyHop = hop;
+ }
+ }
+ });
+
+ if (worstLatencyHop && worstLatencyHop.avgMs != null && worstLatencyHop.avgMs > 150) {
+ lines.push(
+ `Максимальная средняя задержка на хопе ${worstLatencyHop.hop}: около ${worstLatencyHop.avgMs.toFixed(
+ 1
+ )} мс — возможен "узкое место" или удалённый сегмент сети.`
+ );
+ }
+
+ if (firstTimeoutHop) {
+ lines.push(
+ `Начиная с хопа ${firstTimeoutHop.hop} (${firstTimeoutHop.host || 'неизвестный узел'}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.`
+ );
+ } else {
+ lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).');
+ }
+
+ return lines;
+}
+
+export default MikrotikTools;
+