From f0cb694fa699adcf370057896ca491d80f08dbbf Mon Sep 17 00:00:00 2001 From: shats Date: Sat, 21 Feb 2026 22:46:36 +0700 Subject: [PATCH] feat(UptimeMonitorPage): implement history tracking and visualization for server uptime checks --- frontend/src/UptimeMonitorPage.jsx | 346 ++++++++++++++++++++++++----- 1 file changed, 286 insertions(+), 60 deletions(-) diff --git a/frontend/src/UptimeMonitorPage.jsx b/frontend/src/UptimeMonitorPage.jsx index 28a3f8a..6482281 100644 --- a/frontend/src/UptimeMonitorPage.jsx +++ b/frontend/src/UptimeMonitorPage.jsx @@ -8,19 +8,87 @@ import { IconCircleX, IconChartLine, } from '@tabler/icons-react'; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; import PageHeader from './components/PageHeader.jsx'; import { formatRelative } from './lib/datetime.js'; const CHECK_INTERVAL_MS = 2 * 60 * 1000; // 2 минуты -const DELAY_BETWEEN_CHECKS_MS = 1500; // пауза между проверками, чтобы не получить 429 +const DELAY_BETWEEN_CHECKS_MS = 1500; +const HISTORY_MAX = 500; +const STORAGE_KEY = 'uptime-monitor-history'; + +function loadHistoryFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') return parsed; + } catch (_) {} + return {}; +} + +function saveHistoryToStorage(historyMap) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap)); + } catch (_) {} +} + +/** Группировка истории по дням: массив { day, dayLabel, incidents } */ +function historyToIncidentsByDay(history) { + if (!Array.isArray(history) || history.length === 0) return []; + const byDay = new Map(); // dayKey -> count of down checks + history.forEach(({ ts, ok }) => { + if (ok) return; + const d = new Date(ts); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + byDay.set(key, (byDay.get(key) || 0) + 1); + }); + return Array.from(byDay.entries()) + .map(([day, incidents]) => ({ + day, + dayLabel: new Date(day + 'T12:00:00').toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'short', + }), + incidents, + })) + .sort((a, b) => a.day.localeCompare(b.day)); +} + +/** История в точки для графика задержек: { time, ts, ms } */ +function historyToResponseTimeData(history) { + if (!Array.isArray(history) || history.length === 0) return []; + return history.map(({ ts, ok, ms }) => ({ + ts, + time: new Date(ts).toLocaleString('ru-RU', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }), + ms: ok && ms != null ? Math.round(ms) : null, + })); +} export default function UptimeMonitorPage() { const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); const [error, setError] = useState(null); - /** По serverId: { ok, lastCheckTs } */ const [statusMap, setStatusMap] = useState({}); + /** По serverId: массив { ts, ok, ms } */ + const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage); + const [selectedServerId, setSelectedServerId] = useState(null); const intervalRef = useRef(null); const checkingRef = useRef(false); @@ -56,15 +124,19 @@ export default function UptimeMonitorPage() { checkingRef.current = true; setChecking(true); const results = {}; + const newHistory = {}; for (const server of jumphosts) { const serverId = server.id || server.dns || server.ip; if (!serverId) continue; + const t0 = Date.now(); + let ok = false; try { await api.post('/mikrotik/test-connection', { serverId }); - results[serverId] = { ok: true, lastCheckTs: Date.now() }; - } catch { - results[serverId] = { ok: false, lastCheckTs: Date.now() }; - } + ok = true; + } catch (_) {} + const 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)); } setStatusMap((prev) => { @@ -72,6 +144,15 @@ export default function UptimeMonitorPage() { for (const [id, v] of Object.entries(results)) next[id] = v; return next; }); + setHistoryMap((prev) => { + const next = { ...prev }; + for (const [id, one] of Object.entries(newHistory)) { + const list = Array.isArray(next[id]) ? next[id] : []; + const merged = [...list, ...one].slice(-HISTORY_MAX); + next[id] = merged; + } + return next; + }); checkingRef.current = false; setChecking(false); }, [jumphosts]); @@ -85,6 +166,24 @@ export default function UptimeMonitorPage() { }; }, [jumphosts, runChecks]); + // Сохраняем историю в localStorage при изменении + useEffect(() => { + saveHistoryToStorage(historyMap); + }, [historyMap]); + + const selectedServer = selectedServerId + ? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId) + : null; + const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : []; + const responseTimeData = useMemo( + () => historyToResponseTimeData(selectedHistory), + [selectedHistory] + ); + const incidentsByDay = useMemo( + () => historyToIncidentsByDay(selectedHistory), + [selectedHistory] + ); + return (
@@ -125,62 +224,189 @@ export default function UptimeMonitorPage() { )} {!loading && jumphosts.length > 0 && ( -
-
-

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

-
- Проверка каждые {CHECK_INTERVAL_MS / 60000} мин + <> +
+
+

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

+
+ Проверка каждые {CHECK_INTERVAL_MS / 60000} мин · Выберите сервер для графиков +
+
+
+ + + + + + + + + + {jumphosts.map((s) => { + const id = s.id || s.dns || s.ip; + const status = statusMap[id]; + const label = s.dns || s.name || s.ip || id; + const isSelected = selectedServerId === id; + return ( + setSelectedServerId(id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedServerId(id); + } + }} + className={isSelected ? 'table-active' : ''} + style={{ cursor: 'pointer' }} + > + + + + + ); + })} + +
СерверСтатусПоследняя проверка
+
+ + {label} +
+
+ {status == null && ( + + )} + {status?.ok === true && ( + + Доступен + + )} + {status?.ok === false && ( + + Недоступен + + )} + + {status?.lastCheckTs + ? formatRelative(status.lastCheckTs) + : '—'} +
-
- - - - - - - - - - {jumphosts.map((s) => { - const id = s.id || s.dns || s.ip; - const status = statusMap[id]; - const label = s.dns || s.name || s.ip || id; - return ( - - - - - - ); - })} - -
СерверСтатусПоследняя проверка
-
- - {label} -
-
- {status == null && ( - - )} - {status?.ok === true && ( - - Доступен - - )} - {status?.ok === false && ( - - Недоступен - - )} - - {status?.lastCheckTs - ? formatRelative(status.lastCheckTs) - : '—'} -
-
-
+ + {selectedServer && ( +
+
+
+
+

Время ответа за период

+
+ {selectedServer.dns || selectedServer.name || selectedServer.ip} +
+
+
+ {responseTimeData.length === 0 ? ( +

+ Нет данных. Проверки накапливаются со временем (хранятся в браузере). +

+ ) : ( + + + + + + + + + + + Math.max(dataMax || 0, 100)]} + /> + [value != null ? `${value} мс` : '—', 'Задержка']} + labelFormatter={(_, payload) => + payload[0]?.payload?.time ?? '' + } + /> + + + + )} +
+
+
+
+
+
+

Инциденты по дням

+
+ Недоступность (кол-во проверок) +
+
+
+ {incidentsByDay.length === 0 ? ( +

+ Нет инцидентов за сохранённый период или нет данных. +

+ ) : ( + + + + + Math.max(dataMax || 0, 1)]} + /> + [value, 'Инцидентов']} + labelFormatter={(_, payload) => + payload[0]?.payload?.day ?? '' + } + /> + + + + )} +
+
+
+
+ )} + )}