diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 1cc12a7..d7f1bb0 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -22,6 +22,7 @@ const NETWORK_CONFIG_KEY = 'network-config.json'; const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; const PING_CACHE_PREFIX = 'ping-cache/'; const SPEEDTEST_CACHE_PREFIX = 'speed-test-cache/'; +const UPTIME_CACHE_KEY = 'uptime-monitor-cache/latest.json'; /** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */ async function loadUiSettings() { @@ -244,11 +245,64 @@ async function testMikrotikConnection(req, res) { } } +/** Записать результат проверки в кеш Uptime Monitor (S3). Не блокирует ответ. */ +function updateUptimeCache(serverId, entry) { + const { ok, lastCheckTs, ms } = entry; + return readS3TextObject(UPTIME_CACHE_KEY) + .catch(() => ({ body: '{}' })) + .then((data) => { + let cache = { results: {}, updatedAt: null }; + try { + const parsed = JSON.parse(data?.body || '{}'); + if (parsed && typeof parsed === 'object' && parsed.results) cache = parsed; + } catch (_) {} + cache.results[serverId] = { ok, lastCheckTs, ms }; + cache.updatedAt = Date.now(); + return writeS3JsonObject(UPTIME_CACHE_KEY, cache); + }) + .catch((e) => console.warn('[uptime] cache write failed:', e?.message)); +} + +/** + * GET /api/uptime/cache + * Возвращает закешированные результаты проверок, если кеш младше TTL из настроек. + */ +async function getUptimeCache(req, res) { + try { + const uiSettings = await loadUiSettings(); + const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120); + if (ttlSec === 0) { + return res.json({ results: {}, updatedAt: null }); + } + const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null); + if (!data?.body) { + return res.json({ results: {}, updatedAt: null }); + } + let cache = { results: {}, updatedAt: null }; + try { + const parsed = JSON.parse(data.body); + if (parsed && typeof parsed === 'object') cache = parsed; + } catch (_) {} + const now = Date.now(); + if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) { + return res.json({ results: {}, updatedAt: null }); + } + return res.json({ + results: cache.results && typeof cache.results === 'object' ? cache.results : {}, + updatedAt: cache.updatedAt, + }); + } catch (e) { + console.error('[uptime] getUptimeCache', e); + return res.json({ results: {}, updatedAt: null }); + } +} + /** * POST /api/uptime/check * Проверка доступности одного сервера (jumphost/home). Тип проверки берётся из ui-settings.uptimeMonitorCheckType. * Body: { serverId } * Returns: { ok: boolean, ms?: number } + * Результат пишется в кеш (S3) для отображения при заходе на страницу. */ async function uptimeCheck(req, res) { const t0 = Date.now(); @@ -280,6 +334,8 @@ async function uptimeCheck(req, res) { await client.print('system/resource'); ok = true; ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok, lastCheckTs, ms }); return res.json({ ok, ms }); } @@ -293,6 +349,8 @@ async function uptimeCheck(req, res) { ); if (!iface || (!iface.remoteIp && !iface.localIp)) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет туннеля с внутренним адресом для этого сервера' }); } const target = iface.serverId === serverId || iface.serverId === server.ip || iface.serverId === server.dns @@ -301,15 +359,21 @@ async function uptimeCheck(req, res) { const gatewayIp = target; if (!target) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет целевого адреса для пинга' }); } try { const result = await runPingViaRouter(serverId, gatewayIp, target, 3); ms = Date.now() - t0; ok = typeof result.avgMs === 'number'; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok, lastCheckTs, ms: ok ? result.avgMs : ms }); return res.json({ ok, ms: ok ? result.avgMs : ms }); } catch (err) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: err?.message || String(err) }); } } @@ -324,11 +388,15 @@ async function uptimeCheck(req, res) { const sourceServer = others[0]; if (!sourceServer) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет другого jumphost для внешнего пинга' }); } const targetIp = server.ip || server.extIp; if (!targetIp) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'У сервера нет внешнего IP' }); } const sourceId = sourceServer.id || sourceServer.dns || sourceServer.ip; @@ -336,14 +404,20 @@ async function uptimeCheck(req, res) { const result = await runPingViaRouter(sourceId, null, targetIp, 3); ms = Date.now() - t0; ok = typeof result.avgMs === 'number'; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok, lastCheckTs, ms: ok ? result.avgMs : ms }); return res.json({ ok, ms: ok ? result.avgMs : ms }); } catch (err) { ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: err?.message || String(err) }); } } ms = Date.now() - t0; + const lastCheckTs = Date.now(); + updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms }); } catch (error) { const ms = Date.now() - t0; @@ -1462,4 +1536,5 @@ module.exports = { getAddressLists, applyAddressListSummary, uptimeCheck, + getUptimeCache, }; diff --git a/backend/server.js b/backend/server.js index 4156350..360b199 100644 --- a/backend/server.js +++ b/backend/server.js @@ -471,6 +471,7 @@ app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists); app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary); // === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) === +app.get('/api/uptime/cache', mikrotikConfigRoutes.getUptimeCache); app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck); // === TRAFFIC STATS (MikroTik interfaces by jumphost) === diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index d521e12..14c4bf8 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -86,6 +86,7 @@ export default function SettingsPage() { const [trafficInterfacesError, setTrafficInterfacesError] = useState(''); const [uptimeMonitorIntervalSeconds, setUptimeMonitorIntervalSeconds] = useState('120'); const [uptimeMonitorCheckType, setUptimeMonitorCheckType] = useState('http'); + const [uptimeMonitorCacheSeconds, setUptimeMonitorCacheSeconds] = useState('120'); const [serversList, setServersList] = useState([]); const [sidebarSearch, setSidebarSearch] = useState(''); const [activeSection, setActiveSection] = useState(() => { @@ -260,6 +261,9 @@ export default function SettingsPage() { setUptimeMonitorCheckType( checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http' ); + setUptimeMonitorCacheSeconds( + data?.uptimeMonitorCacheSeconds != null ? String(data.uptimeMonitorCacheSeconds) : '120' + ); const raw = data?.trafficInterfaces; setTrafficInterfacesSelected( Array.isArray(raw) @@ -370,6 +374,10 @@ export default function SettingsPage() { uptimeMonitorCheckType === 'internal-ping' || uptimeMonitorCheckType === 'external-ping' ? uptimeMonitorCheckType : 'http', + uptimeMonitorCacheSeconds: Math.max( + 0, + parseInt(uptimeMonitorCacheSeconds, 10) || 120 + ), trafficInterfaces: Array.isArray(trafficInterfacesSelected) ? trafficInterfacesSelected.map((p) => ({ serverKey: p.serverKey, @@ -762,6 +770,20 @@ export default function SettingsPage() { HTTP: подключение к RouterOS API. Внутренний: пинг через туннель (как на карте сети). Внешний: пинг внешнего IP с другого jumphost. +
+ +
)} diff --git a/frontend/src/UptimeMonitorPage.jsx b/frontend/src/UptimeMonitorPage.jsx index cfdf76b..66ee181 100644 --- a/frontend/src/UptimeMonitorPage.jsx +++ b/frontend/src/UptimeMonitorPage.jsx @@ -23,7 +23,7 @@ import PageHeader from './components/PageHeader.jsx'; import { formatRelative } from './lib/datetime.js'; const DEFAULT_CHECK_INTERVAL_SEC = 120; -const DELAY_BETWEEN_CHECKS_MS = 1500; +const PARALLEL_CHECKS = 4; // количество одновременных проверок const HISTORY_MAX = 500; const STORAGE_KEY = 'uptime-monitor-history'; @@ -90,8 +90,8 @@ export default function UptimeMonitorPage() { const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage); const [selectedServerId, setSelectedServerId] = useState(null); const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC); - /** serverId, для которого сейчас идёт проверка — показываем «Проверка…» в строке */ - const [currentCheckingServerId, setCurrentCheckingServerId] = useState(null); + /** serverId, по которым сейчас идёт проверка — показываем «Проверка…» в строке (до PARALLEL_CHECKS одновременно) */ + const [currentCheckingServerIds, setCurrentCheckingServerIds] = useState([]); const intervalRef = useRef(null); const checkingRef = useRef(false); @@ -128,44 +128,66 @@ export default function UptimeMonitorPage() { fetchServers(); }, [fetchServers]); + // При заходе на страницу подставляем кеш — сразу видны последние проверки + useEffect(() => { + if (jumphosts.length === 0) return; + api.get('/uptime/cache') + .then(({ data }) => { + if (data?.results && typeof data.results === 'object' && Object.keys(data.results).length > 0) { + setStatusMap((prev) => ({ ...prev, ...data.results })); + } + }) + .catch(() => {}); + }, [jumphosts]); + const checkIntervalMs = checkIntervalSeconds * 1000; + /** Параллельный запуск проверок (до PARALLEL_CHECKS одновременно), результат по мере готовности */ const runChecks = useCallback(async () => { if (jumphosts.length === 0) return; if (checkingRef.current) return; checkingRef.current = true; setChecking(true); - for (const server of jumphosts) { - const serverId = server.id || server.dns || server.ip; - if (!serverId) continue; - setCurrentCheckingServerId(serverId); - const t0 = Date.now(); - let ok = false; - let ms = null; - try { - const { data: checkData } = await api.post('/uptime/check', { serverId }); - ok = checkData?.ok === true; - ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0; - } catch (_) { - ms = Date.now() - t0; + setCurrentCheckingServerIds([]); + let index = 0; + const getNext = () => { + const i = index++; + return i < jumphosts.length ? jumphosts[i] : null; + }; + const checkOne = async () => { + let server; + while ((server = getNext()) != null) { + const serverId = server.id || server.dns || server.ip; + if (!serverId) continue; + setCurrentCheckingServerIds((prev) => [...prev, serverId]); + const t0 = Date.now(); + let ok = false; + let ms = null; + try { + const { data: checkData } = await api.post('/uptime/check', { serverId }); + ok = checkData?.ok === true; + ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0; + } catch (_) { + ms = Date.now() - t0; + } + const lastCheckTs = Date.now(); + const entry = { ts: lastCheckTs, ok, ms }; + setStatusMap((prev) => ({ ...prev, [serverId]: { ok, lastCheckTs } })); + setHistoryMap((prev) => { + const list = Array.isArray(prev[serverId]) ? prev[serverId] : []; + const merged = [...list, entry].slice(-HISTORY_MAX); + return { ...prev, [serverId]: merged }; + }); + setCurrentCheckingServerIds((prev) => prev.filter((id) => id !== serverId)); } - const lastCheckTs = Date.now(); - const entry = { ts: lastCheckTs, ok, ms }; - // Обновляем UI сразу после каждой проверки — результат виден по мере прохождения тестов - setStatusMap((prev) => ({ ...prev, [serverId]: { ok, lastCheckTs } })); - setHistoryMap((prev) => { - const list = Array.isArray(prev[serverId]) ? prev[serverId] : []; - const merged = [...list, entry].slice(-HISTORY_MAX); - return { ...prev, [serverId]: merged }; - }); - setCurrentCheckingServerId(null); - await new Promise((r) => setTimeout(r, DELAY_BETWEEN_CHECKS_MS)); - } - setCurrentCheckingServerId(null); + }; + await Promise.all(Array.from({ length: PARALLEL_CHECKS }, checkOne)); + setCurrentCheckingServerIds([]); checkingRef.current = false; setChecking(false); }, [jumphosts]); + // Автопроверка по интервалу в фоне (параллельно с другими проверками) useEffect(() => { if (jumphosts.length === 0) return; runChecks(); @@ -278,28 +300,28 @@ export default function UptimeMonitorPage() { - {currentCheckingServerId === id && ( + {currentCheckingServerIds.includes(id) && ( )} - {currentCheckingServerId !== id && status == null && ( + {!currentCheckingServerIds.includes(id) && status == null && ( )} - {currentCheckingServerId !== id && status?.ok === true && ( + {!currentCheckingServerIds.includes(id) && status?.ok === true && ( Доступен )} - {currentCheckingServerId !== id && status?.ok === false && ( + {!currentCheckingServerIds.includes(id) && status?.ok === false && ( Недоступен )} - {currentCheckingServerId === id + {currentCheckingServerIds.includes(id) ? '…' : status?.lastCheckTs ? formatRelative(status.lastCheckTs)