From 431314b9735158d403dd162f831f30fc3bb8e9cd Mon Sep 17 00:00:00 2001 From: shats Date: Sun, 22 Feb 2026 20:40:57 +0700 Subject: [PATCH] feat(PingServicesScheduler): implement ping services scheduler endpoints and cache refresh logic for improved service monitoring --- backend/routes/miscRoutes.js | 135 ++++++++++++++-------- backend/routes/schedulerRoutes.js | 57 +++++++++ backend/server.js | 11 ++ backend/services/pingServicesScheduler.js | 96 +++++++++++++++ 4 files changed, 249 insertions(+), 50 deletions(-) create mode 100644 backend/services/pingServicesScheduler.js diff --git a/backend/routes/miscRoutes.js b/backend/routes/miscRoutes.js index 96baeee..691d408 100644 --- a/backend/routes/miscRoutes.js +++ b/backend/routes/miscRoutes.js @@ -368,6 +368,88 @@ async function loadUiSettingsSync() { const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_'; +/** + * Обновить кеш пинг-сервисов: выполнить пинги по списку из настроек и записать результат в S3. + * Используется планировщиком и при промахе кеша в getPingServices. + * @returns {Promise>} byId + */ +async function refreshPingServicesCache() { + const uiSettings = await loadUiSettingsSync(); + const servicesList = getPingServicesListFromSettings(uiSettings); + const fallbackPayload = {}; + servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; }); + + const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router'; + const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0); + + let serverId = null; + let gatewayIp = null; + if (viaRouter) { + const { readServersFromS3 } = require('./serversRoutes'); + const servers = await readServersFromS3(); + const routerServers = servers.filter( + (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') + ); + serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip)); + gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null; + } + + const cacheKey = cacheSeconds > 0 + ? PING_SERVICES_CACHE_KEY_PREFIX + (viaRouter ? 'router' : 'web') + '_' + String(serverId || '').replace(/[^a-zA-Z0-9.-]/g, '_') + '_' + String(gatewayIp || '').replace(/[^a-zA-Z0-9.]/g, '_') + : null; + + if (viaRouter) { + const { runPingViaRouter } = require('./mikrotikConfigRoutes'); + const { readServersFromS3 } = require('./serversRoutes'); + const servers = await readServersFromS3(); + const routerServers = servers.filter( + (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') + ); + let gatewayIpResolved = gatewayIp; + if (!gatewayIpResolved && serverId && routerServers.length > 0) { + const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[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) { + console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)'); + return fallbackPayload; + } + const results = await Promise.all( + servicesList.map(async (svc) => { + try { + const result = await runPingViaRouter(serverId, gatewayIpResolved || null, 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 (err) { + console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err); + return { id: svc.id, name: svc.name, host: svc.host, ms: null }; + } + }) + ); + const byId = {}; + results.forEach((r) => { byId[r.id] = r; }); + if (cacheKey) { + writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); + } + return byId; + } + + const results = await Promise.all( + servicesList.map(async (svc) => { + const ms = await measureTcpRtt(svc.host, svc.port, 6000); + return { id: svc.id, name: svc.name, host: svc.host, ms }; + }) + ); + const byId = {}; + results.forEach((r) => { byId[r.id] = r; }); + if (cacheKey) { + writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); + } + return byId; +} + // GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора) async function getPingServicesList(req, res) { try { @@ -420,56 +502,8 @@ async function getPingServices(req, res) { } catch (_) {} } - if (viaRouter) { - const { runPingViaRouter } = require('./mikrotikConfigRoutes'); - const { readServersFromS3 } = require('./serversRoutes'); - const servers = await readServersFromS3(); - const routerServers = servers.filter( - (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') - ); - let gatewayIpResolved = gatewayIp; - if (!gatewayIpResolved && serverId && routerServers.length > 0) { - const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[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) { - console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)'); - return res.json(fallbackPayload); - } - const results = await Promise.all( - servicesList.map(async (svc) => { - try { - const result = await runPingViaRouter(serverId, gatewayIpResolved || null, 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 (err) { - console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err); - return { id: svc.id, name: svc.name, host: svc.host, ms: null }; - } - }) - ); - const byId = {}; - results.forEach((r) => { byId[r.id] = r; }); - if (cacheKey) { - writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); - } - return res.json(byId); - } - - const results = await Promise.all( - servicesList.map(async (svc) => { - const ms = await measureTcpRtt(svc.host, svc.port, 6000); - return { id: svc.id, name: svc.name, host: svc.host, ms }; - }) - ); - const byId = {}; - results.forEach((r) => { byId[r.id] = r; }); - if (cacheKey) { - writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); - } - res.json(byId); + const byId = await refreshPingServicesCache(); + return res.json(byId); } catch (e) { console.error('ping-services error', e); const fallback = {}; @@ -695,5 +729,6 @@ module.exports = { postUiSettings, getPingServicesList, getPingServices, + refreshPingServicesCache, }; diff --git a/backend/routes/schedulerRoutes.js b/backend/routes/schedulerRoutes.js index 90910dc..ef07750 100644 --- a/backend/routes/schedulerRoutes.js +++ b/backend/routes/schedulerRoutes.js @@ -5,6 +5,7 @@ const { sendError, sendOk } = require('../middleware/errorHandler'); const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service'); const networkMapScheduler = require('../services/networkMapScheduler'); +const pingServicesScheduler = require('../services/pingServicesScheduler'); const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; @@ -17,6 +18,11 @@ const SCHEDULER_KEYS = [ 'networkMapSchedulerLogMaxLines', ]; +const PING_SERVICES_SCHEDULER_KEYS = [ + 'pingServicesSchedulerEnabled', + 'pingServicesSchedulerIntervalMinutes', +]; + async function readUiSettings() { try { const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); @@ -124,10 +130,61 @@ async function getNetworkMapCache(req, res) { } } +// === Ping services scheduler === + +/** GET /api/scheduler/ping-services/settings */ +async function getPingServicesSchedulerSettings(req, res) { + try { + const ui = await readUiSettings(); + const settings = pingServicesScheduler.getSettingsFromUiSettings(ui); + const nextRunAt = pingServicesScheduler.getNextRunAt(settings); + return res.json({ ...settings, nextRunAt: nextRunAt || undefined }); + } catch (e) { + console.error('[scheduler] getPingServicesSettings', e); + return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER'); + } +} + +/** PATCH /api/scheduler/ping-services/settings */ +async function patchPingServicesSchedulerSettings(req, res) { + try { + const body = req.body || {}; + const current = await readUiSettings(); + for (const key of PING_SERVICES_SCHEDULER_KEYS) { + if (Object.prototype.hasOwnProperty.call(body, key)) { + current[key] = body[key]; + } + } + await writeS3JsonObject(UI_SETTINGS_KEY, current); + const settings = pingServicesScheduler.getSettingsFromUiSettings(current); + return res.json(settings); + } catch (e) { + console.error('[scheduler] patchPingServicesSettings', e); + return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER'); + } +} + +/** POST /api/scheduler/ping-services/run-now */ +async function runPingServicesSchedulerNow(req, res) { + try { + const { refreshPingServicesCache } = require('./miscRoutes'); + refreshPingServicesCache().catch((err) => { + console.error('[scheduler] ping-services run-now failed', err); + }); + return sendOk(res, { message: 'Обновление кеша пинг-сервисов запущено в фоне' }); + } catch (e) { + console.error('[scheduler] ping-services run-now', e); + return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER'); + } +} + module.exports = { getNetworkMapSchedulerSettings, patchNetworkMapSchedulerSettings, getNetworkMapSchedulerLogs, runNetworkMapSchedulerNow, getNetworkMapCache, + getPingServicesSchedulerSettings, + patchPingServicesSchedulerSettings, + runPingServicesSchedulerNow, }; diff --git a/backend/server.js b/backend/server.js index 360b199..5e2c3ae 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,7 @@ const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); +const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); const schedulerRoutes = require('./routes/schedulerRoutes'); const app = express(); @@ -493,6 +494,11 @@ app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.p app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs); app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow); +// === SCHEDULER (пинг сервисов: обновление кеша по расписанию, по умолчанию каждые 2 мин) === +app.get('/api/scheduler/ping-services/settings', schedulerRoutes.getPingServicesSchedulerSettings); +app.patch('/api/scheduler/ping-services/settings', writeLimiter, schedulerRoutes.patchPingServicesSchedulerSettings); +app.post('/api/scheduler/ping-services/run-now', writeLimiter, schedulerRoutes.runPingServicesSchedulerNow); + // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { const { config } = req.body; @@ -530,5 +536,10 @@ app.listen(port, () => { } catch (e) { logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler'); } + try { + initPingServicesScheduler(logger); + } catch (e) { + logger.error({ component: 'ping-services-scheduler', err: e && e.message }, 'Failed to start ping services scheduler'); + } }); diff --git a/backend/services/pingServicesScheduler.js b/backend/services/pingServicesScheduler.js new file mode 100644 index 0000000..bf8448a --- /dev/null +++ b/backend/services/pingServicesScheduler.js @@ -0,0 +1,96 @@ +/** + * Планировщик пинга сервисов из списка настроек. + * Периодически вызывает refreshPingServicesCache и обновляет кеш для GET /api/ping-services. + */ + +const { readS3TextObject } = require('./s3Service'); +const { refreshPingServicesCache } = require('../routes/miscRoutes'); + +const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; + +const DEFAULT_INTERVAL_MINUTES = 2; + +let schedulerTimer = null; +let lastScheduledRunAt = null; + +function getSettingsFromUiSettings(uiSettings) { + return { + enabled: uiSettings.pingServicesSchedulerEnabled !== false, + intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.pingServicesSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)), + }; +} + +/** + * Время следующего запуска по расписанию (timestamp или null если выключено). + */ +function getNextRunAt(settings) { + if (!settings || !settings.enabled) return null; + const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000; + const from = lastScheduledRunAt || Date.now(); + return from + intervalMs; +} + +/** + * Запустить планировщик пинг-сервисов. + * @param {object} logger - pino logger + */ +function initPingServicesScheduler(logger) { + const log = logger || console; + + async function tick() { + lastScheduledRunAt = Date.now(); + let uiSettings; + try { + const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); + uiSettings = JSON.parse(raw?.body || '{}') || {}; + } catch (_) { + return; + } + const settings = getSettingsFromUiSettings(uiSettings); + if (!settings.enabled) return; + + log.info({ component: 'ping-services-scheduler' }, 'Running ping services refresh'); + refreshPingServicesCache().catch((err) => { + log.error({ component: 'ping-services-scheduler', err: err?.message }, 'Ping services refresh failed'); + }); + } + + async function schedule() { + let uiSettings; + try { + const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); + uiSettings = JSON.parse(raw?.body || '{}') || {}; + } catch (_) { + return; + } + const settings = getSettingsFromUiSettings(uiSettings); + if (!settings.enabled) { + log.info({ component: 'ping-services-scheduler' }, 'Ping services scheduler disabled in settings'); + return; + } + const intervalMs = settings.intervalMinutes * 60 * 1000; + log.info( + { component: 'ping-services-scheduler', intervalMinutes: settings.intervalMinutes }, + 'Ping services scheduler started (first run in 15 s, then every %d min)', + settings.intervalMinutes + ); + schedulerTimer = setInterval(tick, intervalMs); + setTimeout(() => tick(), 15 * 1000); + } + + schedule(); +} + +function stopPingServicesScheduler() { + if (schedulerTimer) { + clearInterval(schedulerTimer); + schedulerTimer = null; + } +} + +module.exports = { + initPingServicesScheduler, + stopPingServicesScheduler, + getSettingsFromUiSettings, + getNextRunAt, +};