feat(PingServices): implement UI settings for ping services configuration and enhance ping functionality through RouterOS
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s

This commit is contained in:
2026-02-16 23:35:25 +07:00
parent dfcbdb35be
commit 838ca6b220
3 changed files with 188 additions and 88 deletions
+75 -80
View File
@@ -294,81 +294,21 @@ async function applyMikrotikConfig(req, res) {
}
/**
* POST /api/mikrotik/ping
* Пингует внешний ресурс через указанный MikroTik (jumphost),
* по возможности привязываясь к интерфейсу, соответствующему gateway.
*
* Body:
* - serverId: ID/имя сервера из servers.json (обязательно)
* - gatewayIp?: IP шлюза (remote IP туннеля или прямой gateway)
* - interfaceName?: имя интерфейса RouterOS (если знаем его заранее)
* - target?: адрес для ping (по умолчанию www.gstatic.com)
* - count?: количество пакетов (по умолчанию 5)
* Выполнить один пинг через RouterOS (без HTTP, без кеша).
* @returns Promise<{ avgMs, minMs, maxMs, loss }>
*/
async function pingViaInterface(req, res) {
try {
let {
serverId,
gatewayIp,
interfaceName,
target,
count = 5,
} = req.body || {};
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const uiSettings = await loadUiSettings();
// Цель пинга: из тела запроса, иначе из настроек (Домен для пинга), иначе по умолчанию
if (target == null || String(target).trim() === '') {
const pingDomain = (uiSettings.pingDomain && String(uiSettings.pingDomain).trim()) || '';
target = pingDomain || 'www.gstatic.com';
} else {
target = String(target).trim();
}
const pingCacheMinutes = Math.max(0, parseInt(uiSettings.pingCacheMinutes, 10) || 0);
const cacheKey = pingCacheMinutes > 0 ? pingCacheKey(serverId, gatewayIp, target) : null;
if (cacheKey) {
try {
const data = await readS3TextObject(cacheKey).catch(() => null);
if (data?.body) {
const cached = JSON.parse(data.body);
const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
const ttlMs = pingCacheMinutes * 60 * 1000;
if (cachedAt && Date.now() - cachedAt < ttlMs) {
return res.json({
ok: true,
avgMs: cached.avgMs,
minMs: cached.minMs,
maxMs: cached.maxMs,
loss: cached.loss,
});
}
}
} catch (_) {
// кеш протух или невалиден — выполняем реальный пинг
}
}
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;
async function runPingViaRouter(serverId, gatewayIp, target, count = 5, interfaceName = null) {
const servers = await readServersFromS3();
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
if (!server || server.type !== 'jumphost') {
throw new Error('Jumphost server not found');
}
const creds = getMikrotikCredentials(server);
if (!creds) {
throw new Error('MikroTik credentials not configured for this server');
}
const client = createRosClient(creds);
let ifaceName = interfaceName || null;
if (!ifaceName && gatewayIp) {
const config = await loadNetworkConfig();
@@ -520,18 +460,72 @@ async function pingViaInterface(req, res) {
? Number(String(summary['packet-loss']).replace('%', ''))
: null;
const result = { ok: true, avgMs, minMs, maxMs, loss };
return { ok: true, avgMs, minMs, maxMs, loss };
}
/**
* POST /api/mikrotik/ping
* Пингует внешний ресурс через указанный MikroTik (jumphost),
* по возможности привязываясь к интерфейсу, соответствующему gateway.
*
* Body:
* - serverId: ID/имя сервера из servers.json (обязательно)
* - gatewayIp?: IP шлюза (remote IP туннеля или прямой gateway)
* - interfaceName?: имя интерфейса RouterOS (если знаем его заранее)
* - target?: адрес для ping (по умолчанию www.gstatic.com)
* - count?: количество пакетов (по умолчанию 5)
*/
async function pingViaInterface(req, res) {
try {
let {
serverId,
gatewayIp,
interfaceName,
target,
count = 5,
} = req.body || {};
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const uiSettings = await loadUiSettings();
if (target == null || String(target).trim() === '') {
const pingDomain = (uiSettings.pingDomain && String(uiSettings.pingDomain).trim()) || '';
target = pingDomain || 'www.gstatic.com';
} else {
target = String(target).trim();
}
const pingCacheMinutes = Math.max(0, parseInt(uiSettings.pingCacheMinutes, 10) || 0);
const cacheKey = pingCacheMinutes > 0 ? pingCacheKey(serverId, gatewayIp, target) : null;
if (cacheKey) {
try {
const data = await readS3TextObject(cacheKey).catch(() => null);
if (data?.body) {
const cached = JSON.parse(data.body);
const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
const ttlMs = pingCacheMinutes * 60 * 1000;
if (cachedAt && Date.now() - cachedAt < ttlMs) {
return res.json({
ok: true,
avgMs: cached.avgMs,
minMs: cached.minMs,
maxMs: cached.maxMs,
loss: cached.loss,
});
}
}
} catch (_) {}
}
const result = await runPingViaRouter(serverId, gatewayIp, target, count, interfaceName);
if (cacheKey) {
writeS3JsonObject(cacheKey, {
avgMs,
minMs,
maxMs,
loss,
avgMs: result.avgMs,
minMs: result.minMs,
maxMs: result.maxMs,
loss: result.loss,
cachedAt: Date.now(),
}).catch((err) => console.warn('[mikrotik][pingViaInterface] cache write failed:', err?.message));
}
return res.json(result);
} catch (error) {
const msg = error.response?.data?.message || error.message || 'Ping failed';
@@ -845,4 +839,5 @@ module.exports = {
runScript,
tracerouteViaGateway,
pingViaInterface,
runPingViaRouter,
};
+57 -8
View File
@@ -339,9 +339,63 @@ const PING_SERVICES = [
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443 },
];
// GET /api/ping-services — RTT до Google, Cloudflare, Yandex, Instagram (для карточек на дашборде)
async function getPingServices(req, res) {
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
const jsonText = await streamToString(data.Body);
const parsed = JSON.parse(jsonText || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
// GET /api/ping-services — RTT до Google, Cloudflare, Yandex, Instagram (веб или через RouterOS по настройке)
async function getPingServices(req, res) {
const fallbackPayload = {
google: { id: 'google', name: 'Google', host: '8.8.8.8', ms: null },
cloudflare: { id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', ms: null },
yandex: { id: 'yandex', name: 'Yandex', host: 'ya.ru', ms: null },
instagram: { id: 'instagram', name: 'Instagram', host: 'instagram.com', ms: null },
};
try {
const uiSettings = await loadUiSettingsSync();
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
if (viaRouter) {
const { runPingViaRouter } = require('./mikrotikConfigRoutes');
const { readServersFromS3 } = require('./serversRoutes');
const servers = await readServersFromS3();
const jumphosts = servers.filter((s) => s && String(s.type || '').toLowerCase() === 'jumphost');
const serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (jumphosts[0] && (jumphosts[0].id || jumphosts[0].dns || jumphosts[0].ip));
const gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null;
let gatewayIpResolved = gatewayIp;
if (!gatewayIpResolved && serverId && jumphosts.length > 0) {
const server = jumphosts.find((s) => (s.id || s.dns || s.ip) === serverId) || jumphosts[0];
const gateways = Array.isArray(server.gateways) ? server.gateways : [];
const primary = gateways.find((g) => g && g.primary) || gateways[0];
gatewayIpResolved = primary && (primary.ip || primary.remoteIp) ? (primary.ip || primary.remoteIp) : null;
}
if (!serverId || !gatewayIpResolved) {
return res.json(fallbackPayload);
}
const results = await Promise.all(
PING_SERVICES.map(async (svc) => {
try {
const result = await runPingViaRouter(serverId, gatewayIpResolved, svc.host, 3);
const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null;
return { id: svc.id, name: svc.name, host: svc.host, ms };
} catch (_) {
return { id: svc.id, name: svc.name, host: svc.host, ms: null };
}
})
);
const byId = {};
results.forEach((r) => { byId[r.id] = r; });
return res.json(byId);
}
const results = await Promise.all(
PING_SERVICES.map(async (svc) => {
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
@@ -353,12 +407,7 @@ async function getPingServices(req, res) {
res.json(byId);
} catch (e) {
console.error('ping-services error', e);
res.status(500).json({
google: { id: 'google', name: 'Google', host: '8.8.8.8', ms: null },
cloudflare: { id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', ms: null },
yandex: { id: 'yandex', name: 'Yandex', host: 'ya.ru', ms: null },
instagram: { id: 'instagram', name: 'Instagram', host: 'instagram.com', ms: null },
});
res.status(500).json(fallbackPayload);
}
}