feat(Mikrotik): add ping endpoint and implement prefetching of ping data for gateways in EasySwitchManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m18s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m18s
This commit is contained in:
@@ -272,6 +272,113 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mikrotik/ping
|
||||||
|
* Пингует внешний ресурс через указанный MikroTik (jumphost),
|
||||||
|
* по возможности привязываясь к интерфейсу, соответствующему gateway.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - serverId: ID/имя сервера из servers.json (обязательно)
|
||||||
|
* - gatewayIp?: IP шлюза (remote IP туннеля или прямой gateway)
|
||||||
|
* - interfaceName?: имя интерфейса RouterOS (если знаем его заранее)
|
||||||
|
* - srcAddress?: исходный IP (например, local IP туннеля)
|
||||||
|
* - target?: адрес для ping (по умолчанию www.gstatic.com)
|
||||||
|
* - count?: количество пакетов (по умолчанию 5)
|
||||||
|
*/
|
||||||
|
async function pingViaInterface(req, res) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
serverId,
|
||||||
|
gatewayIp,
|
||||||
|
interfaceName,
|
||||||
|
srcAddress,
|
||||||
|
target = 'www.gstatic.com',
|
||||||
|
count = 5,
|
||||||
|
} = req.body || {};
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Пытаемся найти интерфейс по gatewayIp и serverId, если interfaceName не передан
|
||||||
|
let ifaceName = interfaceName || null;
|
||||||
|
let srcAddr = srcAddress || null;
|
||||||
|
|
||||||
|
if (!ifaceName && !srcAddr && gatewayIp) {
|
||||||
|
const config = await loadNetworkConfig();
|
||||||
|
const tunnelIfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
|
||||||
|
const iface = tunnelIfaces.find((i) => {
|
||||||
|
const sameServer =
|
||||||
|
i.serverId === serverId ||
|
||||||
|
i.serverId === server.ip ||
|
||||||
|
i.serverId === server.dns;
|
||||||
|
const sameIp = i.remoteIp === gatewayIp || i.localIp === gatewayIp;
|
||||||
|
return sameServer && sameIp;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (iface) {
|
||||||
|
ifaceName = iface.name || null;
|
||||||
|
srcAddr = iface.localIp || srcAddr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {};
|
||||||
|
body.address = target;
|
||||||
|
body.count = Number(count) > 0 ? Number(count) : 5;
|
||||||
|
if (ifaceName) body.interface = ifaceName;
|
||||||
|
if (srcAddr) body['src-address'] = srcAddr;
|
||||||
|
|
||||||
|
const pingRes = await client.command('tool/ping', body);
|
||||||
|
const raw = pingRes?.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ищем строку с аггрегированными полями (avg-rtt, min-rtt, max-rtt, packet-loss)
|
||||||
|
let summary = rows.find((r) => r['avg-rtt'] || r['packet-loss']) || rows[rows.length - 1] || {};
|
||||||
|
|
||||||
|
const avgMs = parseMs(summary['avg-rtt'] || summary.time);
|
||||||
|
const minMs = parseMs(summary['min-rtt']);
|
||||||
|
const maxMs = parseMs(summary['max-rtt']);
|
||||||
|
const loss =
|
||||||
|
summary['packet-loss'] != null
|
||||||
|
? Number(String(summary['packet-loss']).replace('%', ''))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return sendOk(res, {
|
||||||
|
ok: true,
|
||||||
|
avgMs,
|
||||||
|
minMs,
|
||||||
|
maxMs,
|
||||||
|
loss,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error.response?.data?.message || error.message || 'Ping failed';
|
||||||
|
const status = error.response?.status;
|
||||||
|
console.error('pingViaInterface:', error);
|
||||||
|
return sendError(res, status && status >= 400 ? status : 502, msg, 'E_PING');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/mikrotik/run-script
|
* POST /api/mikrotik/run-script
|
||||||
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
|
* Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter
|
||||||
@@ -318,4 +425,5 @@ module.exports = {
|
|||||||
testMikrotikConnection,
|
testMikrotikConnection,
|
||||||
applyMikrotikConfig,
|
applyMikrotikConfig,
|
||||||
runScript,
|
runScript,
|
||||||
|
pingViaInterface,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -452,7 +452,10 @@ app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
|
|||||||
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
||||||
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
||||||
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
|
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
|
||||||
|
// Простой health-check (используется NetworkConfigManager при открытии модалки применения)
|
||||||
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
||||||
|
// Реальный ping через RouterOS по интерфейсу/шлюзу
|
||||||
|
app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface);
|
||||||
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function EasySwitchManager() {
|
|||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
const [expandedServers, setExpandedServers] = useState(new Set()); // Развернутые серверы
|
||||||
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
const [groupByTags, setGroupByTags] = useState(true); // Группировка по тегам
|
||||||
|
// Пинги по шлюзам: ключ `${routerId}:${gatewayIp}` → avg RTT в мс
|
||||||
|
const [pingMap, setPingMap] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initData = async () => {
|
const initData = async () => {
|
||||||
@@ -146,6 +148,8 @@ function EasySwitchManager() {
|
|||||||
// Фильтруем серверы, у которых есть gateways
|
// Фильтруем серверы, у которых есть gateways
|
||||||
const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0);
|
const serversWithGateways = serversWithData.filter(s => s.gateways.length > 0);
|
||||||
setServers(serversWithGateways);
|
setServers(serversWithGateways);
|
||||||
|
// Предзагружаем пинги для всех комбинаций server+gateway
|
||||||
|
prefetchPings(serversWithGateways, inventory);
|
||||||
|
|
||||||
// Автоматически разворачиваем первый сервер
|
// Автоматически разворачиваем первый сервер
|
||||||
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
||||||
@@ -176,6 +180,67 @@ function EasySwitchManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить реальные пинги для комбинаций (сервер, gateway).
|
||||||
|
* Пингуем хост www.gstatic.com через соответствующий MikroTik jumphost.
|
||||||
|
*/
|
||||||
|
const prefetchPings = async (serversList, inventory) => {
|
||||||
|
try {
|
||||||
|
const tasks = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
serversList.forEach((server) => {
|
||||||
|
const inv = inventory.find(srv =>
|
||||||
|
String(srv.dns || '').trim() === server.name ||
|
||||||
|
String(srv.hostName || '').trim() === server.name ||
|
||||||
|
String(srv.ip || '').trim() === server.name
|
||||||
|
);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
if (!routerId) return;
|
||||||
|
|
||||||
|
(server.gateways || []).forEach((gw) => {
|
||||||
|
const ip = gw.ip || '';
|
||||||
|
if (!ip) return;
|
||||||
|
const key = `${routerId}:${ip}`;
|
||||||
|
if (seen.has(key) || pingMap[key] !== undefined) return;
|
||||||
|
seen.add(key);
|
||||||
|
tasks.push({ routerId, ip, key });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
tasks.map(async ({ routerId, ip, key }) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/mikrotik/ping', {
|
||||||
|
serverId: routerId,
|
||||||
|
gatewayIp: ip,
|
||||||
|
target: 'www.gstatic.com',
|
||||||
|
count: 5,
|
||||||
|
});
|
||||||
|
return { key, value: typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch ping for', routerId, ip, e?.message || e);
|
||||||
|
return { key, value: null };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
setPingMap(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
results.forEach(r => {
|
||||||
|
next[r.key] = r.value;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('prefetchPings error:', e?.message || e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleGatewaySelect = (serverId, community, gateway) => {
|
const handleGatewaySelect = (serverId, community, gateway) => {
|
||||||
const key = `${serverId}:${community}`;
|
const key = `${serverId}:${community}`;
|
||||||
setActiveGateways(prev => {
|
setActiveGateways(prev => {
|
||||||
@@ -711,7 +776,12 @@ function EasySwitchManager() {
|
|||||||
{server.gateways.map((gw, idx) => {
|
{server.gateways.map((gw, idx) => {
|
||||||
const isActive = activeGw === gw.name;
|
const isActive = activeGw === gw.name;
|
||||||
const isFastest = idx === 0;
|
const isFastest = idx === 0;
|
||||||
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
|
const pingKey = (() => {
|
||||||
|
const inv = getServerMetadata(server.name);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
|
||||||
|
})();
|
||||||
|
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
@@ -785,7 +855,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
||||||
{ping}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,7 +916,12 @@ function EasySwitchManager() {
|
|||||||
{server.gateways.map((gw, idx) => {
|
{server.gateways.map((gw, idx) => {
|
||||||
const isActive = activeGw === gw.name;
|
const isActive = activeGw === gw.name;
|
||||||
const isFastest = idx === 0;
|
const isFastest = idx === 0;
|
||||||
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
|
const pingKey = (() => {
|
||||||
|
const inv = getServerMetadata(server.name);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
|
||||||
|
})();
|
||||||
|
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
@@ -920,7 +995,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
||||||
{ping}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -970,7 +1045,12 @@ function EasySwitchManager() {
|
|||||||
{server.gateways.map((gw, idx) => {
|
{server.gateways.map((gw, idx) => {
|
||||||
const isActive = activeGw === gw.name;
|
const isActive = activeGw === gw.name;
|
||||||
const isFastest = idx === 0;
|
const isFastest = idx === 0;
|
||||||
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
|
const pingKey = (() => {
|
||||||
|
const inv = getServerMetadata(server.name);
|
||||||
|
const routerId = inv?.id || inv?.dns || inv?.ip;
|
||||||
|
return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
|
||||||
|
})();
|
||||||
|
const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={gw.name} className="col-12 col-sm-4">
|
<div key={gw.name} className="col-12 col-sm-4">
|
||||||
@@ -1044,7 +1124,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
{/* Метрика (пинг) */}
|
{/* Метрика (пинг) */}
|
||||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.1rem' }}>
|
||||||
{ping}
|
{ping != null ? ping : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user