feat(UptimeMonitorPage): implement history tracking and visualization for server uptime checks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m19s
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user