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)} |
+
+
+
+
+
+
+
+ )}
+
+ {/* Таблица всех целей */}
+
+
+
Цели мониторинга
+
+
+
+
+
+ | Сервер / IP |
+ Статус |
+ Последняя проверка |
+ RTT |
+ Доступность |
+ Инциденты |
+
+
+
+ {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)}
+ >
+ |
+
+
+ {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'] },