From dbca38d31a04c3c21ea3aa497c236e969a681b2c Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 18 Feb 2026 12:41:20 +0700 Subject: [PATCH] feat(NetworkMapScheduler): integrate network map scheduler functionality with API routes and frontend components for enhanced network monitoring --- backend/routes/mikrotikConfigRoutes.js | 1 + backend/routes/schedulerRoutes.js | 125 +++++++ backend/server.js | 28 +- backend/services/networkMapScheduler.js | 416 +++++++++++++++++++++ frontend/src/App.jsx | 11 +- frontend/src/NetworkMapDashboard.jsx | 20 +- frontend/src/NetworkMapSchedulerPage.jsx | 310 +++++++++++++++ frontend/src/components/CommandPalette.jsx | 4 +- 8 files changed, 902 insertions(+), 13 deletions(-) create mode 100644 backend/routes/schedulerRoutes.js create mode 100644 backend/services/networkMapScheduler.js create mode 100644 frontend/src/NetworkMapSchedulerPage.jsx diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 74c9470..e1df7dc 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -1155,4 +1155,5 @@ module.exports = { pingViaInterface, runPingViaRouter, speedTestViaTunnel, + loadNetworkConfig, }; diff --git a/backend/routes/schedulerRoutes.js b/backend/routes/schedulerRoutes.js new file mode 100644 index 0000000..9eace34 --- /dev/null +++ b/backend/routes/schedulerRoutes.js @@ -0,0 +1,125 @@ +/** + * Роуты планировщика карты сети: настройки, логи, ручной запуск, кеш. + */ + +const { sendError, sendOk } = require('../middleware/errorHandler'); +const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service'); +const networkMapScheduler = require('../services/networkMapScheduler'); + +const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; +const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; + +const SCHEDULER_KEYS = [ + 'networkMapSchedulerEnabled', + 'networkMapSchedulerIntervalMinutes', + 'networkMapSchedulerRunPing', + 'networkMapSchedulerRunSpeedTest', + 'networkMapSchedulerLogMaxLines', +]; + +async function readUiSettings() { + try { + const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); + const parsed = JSON.parse(data?.body || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_) { + return {}; + } +} + +/** GET /api/scheduler/network-map/settings */ +async function getNetworkMapSchedulerSettings(req, res) { + try { + const ui = await readUiSettings(); + const settings = networkMapScheduler.getSettingsFromUiSettings(ui); + return res.json(settings); + } catch (e) { + console.error('[scheduler] getSettings', e); + return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER'); + } +} + +/** PATCH /api/scheduler/network-map/settings — обновить только ключи планировщика в ui-settings */ +async function patchNetworkMapSchedulerSettings(req, res) { + try { + const body = req.body || {}; + const current = await readUiSettings(); + for (const key of SCHEDULER_KEYS) { + if (Object.prototype.hasOwnProperty.call(body, key)) { + current[key] = body[key]; + } + } + if (Object.prototype.hasOwnProperty.call(body, 'networkMapSchedulerLogMaxLines')) { + networkMapScheduler.setLogMaxLines(body.networkMapSchedulerLogMaxLines); + } + await writeS3JsonObject(UI_SETTINGS_KEY, current); + const settings = networkMapScheduler.getSettingsFromUiSettings(current); + return res.json(settings); + } catch (e) { + console.error('[scheduler] patchSettings', e); + return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER'); + } +} + +/** GET /api/scheduler/network-map/logs */ +async function getNetworkMapSchedulerLogs(req, res) { + try { + const lines = networkMapScheduler.getLogs(); + return res.json({ lines }); + } catch (e) { + console.error('[scheduler] getLogs', e); + return sendError(res, 500, 'Не удалось прочитать логи', 'E_SCHEDULER'); + } +} + +/** POST /api/scheduler/network-map/run-now — один запуск по текущим настройкам */ +async function runNetworkMapSchedulerNow(req, res) { + try { + const port = Number(process.env.PORT) || 3001; + const baseUrl = `http://127.0.0.1:${port}`; + const ui = await readUiSettings(); + const settings = networkMapScheduler.getSettingsFromUiSettings(ui); + + networkMapScheduler.runJob({ + baseUrl, + runPing: settings.runPing, + runSpeedTest: settings.runSpeedTest, + }).catch((err) => { + console.error('[scheduler] run-now failed', err); + }); + + return sendOk(res, { message: 'Запуск выполняется в фоне' }); + } catch (e) { + console.error('[scheduler] run-now', e); + return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER'); + } +} + +/** GET /api/network-map-cache — кеш пингов и скоростей для карты сети */ +async function getNetworkMapCache(req, res) { + try { + const data = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null); + if (!data?.body) { + return res.json({ pingMap: {}, speedMap: {}, updatedAt: null }); + } + const parsed = JSON.parse(data.body); + return res.json({ + pingMap: parsed.pingMap && typeof parsed.pingMap === 'object' ? parsed.pingMap : {}, + speedMap: parsed.speedMap && typeof parsed.speedMap === 'object' ? parsed.speedMap : {}, + updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null, + }); + } catch (e) { + console.error('[network-map-cache] get', e); + return res.json({ pingMap: {}, speedMap: {}, updatedAt: null }); + } +} + +module.exports = { + getNetworkMapSchedulerSettings, + patchNetworkMapSchedulerSettings, + getNetworkMapSchedulerLogs, + runNetworkMapSchedulerNow, + getNetworkMapCache, +}; +<|tool▁calls▁begin|><|tool▁call▁begin|> +StrReplace \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 179d652..4bf39a1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -31,6 +31,8 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes'); const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); +const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); +const schedulerRoutes = require('./routes/schedulerRoutes'); const app = express(); const port = Number(process.env.PORT) || 3001; @@ -476,6 +478,15 @@ app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups); app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow); +// === NETWORK MAP CACHE (для быстрой загрузки карты сети) === +app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache); + +// === SCHEDULER (карта сети: пинг и скорость по расписанию) === +app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings); +app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings); +app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs); +app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow); + // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { const { config } = req.body; @@ -503,12 +514,15 @@ app.use(errorHandler); app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); + try { + initMikrotikBackupScheduler(logger); + } catch (e) { + logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler'); + } + try { + initNetworkMapScheduler(logger, port); + } catch (e) { + logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler'); + } }); -// Запускаем планировщик автоматического бэкапа MikroTik (если включен через ENV) -try { - initMikrotikBackupScheduler(logger); -} catch (e) { - logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler'); -} - diff --git a/backend/services/networkMapScheduler.js b/backend/services/networkMapScheduler.js new file mode 100644 index 0000000..7ace435 --- /dev/null +++ b/backend/services/networkMapScheduler.js @@ -0,0 +1,416 @@ +/** + * Планировщик пингов и замеров скорости для карты сети. + * Периодически вызывает пинг и speed-test по связям из network-config, + * сохраняет результаты в S3 (кеш по рёбрам + агрегат network-map-cache/latest.json). + * Логи пишет в кольцевой буфер для просмотра в UI. + */ + +const { readServersFromS3 } = require('../routes/serversRoutes'); +const { loadNetworkConfig } = require('../routes/mikrotikConfigRoutes'); +const { readS3TextObject, writeS3JsonObject } = require('./s3Service'); + +const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; +const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; + +const DEFAULT_INTERVAL_MINUTES = 5; +const DEFAULT_LOG_MAX_LINES = 500; + +/** Кольцевой буфер логов */ +class LogBuffer { + constructor(maxLines = 500) { + this.maxLines = Math.max(100, maxLines); + this.lines = []; + this.index = 0; + } + + push(line) { + const ts = new Date().toISOString(); + const entry = `${ts} ${line}`; + if (this.lines.length < this.maxLines) { + this.lines.push(entry); + } else { + this.lines[this.index] = entry; + this.index = (this.index + 1) % this.maxLines; + } + } + + getLines() { + if (this.lines.length < this.maxLines) return [...this.lines]; + return [...this.lines.slice(this.index), ...this.lines.slice(0, this.index)]; + } + + setMaxLines(n) { + this.maxLines = Math.max(100, n); + if (this.lines.length > this.maxLines) { + this.lines = this.getLines().slice(-this.maxLines); + this.index = 0; + } + } +} + +const logBuffer = new LogBuffer(DEFAULT_LOG_MAX_LINES); + +async function loadUiSettings() { + try { + const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); + const parsed = JSON.parse(data?.body || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_) { + return {}; + } +} + +/** Построить список связей как в NetworkMapDashboard (tunnelInterfaces → connections) */ +async function buildConnections() { + const [serversList, config, uiSettings] = await Promise.all([ + readServersFromS3(), + loadNetworkConfig(), + loadUiSettings(), + ]); + const servers = Array.isArray(serversList) ? serversList : []; + const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; + + const getServer = (serverId) => + servers.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId); + + const connList = []; + for (const iface of tunnelInterfaces) { + if (!iface.serverId || !iface.serverId2) continue; + const s1 = getServer(iface.serverId); + const s2 = getServer(iface.serverId2); + if (!s1 || !s2 || s1.ip === s2.ip) continue; + const s1Key = s1.id || s1.dns || s1.ip; + const s2Key = s2.id || s2.dns || s2.ip; + if (!s1Key || !s2Key) continue; + const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase()); + const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase()); + connList.push({ + from: s1.ip, + to: s2.ip, + fromKey: s1Key, + toKey: s2Key, + internalFromTo: iface.remoteIp || null, + internalToFrom: iface.localIp || null, + speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null, + interfaceName: iface.name || null, + }); + } + return { connections: connList, servers, uiSettings }; +} + +function edgePingKey(a, b) { + return [String(a), String(b)].sort().join(':'); +} + +function speedKey(key1, key2) { + return [String(key1), String(key2)].sort().join(':'); +} + +/** Параллельно с лимитом */ +async function runWithLimit(tasks, limit = 4) { + const results = []; + let index = 0; + async function runNext() { + const i = index++; + if (i >= tasks.length) return; + try { + results[i] = { value: await tasks[i]() }; + } catch (err) { + results[i] = { error: err }; + } + await runNext(); + } + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext()); + await Promise.all(workers); + return results; +} + +/** Один тест на ноду в момент времени */ +async function runWithOnePerKey(tasks, getKey) { + const n = tasks.length; + const keyToIndices = new Map(); + for (let i = 0; i < n; i++) { + const k = String(getKey(i)); + if (!keyToIndices.has(k)) keyToIndices.set(k, []); + keyToIndices.get(k).push(i); + } + const results = []; + for (let i = 0; i < n; i++) results[i] = null; + const keys = Array.from(keyToIndices.keys()); + let round = 0; + while (true) { + const batch = []; + for (const k of keys) { + const indices = keyToIndices.get(k); + if (round < indices.length) batch.push(indices[round]); + } + if (batch.length === 0) break; + await Promise.all( + batch.map(async (i) => { + try { + results[i] = { value: await tasks[i]() }; + } catch (err) { + results[i] = { error: err }; + } + }) + ); + round++; + } + return results; +} + +/** + * Выполнить одну итерацию: пинг и speed-test по связям, записать кеш и логи. + * @param {object} opts - { baseUrl, runPing, runSpeedTest, log } + */ +async function runJob(opts) { + const { baseUrl, runPing = true, runSpeedTest = true, log = (msg) => logBuffer.push(msg) } = opts; + const pingMap = {}; + const speedMap = {}; + + try { + const { connections, servers } = await buildConnections(); + if (connections.length === 0) { + log('Нет связей в network-config (tunnelInterfaces).'); + await writeCache(pingMap, speedMap); + return { pingMap, speedMap }; + } + + log(`Связей: ${connections.length}. Пинг: ${runPing ? 'да' : 'нет'}, Скорость: ${runSpeedTest ? 'да' : 'нет'}`); + + if (runPing) { + const pingTasks = []; + for (const c of connections) { + const from = String(c.from); + const to = String(c.to); + if (!from || !to || from === to) continue; + const srcFrom = servers.find((s) => s.ip === from); + const srcTo = servers.find((s) => s.ip === to); + const canPingFrom = srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo; + const canPingTo = srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom; + + if (canPingFrom) { + pingTasks.push(async () => { + const ekey = edgePingKey(from, to); + try { + const res = await fetch(`${baseUrl}/api/mikrotik/ping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: c.fromKey, + target: c.internalFromTo, + gatewayIp: c.internalFromTo, + count: 3, + }), + }); + const data = await res.json().catch(() => ({})); + const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; + pingMap[ekey] = ms; + log(`Пинг ${from} → ${to}: ${ms != null ? ms + ' ms' : 'ошибка'}`); + return ms; + } catch (e) { + pingMap[ekey] = null; + log(`Пинг ${from} → ${to}: ошибка ${e?.message || e}`); + return null; + } + }); + } else if (canPingTo) { + pingTasks.push(async () => { + const ekey = edgePingKey(from, to); + try { + const res = await fetch(`${baseUrl}/api/mikrotik/ping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: c.toKey, + target: c.internalToFrom, + gatewayIp: c.internalToFrom, + count: 3, + }), + }); + const data = await res.json().catch(() => ({})); + const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; + pingMap[ekey] = ms; + log(`Пинг ${to} → ${from}: ${ms != null ? ms + ' ms' : 'ошибка'}`); + return ms; + } catch (e) { + pingMap[ekey] = null; + log(`Пинг ${to} → ${from}: ошибка ${e?.message || e}`); + return null; + } + }); + } + } + await runWithLimit(pingTasks, 4); + } + + if (runSpeedTest) { + const withSpeed = connections.filter((c) => c.speedTestServerId && c.interfaceName); + const speedTasks = withSpeed.map((c) => async () => { + const key = speedKey(c.fromKey, c.toKey); + try { + const res = await fetch(`${baseUrl}/api/mikrotik/speed-test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: c.speedTestServerId, + interfaceName: c.interfaceName, + }), + signal: (() => { + const c = new AbortController(); + setTimeout(() => c.abort(), 120000); + return c.signal; + })(), + }); + const data = await res.json().catch(() => ({})); + if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) { + speedMap[key] = { + tcpDownloadBps: data.tcpDownloadBps, + tcpUploadBps: data.tcpUploadBps, + cached: data.cached === true, + durationSeconds: data.durationSeconds, + }; + const down = data.tcpDownloadBps != null ? (data.tcpDownloadBps / 1e6).toFixed(1) : '—'; + const up = data.tcpUploadBps != null ? (data.tcpUploadBps / 1e6).toFixed(1) : '—'; + log(`Скорость ${c.interfaceName}: ↓${down} ↑${up} Mbps`); + return speedMap[key]; + } + speedMap[key] = null; + log(`Скорость ${c.interfaceName}: нет данных`); + return null; + } catch (e) { + speedMap[key] = null; + log(`Скорость ${c.interfaceName}: ошибка ${e?.message || e}`); + return null; + } + }); + await runWithOnePerKey(speedTasks, (i) => withSpeed[i].speedTestServerId); + } + + await writeCache(pingMap, speedMap); + log('Кеш карты сети обновлён.'); + } catch (err) { + log(`Ошибка: ${err?.message || err}`); + } + + return { pingMap, speedMap }; +} + +async function writeCache(pingMap, speedMap) { + try { + await writeS3JsonObject(NETWORK_MAP_CACHE_KEY, { + pingMap: pingMap || {}, + speedMap: speedMap || {}, + updatedAt: Date.now(), + }); + } catch (e) { + logBuffer.push(`Запись кеша в S3 не удалась: ${e?.message || e}`); + } +} + +let schedulerTimer = null; + +function getSettingsFromUiSettings(uiSettings) { + return { + enabled: Boolean(uiSettings.networkMapSchedulerEnabled), + intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)), + runPing: uiSettings.networkMapSchedulerRunPing !== false, + runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false, + logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)), + }; +} + +/** + * Запустить планировщик (setInterval). + * @param {object} logger - pino logger + * @param {number} port - порт сервера для вызова API + */ +function initNetworkMapScheduler(logger, port) { + const log = logger || console; + const baseUrl = `http://127.0.0.1:${port}`; + + async function tick() { + 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; + + logBuffer.setMaxLines(settings.logMaxLines); + logBuffer.push('--- Запуск по расписанию ---'); + runJob({ + baseUrl, + runPing: settings.runPing, + runSpeedTest: settings.runSpeedTest, + log: (msg) => { + logBuffer.push(msg); + log.info({ component: 'network-map-scheduler' }, msg); + }, + }).catch((err) => { + logBuffer.push(`Ошибка: ${err?.message || err}`); + log.error({ component: 'network-map-scheduler', err: err?.message }, 'Scheduler run 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: 'network-map-scheduler' }, 'Scheduler disabled in settings'); + return; + } + logBuffer.setMaxLines(settings.logMaxLines); + const intervalMs = settings.intervalMinutes * 60 * 1000; + log.info( + { component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes }, + 'Network map scheduler started (first run in %d min)', + settings.intervalMinutes + ); + schedulerTimer = setInterval(tick, intervalMs); + } + + schedule(); +} + +function stopNetworkMapScheduler() { + if (schedulerTimer) { + clearInterval(schedulerTimer); + schedulerTimer = null; + } +} + +function getLogs() { + return logBuffer.getLines(); +} + +function getLogMaxLines() { + return logBuffer.maxLines; +} + +function setLogMaxLines(n) { + const num = Math.max(100, Math.min(2000, parseInt(n, 10) || 500)); + logBuffer.setMaxLines(num); +} + +module.exports = { + runJob, + initNetworkMapScheduler, + stopNetworkMapScheduler, + getLogs, + getLogMaxLines, + setLogMaxLines, + getSettingsFromUiSettings, + loadUiSettings: loadUiSettings, + buildConnections, + LogBuffer, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d410d84..25b2506 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,7 +23,8 @@ import { IconArrowsExchange, IconLayoutSidebarLeftExpand, IconLayoutNavbar, - IconChartPie + IconChartPie, + IconClockPlay } from '@tabler/icons-react'; import ServerManager from './ServerManager'; import FilterManager from './FilterManager'; @@ -40,6 +41,7 @@ import InterfaceSpeedTest from './InterfaceSpeedTest.jsx'; import Dashboard from './Dashboard'; import TrafficDashboard from './TrafficDashboard.jsx'; import NetworkMapDashboard from './NetworkMapDashboard.jsx'; +import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx'; import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import PingServicesManager from './PingServicesManager.jsx'; import SettingsPage from './SettingsPage.jsx'; @@ -230,7 +232,8 @@ function MainLayout() { { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }, { id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork }, { id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork }, - { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork } + { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }, + { id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay } ] }, { @@ -399,6 +402,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> @@ -558,9 +562,10 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> - + diff --git a/frontend/src/NetworkMapDashboard.jsx b/frontend/src/NetworkMapDashboard.jsx index 74f9d45..5d5f73d 100644 --- a/frontend/src/NetworkMapDashboard.jsx +++ b/frontend/src/NetworkMapDashboard.jsx @@ -84,15 +84,17 @@ export default function NetworkMapDashboard() { setLoading(true); setError(null); try { - const [serversRes, configRes, uiSettingsRes] = await Promise.all([ + const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([ api.get('/servers'), api.get('/network-config').catch(() => ({ data: null })), api.get('/ui-settings').catch(() => ({ data: {} })), + api.get('/network-map-cache').catch(() => ({ data: {} })), ]); const serversList = Array.isArray(serversRes?.data) ? serversRes.data : []; const config = configRes?.data || {}; const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; const uiSettings = uiSettingsRes?.data || {}; + const cache = cacheRes?.data || {}; const ttlSeconds = Math.max( 0, parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0 @@ -128,7 +130,21 @@ export default function NetworkMapDashboard() { setServers(serversList); setConnections(connList); - setSpeedMap({}); + + const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000); + const cacheValid = + cache.updatedAt && + Date.now() - cache.updatedAt < cacheMaxAgeMs && + (Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0); + if (cacheValid) { + setPingMap(cache.pingMap || {}); + setSpeedMap(cache.speedMap || {}); + Object.keys(cache.pingMap || {}).forEach((k) => { + pingTimestampsRef.current[k] = cache.updatedAt; + }); + } else { + setSpeedMap({}); + } } catch (e) { console.error('NetworkMap fetch:', e); setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные'); diff --git a/frontend/src/NetworkMapSchedulerPage.jsx b/frontend/src/NetworkMapSchedulerPage.jsx new file mode 100644 index 0000000..a788512 --- /dev/null +++ b/frontend/src/NetworkMapSchedulerPage.jsx @@ -0,0 +1,310 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import api from './lib/api.js'; +import PageHeader from './components/PageHeader.jsx'; +import { + IconClockPlay, + IconSettings, + IconTerminal2, + IconPlayerPlay, + IconRefresh, +} from '@tabler/icons-react'; + +const POLL_LOGS_MS = 2500; +const INTERVAL_MIN = 1; +const INTERVAL_MAX = 1440; +const LOG_LINES_MIN = 100; +const LOG_LINES_MAX = 2000; + +export default function NetworkMapSchedulerPage() { + const [settings, setSettings] = useState({ + enabled: false, + intervalMinutes: 5, + runPing: true, + runSpeedTest: true, + logMaxLines: 500, + }); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const logEndRef = useRef(null); + const logContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const fetchSettings = useCallback(async () => { + try { + const { data } = await api.get('/scheduler/network-map/settings'); + setSettings({ + enabled: Boolean(data.enabled), + intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)), + runPing: data.runPing !== false, + runSpeedTest: data.runSpeedTest !== false, + logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)), + }); + } catch (e) { + setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки'); + } + }, []); + + const fetchLogs = useCallback(async () => { + try { + const { data } = await api.get('/scheduler/network-map/logs'); + setLogs(Array.isArray(data?.lines) ? data.lines : []); + } catch (_) { + // не показываем ошибку при опросе логов + } + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + await fetchSettings(); + if (!cancelled) setLoading(false); + })(); + return () => { cancelled = true; }; + }, [fetchSettings]); + + useEffect(() => { + fetchLogs(); + const t = setInterval(fetchLogs, POLL_LOGS_MS); + return () => clearInterval(t); + }, [fetchLogs]); + + useEffect(() => { + if (autoScroll && logEndRef.current) { + logEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, autoScroll]); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await api.patch('/scheduler/network-map/settings', { + networkMapSchedulerEnabled: settings.enabled, + networkMapSchedulerIntervalMinutes: settings.intervalMinutes, + networkMapSchedulerRunPing: settings.runPing, + networkMapSchedulerRunSpeedTest: settings.runSpeedTest, + networkMapSchedulerLogMaxLines: settings.logMaxLines, + }); + await fetchSettings(); + } catch (e) { + setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить'); + } finally { + setSaving(false); + } + }; + + const handleRunNow = async () => { + setRunning(true); + setError(null); + try { + await api.post('/scheduler/network-map/run-now'); + setTimeout(fetchLogs, 500); + } catch (e) { + setError(e?.response?.data?.message || e?.message || 'Не удалось запустить'); + } finally { + setRunning(false); + } + }; + + if (loading) { + return ( +
+ } + meta="Пинг и замер скорости между серверами по расписанию" + /> +
Загрузка настроек…
+
+ ); + } + + return ( +
+ } + meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети." + actions={ +
+ +
+ } + /> + + {error && ( +
+ {error} +
+ )} + +
+
+
+
+

+ + Настройки +

+
+ +
+
+
+
+ +
Периодически обновлять пинг и скорость по связям из «Сетевые настройки»
+
+ +
+ + + setSettings((s) => ({ + ...s, + intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)), + })) + } + /> +
От 1 до 1440 (сутки). По умолчанию 5 мин.
+
+ +
+ +
+ + +
+
+ +
+ + + setSettings((s) => ({ + ...s, + logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)), + })) + } + /> +
Кольцевой буфер: от 100 до 2000 строк.
+
+
+
+
+ +
+
+
+

+ + Логи +

+
+ + +
+
+
+
+                {logs.length === 0 ? (
+                  Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.
+                ) : (
+                  logs.map((line, i) => (
+                    
+                      {line}
+                    
+                  ))
+                )}
+                
+              
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 6169569..8b919f7 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -12,7 +12,8 @@ import { IconDownload, IconKeyboard, IconChartPie, - IconSettings + IconSettings, + IconClockPlay } from '@tabler/icons-react' /** @@ -42,6 +43,7 @@ function CommandPalette() { { icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] }, { icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] }, { icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] }, + { icon: IconClockPlay, label: 'Планировщик карты сети', description: 'Пинг и скорость по расписанию, логи', action: () => navigate('/scheduler'), keywords: ['планировщик', 'scheduler', 'карта', 'сеть', 'логи'] }, { icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] }, { icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] }, { icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },