feat(App, CommandPalette): add Uptime Monitor page and integrate into navigation and command palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s

This commit is contained in:
2026-02-21 22:08:46 +07:00
parent e72b3f539d
commit 236f9f9d2e
3 changed files with 454 additions and 3 deletions
+9 -3
View File
@@ -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() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
@@ -552,6 +557,7 @@ function MainLayout() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
+443
View File
@@ -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 (
<div className="page-body">
<div className="container-fluid">
<PageHeader
title="Uptime Monitor"
icon={<IconClock size={28} />}
pretitle="Extra"
meta="Мониторинг доступности по ping с выбранного роутера (как на карте сети)"
actions={
<div className="btn-list">
<label className="form-label mb-0 me-2 align-self-center">Роутер (откуда пинговать)</label>
<ServerAutocompleteInput
value={routerServerId}
onChange={(v) => setRouterServerId(String(v || '').trim())}
servers={routerServers}
placeholder="Jumphost или домашний роутер"
className="form-control form-control-flush"
maxSuggestions={10}
/>
<button
type="button"
className="btn btn-primary"
onClick={runPings}
disabled={!routerServerId || targets.length === 0 || pinging}
>
<IconRefresh className={pinging ? 'spin' : ''} size={18} />
{pinging ? ' Проверка…' : ' Обновить пинг'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={handlePause}
disabled={!routerServerId}
title={pausedRef.current ? 'Возобновить' : 'Приостановить'}
>
{pausedRef.current ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
</button>
</div>
}
/>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
{!loading && routerServerId && targets.length === 0 && (
<div className="alert alert-warning">
Нет целей для мониторинга. Добавьте серверы с IP (кроме выбранного роутера).
</div>
)}
{!loading && routerServerId && targets.length > 0 && (
<>
{/* Детальная карточка выбранной цели (в стиле Tabler Uptime) */}
{selectedTarget && (
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">
{selectedTarget.dns || selectedTarget.ip || selectedTarget.name || selectedTargetKey}
</h3>
<div className="card-actions">
{isUp === true && (
<span className="badge bg-success-lt">Up</span>
)}
{isUp === false && (
<span className="badge bg-danger-lt">Down</span>
)}
{isUp === null && (
<span className="badge bg-secondary-lt"></span>
)}
</div>
</div>
<div className="card-body">
<div className="row row-deck">
<div className="col-sm-6 col-lg-3">
<div className="card card-sm bg-primary-lt">
<div className="card-body">
<div className="text-muted small mb-1">Доступен уже</div>
<div className="h3 mb-0">
{isUp && lastEntry
? formatDuration((Date.now() - lastEntry.ts) / 1000)
: isUp ? '—' : '0 сек'}
</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card card-sm">
<div className="card-body">
<div className="text-muted small mb-1">Проверка каждые {CHECK_INTERVAL_MS / 60000} мин</div>
<div className="h3 mb-0">Последняя: {lastEntry ? formatRelative(lastEntry.ts) : '—'}</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card card-sm">
<div className="card-body">
<div className="text-muted small mb-1">RTT (средн.)</div>
<div className="h3 mb-0">
{lastEntry?.ms != null ? `${Math.round(lastEntry.ms)} мс` : '—'}
</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card card-sm">
<div className="card-body">
<div className="text-muted small mb-1">Инциденты (по истории)</div>
<div className="h3 mb-0">{selectedStats?.incidents ?? '—'}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Таблица периодов (как на Tabler) — по выбранной цели */}
{selectedTarget && selectedStats != null && (
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Доступность по периодам</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th>Период</th>
<th>Доступность</th>
<th>Простой</th>
<th>Инциденты</th>
<th>Самый долгий</th>
<th>Средний инцидент</th>
</tr>
</thead>
<tbody>
<tr>
<td>По сохранённой истории</td>
<td>
{selectedStats.availability != null
? `${selectedStats.availability.toFixed(2)}%`
: '—'}
</td>
<td>{formatDuration(selectedStats.totalDowntimeSec)}</td>
<td>{selectedStats.incidents}</td>
<td>{formatDuration(selectedStats.longestDowntimeSec)}</td>
<td>{formatDuration(selectedStats.avgIncidentSec)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Таблица всех целей */}
<div className="card">
<div className="card-header">
<h3 className="card-title">Цели мониторинга</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th>Сервер / IP</th>
<th>Статус</th>
<th>Последняя проверка</th>
<th>RTT</th>
<th>Доступность</th>
<th>Инциденты</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={key}
className={isSelected ? 'table-active' : ''}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedTargetKey(key)}
>
<td>
<div className="d-flex align-items-center">
<IconServer size={18} className="me-2 text-muted" />
{t.dns || t.name || t.ip || t.extIp || key}
</div>
</td>
<td>
{last == null && (
<span className="badge bg-secondary"></span>
)}
{last?.up === true && (
<span className="badge bg-success-lt text-success">
<IconCircleCheck size={14} /> Up
</span>
)}
{last?.up === false && (
<span className="badge bg-danger-lt text-danger">
<IconCircleX size={14} /> Down
</span>
)}
</td>
<td>{last ? formatRelative(last.ts) : '—'}</td>
<td>{last?.ms != null ? `${Math.round(last.ms)} мс` : '—'}</td>
<td>
{stats.availability != null ? `${stats.availability.toFixed(1)}%` : '—'}
</td>
<td>{stats.incidents}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
{!loading && !routerServerId && (
<div className="empty">
<div className="empty-icon">
<IconChartLine size={48} />
</div>
<p className="empty-title">Выберите роутер</p>
<p className="empty-subtitle text-muted">
Укажите jumphost или домашний роутер с MikroTik API с него будет выполняться ping по целям.
</p>
</div>
)}
</div>
</div>
);
}
@@ -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'] },