Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m54s
886 lines
38 KiB
React
886 lines
38 KiB
React
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">
|
||
Текущий сервер:
|
||
<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;
|
||
|