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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
This commit is contained in:
@@ -294,81 +294,21 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/mikrotik/ping
|
* Выполнить один пинг через RouterOS (без HTTP, без кеша).
|
||||||
* Пингует внешний ресурс через указанный MikroTik (jumphost),
|
* @returns Promise<{ avgMs, minMs, maxMs, loss }>
|
||||||
* по возможности привязываясь к интерфейсу, соответствующему 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) {
|
async function runPingViaRouter(serverId, gatewayIp, target, count = 5, interfaceName = null) {
|
||||||
try {
|
const servers = await readServersFromS3();
|
||||||
let {
|
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
|
||||||
serverId,
|
if (!server || server.type !== 'jumphost') {
|
||||||
gatewayIp,
|
throw new Error('Jumphost server not found');
|
||||||
interfaceName,
|
}
|
||||||
target,
|
const creds = getMikrotikCredentials(server);
|
||||||
count = 5,
|
if (!creds) {
|
||||||
} = req.body || {};
|
throw new Error('MikroTik credentials not configured for this server');
|
||||||
|
}
|
||||||
if (!serverId) {
|
const client = createRosClient(creds);
|
||||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
let ifaceName = interfaceName || null;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!ifaceName && gatewayIp) {
|
if (!ifaceName && gatewayIp) {
|
||||||
const config = await loadNetworkConfig();
|
const config = await loadNetworkConfig();
|
||||||
@@ -520,18 +460,72 @@ async function pingViaInterface(req, res) {
|
|||||||
? Number(String(summary['packet-loss']).replace('%', ''))
|
? Number(String(summary['packet-loss']).replace('%', ''))
|
||||||
: null;
|
: 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) {
|
if (cacheKey) {
|
||||||
writeS3JsonObject(cacheKey, {
|
writeS3JsonObject(cacheKey, {
|
||||||
avgMs,
|
avgMs: result.avgMs,
|
||||||
minMs,
|
minMs: result.minMs,
|
||||||
maxMs,
|
maxMs: result.maxMs,
|
||||||
loss,
|
loss: result.loss,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
}).catch((err) => console.warn('[mikrotik][pingViaInterface] cache write failed:', err?.message));
|
}).catch((err) => console.warn('[mikrotik][pingViaInterface] cache write failed:', err?.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error.response?.data?.message || error.message || 'Ping failed';
|
const msg = error.response?.data?.message || error.message || 'Ping failed';
|
||||||
@@ -845,4 +839,5 @@ module.exports = {
|
|||||||
runScript,
|
runScript,
|
||||||
tracerouteViaGateway,
|
tracerouteViaGateway,
|
||||||
pingViaInterface,
|
pingViaInterface,
|
||||||
|
runPingViaRouter,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -339,9 +339,63 @@ const PING_SERVICES = [
|
|||||||
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443 },
|
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/ping-services — RTT до Google, Cloudflare, Yandex, Instagram (для карточек на дашборде)
|
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
||||||
async function getPingServices(req, res) {
|
async function loadUiSettingsSync() {
|
||||||
try {
|
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(
|
const results = await Promise.all(
|
||||||
PING_SERVICES.map(async (svc) => {
|
PING_SERVICES.map(async (svc) => {
|
||||||
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
|
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
|
||||||
@@ -353,12 +407,7 @@ async function getPingServices(req, res) {
|
|||||||
res.json(byId);
|
res.json(byId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ping-services error', e);
|
console.error('ping-services error', e);
|
||||||
res.status(500).json({
|
res.status(500).json(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 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const [pingCacheMinutes, setPingCacheMinutes] = useState('');
|
const [pingCacheMinutes, setPingCacheMinutes] = useState('');
|
||||||
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
|
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
|
||||||
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
|
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
|
||||||
|
const [pingServicesSource, setPingServicesSource] = useState('web');
|
||||||
|
const [pingServicesServerId, setPingServicesServerId] = useState('');
|
||||||
|
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -41,6 +44,9 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '');
|
setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '');
|
||||||
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
|
||||||
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
|
||||||
|
setPingServicesSource(String(data?.pingServicesSource || 'web').toLowerCase() === 'router' ? 'router' : 'web');
|
||||||
|
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
|
||||||
|
setPingServicesGatewayIp(String(data?.pingServicesGatewayIp || '').trim());
|
||||||
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,6 +111,9 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
|
pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
|
||||||
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
||||||
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
|
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
|
||||||
|
pingServicesSource: pingServicesSource === 'router' ? 'router' : 'web',
|
||||||
|
pingServicesServerId: String(pingServicesServerId || '').trim(),
|
||||||
|
pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(),
|
||||||
};
|
};
|
||||||
const payload = {
|
const payload = {
|
||||||
settings: mergedSettings,
|
settings: mergedSettings,
|
||||||
@@ -227,6 +236,53 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-top">
|
||||||
|
<h6 className="mb-2">Пинг сервисов на главной</h6>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="form-label small">Источник пинга</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={pingServicesSource}
|
||||||
|
onChange={(e) => setPingServicesSource(e.target.value)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<option value="web">Веб (TCP с сервера приложения)</option>
|
||||||
|
<option value="router">Роутер (RouterOS API)</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text small">
|
||||||
|
«Веб» — задержка до 8.8.8.8, 1.1.1.1 и др. с сервера. «Роутер» — пинг через выбранный MikroTik (jumphost).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pingServicesSource === 'router' && (
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Роутер (jumphost)"
|
||||||
|
name="pingServicesServerId"
|
||||||
|
type="text"
|
||||||
|
value={pingServicesServerId}
|
||||||
|
onChange={setPingServicesServerId}
|
||||||
|
placeholder="ID, DNS или IP jumphost"
|
||||||
|
helpText="Оставьте пустым — будет использован первый jumphost из списка серверов."
|
||||||
|
disabled={loading || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="IP шлюза (опционально)"
|
||||||
|
name="pingServicesGatewayIp"
|
||||||
|
type="text"
|
||||||
|
value={pingServicesGatewayIp}
|
||||||
|
onChange={setPingServicesGatewayIp}
|
||||||
|
placeholder="IP шлюза"
|
||||||
|
helpText="Оставьте пустым — берётся первый шлюз роутера."
|
||||||
|
disabled={loading || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-top">
|
<div className="mt-4 pt-3 border-top">
|
||||||
<h6 className="mb-3">Настройка PTR зоны</h6>
|
<h6 className="mb-3">Настройка PTR зоны</h6>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user