feat(UptimeMonitorPage): implement history tracking and visualization for server uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s

This commit is contained in:
2026-02-21 22:46:36 +07:00
parent 28a6978243
commit f0cb694fa6
+286 -60
View File
@@ -8,19 +8,87 @@ import {
IconCircleX, IconCircleX,
IconChartLine, IconChartLine,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
import { formatRelative } from './lib/datetime.js'; import { formatRelative } from './lib/datetime.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000; // 2 минуты 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() { export default function UptimeMonitorPage() {
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
/** По serverId: { ok, lastCheckTs } */
const [statusMap, setStatusMap] = useState({}); const [statusMap, setStatusMap] = useState({});
/** По serverId: массив { ts, ok, ms } */
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
const [selectedServerId, setSelectedServerId] = useState(null);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const checkingRef = useRef(false); const checkingRef = useRef(false);
@@ -56,15 +124,19 @@ export default function UptimeMonitorPage() {
checkingRef.current = true; checkingRef.current = true;
setChecking(true); setChecking(true);
const results = {}; const results = {};
const newHistory = {};
for (const server of jumphosts) { for (const server of jumphosts) {
const serverId = server.id || server.dns || server.ip; const serverId = server.id || server.dns || server.ip;
if (!serverId) continue; if (!serverId) continue;
const t0 = Date.now();
let ok = false;
try { try {
await api.post('/mikrotik/test-connection', { serverId }); await api.post('/mikrotik/test-connection', { serverId });
results[serverId] = { ok: true, lastCheckTs: Date.now() }; ok = true;
} catch { } catch (_) {}
results[serverId] = { ok: false, lastCheckTs: Date.now() }; 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)); await new Promise((r) => setTimeout(r, DELAY_BETWEEN_CHECKS_MS));
} }
setStatusMap((prev) => { setStatusMap((prev) => {
@@ -72,6 +144,15 @@ export default function UptimeMonitorPage() {
for (const [id, v] of Object.entries(results)) next[id] = v; for (const [id, v] of Object.entries(results)) next[id] = v;
return next; 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; checkingRef.current = false;
setChecking(false); setChecking(false);
}, [jumphosts]); }, [jumphosts]);
@@ -85,6 +166,24 @@ export default function UptimeMonitorPage() {
}; };
}, [jumphosts, runChecks]); }, [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 ( return (
<div className="page-body"> <div className="page-body">
<div className="container-fluid"> <div className="container-fluid">
@@ -125,62 +224,189 @@ export default function UptimeMonitorPage() {
)} )}
{!loading && jumphosts.length > 0 && ( {!loading && jumphosts.length > 0 && (
<div className="card"> <>
<div className="card-header"> <div className="card">
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3> <div className="card-header">
<div className="card-actions text-muted small"> <h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
Проверка каждые {CHECK_INTERVAL_MS / 60000} мин <div className="card-actions text-muted small">
Проверка каждые {CHECK_INTERVAL_MS / 60000} мин · Выберите сервер для графиков
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped">
<thead>
<tr>
<th>Сервер</th>
<th>Статус</th>
<th>Последняя проверка</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedServerId(id);
}
}}
className={isSelected ? 'table-active' : ''}
style={{ cursor: 'pointer' }}
>
<td>
<div className="d-flex align-items-center">
<IconServer size={18} className="me-2 text-muted" />
{label}
</div>
</td>
<td>
{status == null && (
<span className="badge bg-secondary"></span>
)}
{status?.ok === true && (
<span className="badge bg-success-lt text-success">
<IconCircleCheck size={14} /> Доступен
</span>
)}
{status?.ok === false && (
<span className="badge bg-danger-lt text-danger">
<IconCircleX size={14} /> Недоступен
</span>
)}
</td>
<td className="text-muted">
{status?.lastCheckTs
? formatRelative(status.lastCheckTs)
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
</div> </div>
<div className="table-responsive">
<table className="table table-vcenter card-table table-striped"> {selectedServer && (
<thead> <div className="row mt-4">
<tr> <div className="col-lg-6 mb-4">
<th>Сервер</th> <div className="card h-100">
<th>Статус</th> <div className="card-header">
<th>Последняя проверка</th> <h3 className="card-title">Время ответа за период</h3>
</tr> <div className="card-actions text-muted small">
</thead> {selectedServer.dns || selectedServer.name || selectedServer.ip}
<tbody> </div>
{jumphosts.map((s) => { </div>
const id = s.id || s.dns || s.ip; <div className="card-body">
const status = statusMap[id]; {responseTimeData.length === 0 ? (
const label = s.dns || s.name || s.ip || id; <p className="text-muted mb-0">
return ( Нет данных. Проверки накапливаются со временем (хранятся в браузере).
<tr key={id}> </p>
<td> ) : (
<div className="d-flex align-items-center"> <ResponsiveContainer width="100%" height={260}>
<IconServer size={18} className="me-2 text-muted" /> <AreaChart
{label} data={responseTimeData}
</div> margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
</td> >
<td> <defs>
{status == null && ( <linearGradient id="colorMs" x1="0" y1="0" x2="0" y2="1">
<span className="badge bg-secondary"></span> <stop offset="5%" stopColor="#206bc4" stopOpacity={0.3} />
)} <stop offset="95%" stopColor="#206bc4" stopOpacity={0} />
{status?.ok === true && ( </linearGradient>
<span className="badge bg-success-lt text-success"> </defs>
<IconCircleCheck size={14} /> Доступен <CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
</span> <XAxis
)} dataKey="time"
{status?.ok === false && ( tick={{ fontSize: 11 }}
<span className="badge bg-danger-lt text-danger"> interval="preserveStartEnd"
<IconCircleX size={14} /> Недоступен />
</span> <YAxis
)} unit=" мс"
</td> tick={{ fontSize: 11 }}
<td className="text-muted"> domain={[0, (dataMax) => Math.max(dataMax || 0, 100)]}
{status?.lastCheckTs />
? formatRelative(status.lastCheckTs) <Tooltip
: '—'} formatter={(value) => [value != null ? `${value} мс` : '—', 'Задержка']}
</td> labelFormatter={(_, payload) =>
</tr> payload[0]?.payload?.time ?? ''
); }
})} />
</tbody> <Area
</table> type="monotone"
</div> dataKey="ms"
</div> stroke="#206bc4"
fill="url(#colorMs)"
strokeWidth={2}
connectNulls={false}
dot={false}
activeDot={{ r: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
<div className="col-lg-6 mb-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Инциденты по дням</h3>
<div className="card-actions text-muted small">
Недоступность (кол-во проверок)
</div>
</div>
<div className="card-body">
{incidentsByDay.length === 0 ? (
<p className="text-muted mb-0">
Нет инцидентов за сохранённый период или нет данных.
</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={incidentsByDay}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
<XAxis
dataKey="dayLabel"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11 }}
domain={[0, (dataMax) => Math.max(dataMax || 0, 1)]}
/>
<Tooltip
formatter={(value) => [value, 'Инцидентов']}
labelFormatter={(_, payload) =>
payload[0]?.payload?.day ?? ''
}
/>
<Bar
dataKey="incidents"
fill="#d63939"
radius={[2, 2, 0, 0]}
name="Инциденты"
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
)}
</>
)} )}
</div> </div>
</div> </div>