refactor(UptimeMonitorPage): optimize uptime checks with memoization and update check interval to 2 minutes
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
This commit is contained in:
+106
-361
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -7,109 +7,30 @@ import {
|
|||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
IconCircleX,
|
IconCircleX,
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
IconPlayerPlay,
|
|
||||||
IconPlayerPause,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
|
||||||
import { formatRelative } from './lib/datetime.js';
|
import { formatRelative } from './lib/datetime.js';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000; // 1 минута
|
const CHECK_INTERVAL_MS = 2 * 60 * 1000; // 2 минуты
|
||||||
const HISTORY_MAX = 300; // храним последние 300 проверок для расчёта доступности
|
const DELAY_BETWEEN_CHECKS_MS = 1500; // пауза между проверками, чтобы не получить 429
|
||||||
|
|
||||||
/** Форматирование длительности (секунды → "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() {
|
export default function UptimeMonitorPage() {
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [routerServerId, setRouterServerId] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pinging, setPinging] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
/** По каждому target (ip или id): массив { ts, up, ms } */
|
/** По serverId: { ok, lastCheckTs } */
|
||||||
const [historyMap, setHistoryMap] = useState({});
|
const [statusMap, setStatusMap] = useState({});
|
||||||
/** Выбранная цель для детальной карточки (ip или id) */
|
|
||||||
const [selectedTargetKey, setSelectedTargetKey] = useState(null);
|
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
const pausedRef = useRef(false);
|
const checkingRef = useRef(false);
|
||||||
|
|
||||||
const routerServers = servers.filter(
|
const jumphosts = useMemo(
|
||||||
(s) => ['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
|
() =>
|
||||||
|
servers.filter((s) =>
|
||||||
|
['jumphost', 'home'].includes(String(s.type || '').toLowerCase())
|
||||||
|
),
|
||||||
|
[servers]
|
||||||
);
|
);
|
||||||
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 () => {
|
const fetchServers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -129,70 +50,40 @@ export default function UptimeMonitorPage() {
|
|||||||
fetchServers();
|
fetchServers();
|
||||||
}, [fetchServers]);
|
}, [fetchServers]);
|
||||||
|
|
||||||
const runPings = useCallback(async () => {
|
const runChecks = useCallback(async () => {
|
||||||
if (!routerServerId || targets.length === 0) return;
|
if (jumphosts.length === 0) return;
|
||||||
setPinging(true);
|
if (checkingRef.current) return;
|
||||||
const serverId = routerServerId;
|
checkingRef.current = true;
|
||||||
|
setChecking(true);
|
||||||
const results = {};
|
const results = {};
|
||||||
for (const target of targets) {
|
for (const server of jumphosts) {
|
||||||
const ip = target.ip || target.extIp;
|
const serverId = server.id || server.dns || server.ip;
|
||||||
if (!ip) continue;
|
if (!serverId) continue;
|
||||||
const key = target.id || target.ip || target.dns || ip;
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/mikrotik/ping', {
|
await api.post('/mikrotik/test-connection', { serverId });
|
||||||
serverId,
|
results[serverId] = { ok: true, lastCheckTs: Date.now() };
|
||||||
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 {
|
} catch {
|
||||||
results[key] = { ts: Date.now(), up: false, ms: null };
|
results[serverId] = { ok: false, lastCheckTs: Date.now() };
|
||||||
}
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, DELAY_BETWEEN_CHECKS_MS));
|
||||||
}
|
}
|
||||||
setHistoryMap((prev) => {
|
setStatusMap((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const [key, entry] of Object.entries(results)) {
|
for (const [id, v] of Object.entries(results)) next[id] = v;
|
||||||
const list = Array.isArray(next[key]) ? next[key] : [];
|
|
||||||
const newList = [...list, entry].slice(-HISTORY_MAX);
|
|
||||||
next[key] = newList;
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setPinging(false);
|
checkingRef.current = false;
|
||||||
}, [routerServerId, targets]);
|
setChecking(false);
|
||||||
|
}, [jumphosts]);
|
||||||
|
|
||||||
/** Автообновление по интервалу */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!routerServerId || pausedRef.current) return;
|
if (jumphosts.length === 0) return;
|
||||||
runPings();
|
runChecks();
|
||||||
intervalRef.current = setInterval(runPings, CHECK_INTERVAL_MS);
|
intervalRef.current = setInterval(runChecks, CHECK_INTERVAL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [routerServerId, runPings]);
|
}, [jumphosts, runChecks]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
@@ -201,37 +92,17 @@ export default function UptimeMonitorPage() {
|
|||||||
title="Uptime Monitor"
|
title="Uptime Monitor"
|
||||||
icon={<IconClock size={28} />}
|
icon={<IconClock size={28} />}
|
||||||
pretitle="Extra"
|
pretitle="Extra"
|
||||||
meta="Мониторинг доступности по ping с выбранного роутера (как на карте сети)"
|
meta="Проверка доступности всех Jumphost и домашних роутеров (MikroTik API)"
|
||||||
actions={
|
actions={
|
||||||
<div className="btn-list">
|
<button
|
||||||
<label className="form-label mb-0 me-2 align-self-center">Роутер (откуда пинговать)</label>
|
type="button"
|
||||||
<ServerAutocompleteInput
|
className="btn btn-primary"
|
||||||
value={routerServerId}
|
onClick={runChecks}
|
||||||
onChange={(v) => setRouterServerId(String(v || '').trim())}
|
disabled={jumphosts.length === 0 || checking}
|
||||||
servers={routerServers}
|
>
|
||||||
placeholder="Jumphost или домашний роутер"
|
<IconRefresh className={checking ? 'spin' : ''} size={18} />
|
||||||
className="form-control form-control-flush"
|
{checking ? ' Проверка…' : ' Проверить все'}
|
||||||
maxSuggestions={10}
|
</button>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -241,202 +112,76 @@ export default function UptimeMonitorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && routerServerId && targets.length === 0 && (
|
{!loading && jumphosts.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">
|
||||||
<div className="empty-icon">
|
<div className="empty-icon">
|
||||||
<IconChartLine size={48} />
|
<IconChartLine size={48} />
|
||||||
</div>
|
</div>
|
||||||
<p className="empty-title">Выберите роутер</p>
|
<p className="empty-title">Нет Jumphost</p>
|
||||||
<p className="empty-subtitle text-muted">
|
<p className="empty-subtitle text-muted">
|
||||||
Укажите jumphost или домашний роутер с MikroTik API — с него будет выполняться ping по целям.
|
Добавьте серверы типа «Jumphost» или «Home» с настроенным MikroTik API — здесь будет отображаться их доступность.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && jumphosts.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
|
||||||
|
<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;
|
||||||
|
return (
|
||||||
|
<tr key={id}>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user