diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index eb195b7..dea73a8 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -244,6 +244,115 @@ async function testMikrotikConnection(req, res) { } } +/** + * POST /api/uptime/check + * Проверка доступности одного сервера (jumphost/home). Тип проверки берётся из ui-settings.uptimeMonitorCheckType. + * Body: { serverId } + * Returns: { ok: boolean, ms?: number } + */ +async function uptimeCheck(req, res) { + const t0 = Date.now(); + try { + const { serverId } = req.body || {}; + if (!serverId) { + return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); + } + const uiSettings = await loadUiSettings(); + const checkType = String(uiSettings.uptimeMonitorCheckType || 'http').toLowerCase(); + const effectiveType = + checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http'; + + const servers = await readServersFromS3(); + const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); + if (!server || !['jumphost', 'home'].includes(String(server.type || '').toLowerCase())) { + return sendError(res, 400, 'Jumphost или Home сервер не найден', 'E_NOT_FOUND'); + } + + let ok = false; + let ms = null; + + if (effectiveType === 'http') { + const creds = getMikrotikCredentials(server); + if (!creds) { + return sendError(res, 400, 'MikroTik credentials not configured', 'E_CREDENTIALS'); + } + const client = createRosClient(creds); + await client.print('system/resource'); + ok = true; + ms = Date.now() - t0; + return res.json({ ok, ms }); + } + + if (effectiveType === 'internal-ping') { + const config = await loadNetworkConfig(); + const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; + const iface = tunnelInterfaces.find( + (i) => + (i.serverId && (i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns)) || + (i.serverId2 && (i.serverId2 === serverId || i.serverId2 === server.ip || i.serverId2 === server.dns)) + ); + if (!iface || (!iface.remoteIp && !iface.localIp)) { + ms = Date.now() - t0; + return res.json({ ok: false, ms, error: 'Нет туннеля с внутренним адресом для этого сервера' }); + } + const target = iface.serverId === serverId || iface.serverId === server.ip || iface.serverId === server.dns + ? iface.remoteIp + : iface.localIp; + const gatewayIp = target; + if (!target) { + ms = Date.now() - t0; + 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'; + return res.json({ ok, ms: ok ? result.avgMs : ms }); + } catch (err) { + ms = Date.now() - t0; + return res.json({ ok: false, ms, error: err?.message || String(err) }); + } + } + + if (effectiveType === 'external-ping') { + const others = servers.filter( + (s) => + ['jumphost', 'home'].includes(String(s.type || '').toLowerCase()) && + (s.id || s.dns || s.ip) !== serverId && + s.ip !== server.ip + ); + const sourceServer = others[0]; + if (!sourceServer) { + ms = Date.now() - t0; + return res.json({ ok: false, ms, error: 'Нет другого jumphost для внешнего пинга' }); + } + const targetIp = server.ip || server.extIp; + if (!targetIp) { + ms = Date.now() - t0; + return res.json({ ok: false, ms, error: 'У сервера нет внешнего IP' }); + } + const sourceId = sourceServer.id || sourceServer.dns || sourceServer.ip; + try { + const result = await runPingViaRouter(sourceId, null, targetIp, 3); + ms = Date.now() - t0; + ok = typeof result.avgMs === 'number'; + return res.json({ ok, ms: ok ? result.avgMs : ms }); + } catch (err) { + ms = Date.now() - t0; + return res.json({ ok: false, ms, error: err?.message || String(err) }); + } + } + + ms = Date.now() - t0; + return res.json({ ok: false, ms }); + } catch (error) { + const ms = Date.now() - t0; + const msg = error?.message || String(error); + console.error('uptimeCheck:', error); + return res.status(502).json({ ok: false, ms, error: msg }); + } +} + /** * POST /api/mikrotik/apply * Body: { serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean } @@ -1344,4 +1453,5 @@ module.exports = { loadNetworkConfig, getAddressLists, applyAddressListSummary, + uptimeCheck, }; diff --git a/backend/server.js b/backend/server.js index 272faed..4156350 100644 --- a/backend/server.js +++ b/backend/server.js @@ -470,6 +470,9 @@ app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); 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.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck); + // === TRAFFIC STATS (MikroTik interfaces by jumphost) === app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats); diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index fc68a23..d521e12 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -13,6 +13,7 @@ import { IconChartBar, IconRefresh, IconServer, + IconClock, } from '@tabler/icons-react'; import FormField from './components/FormField'; import ErrorAlert from './components/ErrorAlert'; @@ -37,6 +38,7 @@ const SIDEBAR_GROUPS = [ items: [ { id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud }, { id: 'ping-services', title: 'Пинг на главной', icon: IconChartPie }, + { id: 'uptime-monitor', title: 'Uptime Monitor', icon: IconClock }, ], }, { @@ -82,6 +84,8 @@ export default function SettingsPage() { const [trafficJumphosts, setTrafficJumphosts] = useState([]); const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false); const [trafficInterfacesError, setTrafficInterfacesError] = useState(''); + const [uptimeMonitorIntervalSeconds, setUptimeMonitorIntervalSeconds] = useState('120'); + const [uptimeMonitorCheckType, setUptimeMonitorCheckType] = useState('http'); const [serversList, setServersList] = useState([]); const [sidebarSearch, setSidebarSearch] = useState(''); const [activeSection, setActiveSection] = useState(() => { @@ -247,6 +251,15 @@ export default function SettingsPage() { ? String(data.interfaceSpeedTestCacheMinutes) : '' ); + setUptimeMonitorIntervalSeconds( + data?.uptimeMonitorIntervalSeconds != null + ? String(data.uptimeMonitorIntervalSeconds) + : '120' + ); + const checkType = String(data?.uptimeMonitorCheckType || 'http').toLowerCase(); + setUptimeMonitorCheckType( + checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http' + ); const raw = data?.trafficInterfaces; setTrafficInterfacesSelected( Array.isArray(raw) @@ -349,6 +362,14 @@ export default function SettingsPage() { 0, parseInt(interfaceSpeedTestCacheMinutes, 10) || 0 ), + uptimeMonitorIntervalSeconds: Math.max( + 30, + parseInt(uptimeMonitorIntervalSeconds, 10) || 120 + ), + uptimeMonitorCheckType: + uptimeMonitorCheckType === 'internal-ping' || uptimeMonitorCheckType === 'external-ping' + ? uptimeMonitorCheckType + : 'http', trafficInterfaces: Array.isArray(trafficInterfacesSelected) ? trafficInterfacesSelected.map((p) => ({ serverKey: p.serverKey, @@ -704,6 +725,47 @@ export default function SettingsPage() { )} + {activeSection === 'uptime-monitor' && ( + <> + +

+ Настройки страницы «Uptime Monitor»: интервал проверки доступности Jumphost/Home и способ проверки. +

+
+
+ +
+
+ + +
+ HTTP: подключение к RouterOS API. Внутренний: пинг через туннель (как на карте сети). Внешний: пинг внешнего IP с другого jumphost. +
+
+
+ + )} + {activeSection === 'ptr-zone' && ( <> diff --git a/frontend/src/UptimeMonitorPage.jsx b/frontend/src/UptimeMonitorPage.jsx index 6482281..70019e2 100644 --- a/frontend/src/UptimeMonitorPage.jsx +++ b/frontend/src/UptimeMonitorPage.jsx @@ -22,7 +22,7 @@ import { import PageHeader from './components/PageHeader.jsx'; import { formatRelative } from './lib/datetime.js'; -const CHECK_INTERVAL_MS = 2 * 60 * 1000; // 2 минуты +const DEFAULT_CHECK_INTERVAL_SEC = 120; const DELAY_BETWEEN_CHECKS_MS = 1500; const HISTORY_MAX = 500; const STORAGE_KEY = 'uptime-monitor-history'; @@ -89,6 +89,7 @@ export default function UptimeMonitorPage() { /** По serverId: массив { ts, ok, ms } */ const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage); const [selectedServerId, setSelectedServerId] = useState(null); + const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC); const intervalRef = useRef(null); const checkingRef = useRef(false); @@ -104,8 +105,15 @@ export default function UptimeMonitorPage() { setLoading(true); setError(null); try { - const { data } = await api.get('/servers'); + const [serversRes, settingsRes] = await Promise.all([ + api.get('/servers'), + api.get('/ui-settings').catch(() => ({ data: {} })), + ]); + const data = serversRes?.data; setServers(Array.isArray(data) ? data : []); + const ui = settingsRes?.data || {}; + const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC)); + setCheckIntervalSeconds(interval); } catch (e) { setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы'); setServers([]); @@ -118,6 +126,8 @@ export default function UptimeMonitorPage() { fetchServers(); }, [fetchServers]); + const checkIntervalMs = checkIntervalSeconds * 1000; + const runChecks = useCallback(async () => { if (jumphosts.length === 0) return; if (checkingRef.current) return; @@ -130,11 +140,14 @@ export default function UptimeMonitorPage() { if (!serverId) continue; const t0 = Date.now(); let ok = false; + let ms = null; try { - await api.post('/mikrotik/test-connection', { serverId }); - ok = true; - } catch (_) {} - const ms = Date.now() - t0; + 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; + } results[serverId] = { ok, lastCheckTs: Date.now() }; newHistory[serverId] = [{ ts: Date.now(), ok, ms }]; await new Promise((r) => setTimeout(r, DELAY_BETWEEN_CHECKS_MS)); @@ -160,11 +173,11 @@ export default function UptimeMonitorPage() { useEffect(() => { if (jumphosts.length === 0) return; runChecks(); - intervalRef.current = setInterval(runChecks, CHECK_INTERVAL_MS); + intervalRef.current = setInterval(runChecks, checkIntervalMs); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [jumphosts, runChecks]); + }, [jumphosts, runChecks, checkIntervalMs]); // Сохраняем историю в localStorage при изменении useEffect(() => { @@ -229,7 +242,7 @@ export default function UptimeMonitorPage() {

Доступность Jumphost и домашних роутеров

- Проверка каждые {CHECK_INTERVAL_MS / 60000} мин · Выберите сервер для графиков + Проверка каждые {checkIntervalSeconds} сек · Выберите сервер для графиков