Files
router-lists-ui/frontend/src/MikrotikTools.jsx
T
2026-02-12 22:33:43 +07:00

886 lines
38 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import { useNotify } from './components/NotifyProvider.jsx';
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
import { countryToFlag } from './utils/serverUtils.js';
import {
IconRoute,
IconWorld,
IconAlertTriangle,
IconRefresh,
IconSearch,
} 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 [mode, setMode] = useState('traceroute'); // 'traceroute' | 'ping'
const [running, setRunning] = useState(false);
const [hops, setHops] = useState([]);
const [analysis, setAnalysis] = useState([]);
const [pingResult, setPingResult] = useState(null);
const [useDns, setUseDns] = useState(true);
const [serverSearch, setServerSearch] = 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 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);
try {
if (mode === 'traceroute') {
const body = {
serverId,
target: trimmedTarget,
useDns,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
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));
} else {
// mode === 'ping'
const body = {
serverId,
target: trimmedTarget,
count: 4,
};
if (gatewayMeta?.ip) {
body.gatewayIp = gatewayMeta.ip;
}
const res = await api.post('/mikrotik/ping', body);
const data = res?.data || {};
if (data.ok === false) {
notify.error(data.error || 'Ping завершился с ошибкой');
return;
}
setPingResult({
target: trimmedTarget,
...data,
});
}
} catch (error) {
console.error('[MikrotikTools] check failed', error);
notify.error(error?.message || 'Ошибка при выполнении проверки');
} finally {
setRunning(false);
}
};
const handleReset = () => {
setGatewayRef('');
setGatewayMeta(null);
setTarget('');
setHops([]);
setAnalysis([]);
setPingResult(null);
};
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[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 currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
const isInitialLoading = loading && servers.length === 0;
return (
<div className="page">
<PageHeader
title="MikroTik Tools"
pretitle="Проверка доступности через выбранный jumphost/gateway"
meta={`Текущий сервер: ${currentServerLabel}`}
/>
{/* Выбор сервера для трассировки (в стиле MikrotikBackups / filters) */}
<div className="card mb-3">
<div className="card-header">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-1">Сервер для трассировки</h3>
<div className="text-muted small">
Доступно Jumphost-серверов: {jumphostServers.length}.{' '}
{serverId
? 'Кликните по другой карточке, чтобы сменить сервер.'
: 'Выберите сервер, чтобы выполнять трассировку через него.'}
</div>
</div>
<div className="col-auto">
<div className="input-icon" style={{ minWidth: 240 }}>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов по IP, DNS, провайдеру..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
/>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
</div>
</div>
</div>
</div>
<div className="card-body">
{isInitialLoading ? (
<TableSkeleton rows={2} columns={2} />
) : jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы использовать инструменты MikroTik.
</div>
) : visibleJumphostServers.length === 0 ? (
<div className="text-muted small">
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
</div>
) : (
<div className="row row-cards g-2">
{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-4 col-lg-3" 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 d-flex align-items-start gap-2">
<span className="avatar bg-primary-lt" title={s.country || ''}>
{flag || s.ip?.slice(0, 2) || '?'}
</span>
<div className="flex-grow-1 min-w-0">
<div className="d-flex align-items-center gap-2">
<div className="fw-bold text-truncate" title={s.dns || s.ip || id}>
{s.dns || s.ip || id}
</div>
</div>
{line && (
<div className="text-muted small text-truncate" title={line}>
{line}
</div>
)}
<div className="text-muted small mt-1">
{[s.country, s.provider].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="text-muted small mt-2">
Текущий сервер:&nbsp;
<span className="fw-semibold">{currentServerLabel}</span>
</div>
</div>
</div>
{/* Параметры трассировки + результаты */}
<div className="row g-3">
<div className="col-md-4">
<div className="card">
<div className="card-header">
<div className="d-flex align-items-center justify-content-between">
<h3 className="card-title mb-0">Параметры проверки</h3>
<div className="btn-group btn-group-sm" role="group" aria-label="Тип проверки">
<button
type="button"
className={`btn ${mode === 'traceroute' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => { setMode('traceroute'); setHops([]); setAnalysis([]); setPingResult(null); }}
>
Traceroute
</button>
<button
type="button"
className={`btn ${mode === 'ping' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => { setMode('ping'); setHops([]); setAnalysis([]); setPingResult(null); }}
>
Ping
</button>
</div>
</div>
</div>
<div className="card-body">
{loading && !networkConfig ? (
<div className="text-muted small">Загрузка сетевых настроек</div>
) : (
<>
<div className="mb-3">
<label className="form-label">Привязка к gateway / интерфейсу (опционально)</label>
{/* Карточки gateways выбранного сервера */}
{serverId && gateways.length > 0 && (
<div className="mb-2">
<div className="row row-cards g-2">
{gateways
.filter((g) => {
if (!g || !g.ip) return false;
if (!serverId) return false;
if (!currentServer) return g.serverId === serverId;
const serverIds = new Set([
serverId,
currentServer.id,
currentServer.ip,
currentServer.dns,
].filter(Boolean).map(String));
return serverIds.has(String(g.serverId));
})
.map((g) => {
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
const flag = g.country ? countryToFlag(g.country) : '';
// В подзаголовке показываем страну/провайдера, чтобы не дублировать description
const subtitle = [g.country, g.provider].filter(Boolean).join(' · ');
return (
// Показываем карточки одной колонкой (по одной в ряд), чтобы текст был читаемым
<div className="col-12" key={g.id || g.ip}>
<div
className={`card card-sm h-100 cursor-pointer ${
isSelected ? 'border-primary card-hover' : 'card-hover'
}`}
onClick={() => {
const next = isSelected ? '' : (g.ip || g.id);
setGatewayRef(next);
if (!next) {
setGatewayMeta(null);
} else {
setGatewayMeta({ ip: g.ip, gateway: g });
if (!target && g.ip) setTarget(g.ip);
}
}}
>
<div className="card-body py-2 px-3 d-flex align-items-start gap-2">
<span className="avatar bg-green-lt text-green">
{flag || (g.ip ? g.ip.split('.').slice(-1)[0] : '?')}
</span>
<div className="flex-grow-1 min-w-0">
<div className="fw-bold text-truncate" title={g.ip || g.description || ''}>
{g.ip || '—'}
</div>
{g.description && (
<div className="text-muted small text-truncate" title={g.description}>
{g.description}
</div>
)}
{subtitle && (
<div className="text-muted small text-truncate">
{subtitle}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Расширенный выбор по IP/интерфейсу */}
<GatewayAutocompleteInput
value={gatewayRef}
onChange={setGatewayRef}
gateways={gateways}
interfaces={interfaces}
serverId={serverId || null}
placeholder="IP gateway или remote IP туннеля…"
onSelectMeta={handleSelectGatewayMeta}
/>
<div className="form-text">
Можно выбрать gateway карточкой выше или через поиск по IP/интерфейсу.
</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>
{mode === 'traceroute' && (
<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 className="mb-2">
<label className="form-label">Формат адресов в результатах</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="useDnsToggle"
checked={useDns}
onChange={(e) => setUseDns(e.target.checked)}
/>
<label className="form-check-label" htmlFor="useDnsToggle">
{useDns ? 'DNS + IP (если доступны)' : 'Только IP адреса'}
</label>
</div>
<div className="form-text">
Для traceroute параметр также передаётся на MikroTik как <code>use-dns</code>.
</div>
</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={handleRunCheck}
disabled={running || loading}
>
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
</button>
</div>
</div>
</div>
<div className="col-md-8">
{mode === 'traceroute' && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title mb-0">Результаты трассировки</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-vcenter card-table table-hover mb-0">
<thead>
<tr>
<th style={{ width: 70 }}>Хоп</th>
<th>Адрес / узел</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Статус</th>
</tr>
</thead>
<tbody>
{hops.map((h) => {
const ip = h.ip || '';
const dns = h.dns || '';
let mainLabel;
if (useDns) {
if (dns && ip) mainLabel = `${dns} (${ip})`;
else mainLabel = dns || ip || h.host || '—';
} else {
mainLabel = ip || dns || h.host || '—';
}
const avg = typeof h.avgMs === 'number' ? h.avgMs : null;
const loss = typeof h.loss === 'number' ? h.loss : null;
const statusRaw = (h.status || '').toString();
const statusLower = statusRaw.toLowerCase();
const hasTimeout =
statusLower.includes('timeout') ||
statusLower.includes('no reply') ||
statusLower.includes('unreachable');
let severity = 'ok';
if (hasTimeout || (loss != null && loss >= 50)) {
severity = 'danger';
} else if ((loss != null && loss > 0) || (avg != null && avg > 200)) {
severity = 'warn';
}
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr key={h.hop || `${ip || h.host}-${Math.random()}`} className={rowClass}>
<td>
<span className="badge bg-blue-lt text-blue">
{h.hop}
</span>
</td>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold text-truncate" title={mainLabel}>
{mainLabel}
</span>
{ip && dns && (
<span className="text-muted small text-truncate" title={ip}>
IP: {ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '}
{h.worstMs != null ? h.worstMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Проблемный хоп</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">Возможная проблема</span>
)}
<span className="text-muted small">
{statusRaw || (loss != null || avg != null ? 'OK' : '—')}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{mode === 'ping' && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title mb-0">Результаты ping</h3>
</div>
<div className="card-body p-0">
{!pingResult ? (
<div className="p-3 text-muted small">
Результаты ping появятся здесь после запуска проверки.
</div>
) : (
<div className="table-responsive">
<table className="table table-vcenter card-table mb-0">
<thead>
<tr>
<th>Цель</th>
<th style={{ width: 130 }}>Среднее, мс</th>
<th style={{ width: 140 }}>Мин / Макс, мс</th>
<th style={{ width: 110 }}>Потери</th>
<th style={{ width: 200 }}>Оценка</th>
</tr>
</thead>
<tbody>
{(() => {
const avg = typeof pingResult.avgMs === 'number' ? pingResult.avgMs : null;
const loss = typeof pingResult.loss === 'number' ? pingResult.loss : null;
let severity = 'ok';
if (loss != null && loss >= 50) severity = 'danger';
else if ((loss != null && loss > 0) || (avg != null && avg > 200)) severity = 'warn';
const rowClass =
severity === 'danger'
? 'table-danger'
: severity === 'warn'
? 'table-warning'
: '';
return (
<tr className={rowClass}>
<td>
<div className="d-flex flex-column">
<span className="fw-semibold">
{pingResult.target || target || '—'}
</span>
{gatewayMeta?.ip && (
<span className="text-muted small">
через gateway {gatewayMeta.ip}
</span>
)}
</div>
</td>
<td>
{avg != null ? (
<span
className={
avg > 200
? 'text-danger fw-semibold'
: avg > 120
? 'text-warning fw-semibold'
: ''
}
>
{avg.toFixed(1)}
</span>
) : (
'—'
)}
</td>
<td>
{pingResult.minMs != null ? pingResult.minMs.toFixed(1) : '—'} /{' '}
{pingResult.maxMs != null ? pingResult.maxMs.toFixed(1) : '—'}
</td>
<td>
{loss != null ? (
<span
className={
loss >= 50
? 'badge bg-red-lt text-red'
: loss > 0
? 'badge bg-yellow-lt text-yellow'
: 'badge bg-green-lt text-green'
}
>
{loss}%
</span>
) : (
'—'
)}
</td>
<td>
{severity === 'danger' && (
<span className="badge bg-red-lt text-red me-1">Плохое качество</span>
)}
{severity === 'warn' && (
<span className="badge bg-yellow-lt text-yellow me-1">
Возможные проблемы
</span>
)}
<span className="text-muted small">
{loss === 0
? 'Все пакеты доставлены'
: loss != null
? `Потеряно пакетов: ${loss}%`
: 'Нет данных о потере пакетов'}
</span>
</td>
</tr>
);
})()}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{mode === 'traceroute' && (
<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 ip = hop.ip || hop.host || hop.address || '';
const dns = hop.dns || null;
const hostDisplay = dns ? `${dns}${ip ? ` (${ip})` : ''}` : (ip || hop.host || '');
const avg = hop.avgMs;
const loss = hop.loss;
let descr = `Хоп ${hop.hop || index + 1}: ${hostDisplay || 'неизвестный узел'}`;
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 (по IP)
const gw = gateways.find((g) => g.ip && g.ip === ip);
if (gw) {
const srvLabel = findServerLabel(gw.serverId);
details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`);
}
// Привязка к туннельному интерфейсу (по IP)
const iface = ifaces.find((i) => i.localIp === ip || i.remoteIp === ip);
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.dns ? `${firstTimeoutHop.dns}${firstTimeoutHop.ip ? ` (${firstTimeoutHop.ip})` : ''}` : (firstTimeoutHop.ip || firstTimeoutHop.host || 'неизвестный узел')}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.`
);
} else {
lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).');
}
return lines;
}
export default MikrotikTools;