feat(MikrotikTools): add traceroute functionality via MikroTik API and integrate new tools route in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m36s

This commit is contained in:
2026-02-12 15:24:53 +07:00
parent c75f5f6b82
commit 6077d3bc5b
4 changed files with 532 additions and 1 deletions
+106
View File
@@ -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,
};
+2
View File
@@ -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);
+4 -1
View File
@@ -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() {
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
+420
View File
@@ -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 (
<div className="container-xl py-3">
<div className="page-header d-print-none mb-3">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title d-flex align-items-center">
<IconRoute size={24} className="me-2 text-primary" />
Инструменты MikroTik: трассировка
</h2>
<div className="page-subtitle text-muted">
Проверка маршрута до домена/IP через выбранный jumphost и gateway с расшифровкой хопов.
</div>
</div>
</div>
</div>
<div className="row g-3">
<div className="col-md-4">
<div className="card">
<div className="card-header">
<h3 className="card-title">Параметры проверки</h3>
</div>
<div className="card-body">
{loading ? (
<div className="text-muted small">Загрузка серверов и сетевых настроек</div>
) : (
<>
<div className="mb-3">
<label className="form-label">Сервер (jumphost)</label>
<ServerAutocompleteInput
value={serverId}
onChange={setServerId}
servers={servers.filter((s) => String(s.type).toLowerCase() === 'jumphost')}
placeholder="Начните вводить IP, DNS или провайдера…"
/>
<div className="form-text">
Выберите MikroTikjumphost, через который будет выполняться трассировка.
</div>
</div>
<div className="mb-3">
<label className="form-label">Привязка к gateway / интерфейсу (опционально)</label>
<GatewayAutocompleteInput
value={gatewayRef}
onChange={setGatewayRef}
gateways={gateways}
interfaces={interfaces}
serverId={serverId || null}
placeholder="IP gateway или remote IP туннеля…"
onSelectMeta={handleSelectGatewayMeta}
/>
<div className="form-text">
Можно привязать трассировку к конкретному прямому gateway или туннельному интерфейсу.
</div>
</div>
<div className="mb-3">
<label className="form-label">Домен или IP назначения</label>
<div className="input-group input-group-flat">
<span className="input-group-text">
<IconWorld size={16} className="text-muted" />
</span>
<input
type="text"
className="form-control"
placeholder="example.com или 8.8.8.8"
value={target}
onChange={(e) => setTarget(e.target.value)}
/>
{gatewayMeta?.ip && (
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setTarget(gatewayMeta.ip)}
>
IP gateway
</button>
)}
</div>
<div className="form-text">
Можно трассировать как до внешнего домена, так и до IP gateway/туннеля.
</div>
</div>
<div className="mb-3">
<label className="form-label">Максимальное число хопов</label>
<input
type="number"
className="form-control"
min={1}
max={64}
value={maxHops}
onChange={(e) => setMaxHops(e.target.value)}
/>
</div>
</>
)}
</div>
<div className="card-footer d-flex justify-content-between">
<button
type="button"
className="btn btn-outline-secondary"
onClick={handleReset}
disabled={running}
>
<IconRefresh size={16} className="me-1" />
Сбросить
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleRunTraceroute}
disabled={running || loading}
>
{running ? 'Трассировка…' : 'Запустить трассировку'}
</button>
</div>
</div>
</div>
<div className="col-md-8">
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Результаты трассировки</h3>
</div>
<div className="card-body p-0">
{hops.length === 0 ? (
<div className="p-3 text-muted small">
Результаты трассировки появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-sm table-hover table-striped mb-0">
<thead>
<tr>
<th style={{ width: 60 }}>Хоп</th>
<th>Адрес / узел</th>
<th style={{ width: 120 }}>Среднее, мс</th>
<th style={{ width: 120 }}>Мин / Макс, мс</th>
<th style={{ width: 100 }}>Потери</th>
<th style={{ width: 160 }}>Статус</th>
</tr>
</thead>
<tbody>
{hops.map((h) => (
<tr key={h.hop || `${h.host}-${Math.random()}`}>
<td>{h.hop}</td>
<td>
{h.host || '—'}
{h._label && (
<div className="text-muted small">{h._label}</div>
)}
</td>
<td>{h.avgMs != null ? h.avgMs.toFixed(1) : '—'}</td>
<td>
{h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
{h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
</td>
<td>{h.loss != null ? `${h.loss}%` : '—'}</td>
<td>{h.status || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title d-flex align-items-center">
<IconAlertTriangle size={18} className="me-2 text-yellow" />
Расшифровка трассировки
</h3>
</div>
<div className="card-body">
{analysis.length === 0 ? (
<div className="text-muted small">
После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем.
</div>
) : (
<ul className="list-unstyled mb-0">
{analysis.map((line, idx) => (
<li key={idx} className="mb-1">
{line}
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
</div>
);
}
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;