From 236f9f9d2e7b48bbfe2616d8c2692839637e7bb1 Mon Sep 17 00:00:00 2001 From: shats Date: Sat, 21 Feb 2026 22:08:46 +0700 Subject: [PATCH] feat(App, CommandPalette): add Uptime Monitor page and integrate into navigation and command palette --- frontend/src/App.jsx | 12 +- frontend/src/UptimeMonitorPage.jsx | 443 +++++++++++++++++++++ frontend/src/components/CommandPalette.jsx | 2 + 3 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 frontend/src/UptimeMonitorPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd2e0c8..f3ce92e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,7 @@ import { IconLayoutSidebarLeftExpand, IconLayoutNavbar, IconChartPie, + IconClock, IconClockPlay, IconShield } from '@tabler/icons-react'; @@ -43,6 +44,7 @@ import Dashboard from './Dashboard'; import TrafficDashboard from './TrafficDashboard.jsx'; import NetworkMapDashboard from './NetworkMapDashboard.jsx'; import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx'; +import UptimeMonitorPage from './UptimeMonitorPage.jsx'; import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import PingServicesManager from './PingServicesManager.jsx'; import FirewallPage from './FirewallPage.jsx'; @@ -64,7 +66,7 @@ function LanguageProvider({ children }) { const dict = { ru: { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', - dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', + dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', light: 'Светлая', dark: 'Тёмная', @@ -72,7 +74,7 @@ function LanguageProvider({ children }) { }, en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', - dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', + dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs', easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', light: 'Light', dark: 'Dark', @@ -199,7 +201,8 @@ function MainLayout() { items: [ { id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome }, { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, - { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork } + { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }, + { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock } ] }, { @@ -236,6 +239,7 @@ function MainLayout() { { id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield }, { id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork }, { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }, + { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }, { id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay } ] }, @@ -391,6 +395,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> @@ -552,6 +557,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/UptimeMonitorPage.jsx b/frontend/src/UptimeMonitorPage.jsx new file mode 100644 index 0000000..0377a81 --- /dev/null +++ b/frontend/src/UptimeMonitorPage.jsx @@ -0,0 +1,443 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import api from './lib/api.js'; +import { + IconClock, + IconRefresh, + IconServer, + IconCircleCheck, + IconCircleX, + IconChartLine, + IconPlayerPlay, + IconPlayerPause, +} from '@tabler/icons-react'; +import PageHeader from './components/PageHeader.jsx'; +import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; +import { formatRelative } from './lib/datetime.js'; + +const CHECK_INTERVAL_MS = 60 * 1000; // 1 минута +const HISTORY_MAX = 300; // храним последние 300 проверок для расчёта доступности + +/** Форматирование длительности (секунды → "X days Y hours Z mins") */ +function formatDuration(seconds) { + if (seconds == null || seconds < 0 || !Number.isFinite(seconds)) return '—'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const parts = []; + if (d > 0) parts.push(`${d} дн`); + if (h > 0) parts.push(`${h} ч`); + if (m > 0) parts.push(`${m} мин`); + if (s > 0 || parts.length === 0) parts.push(`${s} сек`); + return parts.join(' '); +} + +/** Считает доступность и инциденты по истории проверок */ +function computeUptimeStats(history) { + if (!Array.isArray(history) || history.length === 0) { + return { availability: null, incidents: 0, totalDowntimeSec: 0, longestDowntimeSec: 0, avgIncidentSec: 0, upSinceSec: null }; + } + const now = Date.now() / 1000; + let upCount = 0; + let incidentCount = 0; + let totalDowntimeSec = 0; + let longestDowntimeSec = 0; + let currentDowntimeSec = 0; + let lastUpTs = null; + let upSinceSec = null; + + for (let i = 0; i < history.length; i++) { + const { ts, up } = history[i]; + const tsSec = ts / 1000; + if (up) { + upCount++; + if (lastUpTs != null && i > 0) { + const gap = tsSec - lastUpTs; + if (gap > 60) { + incidentCount++; + totalDowntimeSec += currentDowntimeSec; + if (currentDowntimeSec > longestDowntimeSec) longestDowntimeSec = currentDowntimeSec; + } + } + lastUpTs = tsSec; + currentDowntimeSec = 0; + if (upSinceSec == null) upSinceSec = now - tsSec; + } else { + if (i > 0) { + const prev = history[i - 1]; + const gap = (ts - prev.ts) / 1000; + currentDowntimeSec += gap; + } + } + } + if (currentDowntimeSec > 0) { + incidentCount++; + totalDowntimeSec += currentDowntimeSec; + if (currentDowntimeSec > longestDowntimeSec) longestDowntimeSec = currentDowntimeSec; + } + const total = history.length; + const availability = total > 0 ? (upCount / total) * 100 : null; + const avgIncidentSec = incidentCount > 0 ? totalDowntimeSec / incidentCount : 0; + return { + availability, + incidents: incidentCount, + totalDowntimeSec, + longestDowntimeSec, + avgIncidentSec, + upSinceSec: lastUpTs != null ? upSinceSec : null, + }; +} + +export default function UptimeMonitorPage() { + const [servers, setServers] = useState([]); + const [routerServerId, setRouterServerId] = useState(''); + const [loading, setLoading] = useState(true); + const [pinging, setPinging] = useState(false); + const [error, setError] = useState(null); + /** По каждому target (ip или id): массив { ts, up, ms } */ + const [historyMap, setHistoryMap] = useState({}); + /** Выбранная цель для детальной карточки (ip или id) */ + const [selectedTargetKey, setSelectedTargetKey] = useState(null); + const intervalRef = useRef(null); + const pausedRef = useRef(false); + + const routerServers = servers.filter( + (s) => ['jumphost', 'home'].includes(String(s.type || '').toLowerCase()) + ); + const targets = servers.filter((s) => { + const ip = s.ip || s.extIp; + if (!ip) return false; + const id = s.id || s.dns || s.ip; + return id !== routerServerId && ip !== routerServerId; + }); + + const fetchServers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data } = await api.get('/servers'); + setServers(Array.isArray(data) ? data : []); + } catch (e) { + setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы'); + setServers([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchServers(); + }, [fetchServers]); + + const runPings = useCallback(async () => { + if (!routerServerId || targets.length === 0) return; + setPinging(true); + const serverId = routerServerId; + const results = {}; + for (const target of targets) { + const ip = target.ip || target.extIp; + if (!ip) continue; + const key = target.id || target.ip || target.dns || ip; + try { + const { data } = await api.post('/mikrotik/ping', { + serverId, + target: ip, + gatewayIp: null, + count: 3, + }); + const up = data && typeof data.avgMs === 'number'; + const ms = up ? data.avgMs : null; + results[key] = { ts: Date.now(), up, ms }; + } catch { + results[key] = { ts: Date.now(), up: false, ms: null }; + } + } + setHistoryMap((prev) => { + const next = { ...prev }; + for (const [key, entry] of Object.entries(results)) { + const list = Array.isArray(next[key]) ? next[key] : []; + const newList = [...list, entry].slice(-HISTORY_MAX); + next[key] = newList; + } + return next; + }); + setPinging(false); + }, [routerServerId, targets]); + + /** Автообновление по интервалу */ + useEffect(() => { + if (!routerServerId || pausedRef.current) return; + runPings(); + intervalRef.current = setInterval(runPings, CHECK_INTERVAL_MS); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [routerServerId, runPings]); + + const handlePause = () => { + pausedRef.current = !pausedRef.current; + if (pausedRef.current && intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } else if (!pausedRef.current && routerServerId) { + runPings(); + intervalRef.current = setInterval(runPings, CHECK_INTERVAL_MS); + } + setPinging((p) => p); + }; + + const selectedTarget = selectedTargetKey + ? targets.find((t) => (t.id || t.ip || t.dns) === selectedTargetKey) + : targets[0]; + const selectedHistory = selectedTargetKey && historyMap[selectedTargetKey]; + const selectedStats = selectedHistory ? computeUptimeStats(selectedHistory) : null; + const lastEntry = selectedHistory && selectedHistory.length > 0 ? selectedHistory[selectedHistory.length - 1] : null; + const isUp = lastEntry?.up ?? null; + + return ( +
+
+ } + pretitle="Extra" + meta="Мониторинг доступности по ping с выбранного роутера (как на карте сети)" + actions={ +
+ + setRouterServerId(String(v || '').trim())} + servers={routerServers} + placeholder="Jumphost или домашний роутер" + className="form-control form-control-flush" + maxSuggestions={10} + /> + + +
+ } + /> + + {error && ( +
+ {error} +
+ )} + + {!loading && routerServerId && targets.length === 0 && ( +
+ Нет целей для мониторинга. Добавьте серверы с IP (кроме выбранного роутера). +
+ )} + + {!loading && routerServerId && targets.length > 0 && ( + <> + {/* Детальная карточка выбранной цели (в стиле Tabler Uptime) */} + {selectedTarget && ( +
+
+
+
+

+ {selectedTarget.dns || selectedTarget.ip || selectedTarget.name || selectedTargetKey} +

+
+ {isUp === true && ( + Up + )} + {isUp === false && ( + Down + )} + {isUp === null && ( + + )} +
+
+
+
+
+
+
+
Доступен уже
+
+ {isUp && lastEntry + ? formatDuration((Date.now() - lastEntry.ts) / 1000) + : isUp ? '—' : '0 сек'} +
+
+
+
+
+
+
+
Проверка каждые {CHECK_INTERVAL_MS / 60000} мин
+
Последняя: {lastEntry ? formatRelative(lastEntry.ts) : '—'}
+
+
+
+
+
+
+
RTT (средн.)
+
+ {lastEntry?.ms != null ? `${Math.round(lastEntry.ms)} мс` : '—'} +
+
+
+
+
+
+
+
Инциденты (по истории)
+
{selectedStats?.incidents ?? '—'}
+
+
+
+
+
+
+
+
+ )} + + {/* Таблица периодов (как на Tabler) — по выбранной цели */} + {selectedTarget && selectedStats != null && ( +
+
+
+
+

Доступность по периодам

+
+
+ + + + + + + + + + + + + + + + + + + + + +
ПериодДоступностьПростойИнцидентыСамый долгийСредний инцидент
По сохранённой истории + {selectedStats.availability != null + ? `${selectedStats.availability.toFixed(2)}%` + : '—'} + {formatDuration(selectedStats.totalDowntimeSec)}{selectedStats.incidents}{formatDuration(selectedStats.longestDowntimeSec)}{formatDuration(selectedStats.avgIncidentSec)}
+
+
+
+
+ )} + + {/* Таблица всех целей */} +
+
+

Цели мониторинга

+
+
+ + + + + + + + + + + + + {targets.map((t) => { + const key = t.id || t.ip || t.dns || t.ip; + const hist = historyMap[key] || []; + const last = hist.length > 0 ? hist[hist.length - 1] : null; + const stats = computeUptimeStats(hist); + const isSelected = (selectedTargetKey || (targets[0] && (targets[0].id || targets[0].ip))) === key; + return ( + setSelectedTargetKey(key)} + > + + + + + + + + ); + })} + +
Сервер / IPСтатусПоследняя проверкаRTTДоступностьИнциденты
+
+ + {t.dns || t.name || t.ip || t.extIp || key} +
+
+ {last == null && ( + + )} + {last?.up === true && ( + + Up + + )} + {last?.up === false && ( + + Down + + )} + {last ? formatRelative(last.ts) : '—'}{last?.ms != null ? `${Math.round(last.ms)} мс` : '—'} + {stats.availability != null ? `${stats.availability.toFixed(1)}%` : '—'} + {stats.incidents}
+
+
+ + )} + + {!loading && !routerServerId && ( +
+
+ +
+

Выберите роутер

+

+ Укажите jumphost или домашний роутер с MikroTik API — с него будет выполняться ping по целям. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 2492327..97c7395 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -13,6 +13,7 @@ import { IconKeyboard, IconChartPie, IconSettings, + IconClock, IconClockPlay, IconShield } from '@tabler/icons-react' @@ -44,6 +45,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: IconClock, label: 'Uptime Monitor', description: 'Мониторинг доступности по ping с выбранного роутера', action: () => navigate('/uptime-monitor'), keywords: ['uptime', 'мониторинг', 'доступность', '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'] },