feat(UptimeMonitor): add uptime check endpoint and frontend settings for monitoring server availability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m34s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m34s
This commit is contained in:
@@ -244,6 +244,115 @@ async function testMikrotikConnection(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/uptime/check
|
||||||
|
* Проверка доступности одного сервера (jumphost/home). Тип проверки берётся из ui-settings.uptimeMonitorCheckType.
|
||||||
|
* Body: { serverId }
|
||||||
|
* Returns: { ok: boolean, ms?: number }
|
||||||
|
*/
|
||||||
|
async function uptimeCheck(req, res) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const { serverId } = req.body || {};
|
||||||
|
if (!serverId) {
|
||||||
|
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
const uiSettings = await loadUiSettings();
|
||||||
|
const checkType = String(uiSettings.uptimeMonitorCheckType || 'http').toLowerCase();
|
||||||
|
const effectiveType =
|
||||||
|
checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http';
|
||||||
|
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
|
||||||
|
if (!server || !['jumphost', 'home'].includes(String(server.type || '').toLowerCase())) {
|
||||||
|
return sendError(res, 400, 'Jumphost или Home сервер не найден', 'E_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
let ms = null;
|
||||||
|
|
||||||
|
if (effectiveType === 'http') {
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
return sendError(res, 400, 'MikroTik credentials not configured', 'E_CREDENTIALS');
|
||||||
|
}
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
await client.print('system/resource');
|
||||||
|
ok = true;
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok, ms });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveType === 'internal-ping') {
|
||||||
|
const config = await loadNetworkConfig();
|
||||||
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
const iface = tunnelInterfaces.find(
|
||||||
|
(i) =>
|
||||||
|
(i.serverId && (i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns)) ||
|
||||||
|
(i.serverId2 && (i.serverId2 === serverId || i.serverId2 === server.ip || i.serverId2 === server.dns))
|
||||||
|
);
|
||||||
|
if (!iface || (!iface.remoteIp && !iface.localIp)) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: 'Нет туннеля с внутренним адресом для этого сервера' });
|
||||||
|
}
|
||||||
|
const target = iface.serverId === serverId || iface.serverId === server.ip || iface.serverId === server.dns
|
||||||
|
? iface.remoteIp
|
||||||
|
: iface.localIp;
|
||||||
|
const gatewayIp = target;
|
||||||
|
if (!target) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: 'Нет целевого адреса для пинга' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await runPingViaRouter(serverId, gatewayIp, target, 3);
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
ok = typeof result.avgMs === 'number';
|
||||||
|
return res.json({ ok, ms: ok ? result.avgMs : ms });
|
||||||
|
} catch (err) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveType === 'external-ping') {
|
||||||
|
const others = servers.filter(
|
||||||
|
(s) =>
|
||||||
|
['jumphost', 'home'].includes(String(s.type || '').toLowerCase()) &&
|
||||||
|
(s.id || s.dns || s.ip) !== serverId &&
|
||||||
|
s.ip !== server.ip
|
||||||
|
);
|
||||||
|
const sourceServer = others[0];
|
||||||
|
if (!sourceServer) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: 'Нет другого jumphost для внешнего пинга' });
|
||||||
|
}
|
||||||
|
const targetIp = server.ip || server.extIp;
|
||||||
|
if (!targetIp) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: 'У сервера нет внешнего IP' });
|
||||||
|
}
|
||||||
|
const sourceId = sourceServer.id || sourceServer.dns || sourceServer.ip;
|
||||||
|
try {
|
||||||
|
const result = await runPingViaRouter(sourceId, null, targetIp, 3);
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
ok = typeof result.avgMs === 'number';
|
||||||
|
return res.json({ ok, ms: ok ? result.avgMs : ms });
|
||||||
|
} catch (err) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms, error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
return res.json({ ok: false, ms });
|
||||||
|
} catch (error) {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
const msg = error?.message || String(error);
|
||||||
|
console.error('uptimeCheck:', error);
|
||||||
|
return res.status(502).json({ ok: false, ms, error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/mikrotik/apply
|
* POST /api/mikrotik/apply
|
||||||
* Body: { serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }
|
* Body: { serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }
|
||||||
@@ -1344,4 +1453,5 @@ module.exports = {
|
|||||||
loadNetworkConfig,
|
loadNetworkConfig,
|
||||||
getAddressLists,
|
getAddressLists,
|
||||||
applyAddressListSummary,
|
applyAddressListSummary,
|
||||||
|
uptimeCheck,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -470,6 +470,9 @@ app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
|||||||
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
||||||
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
||||||
|
|
||||||
|
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
||||||
|
app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck);
|
||||||
|
|
||||||
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
||||||
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconServer,
|
IconServer,
|
||||||
|
IconClock,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FormField from './components/FormField';
|
import FormField from './components/FormField';
|
||||||
import ErrorAlert from './components/ErrorAlert';
|
import ErrorAlert from './components/ErrorAlert';
|
||||||
@@ -37,6 +38,7 @@ const SIDEBAR_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud },
|
{ id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud },
|
||||||
{ id: 'ping-services', title: 'Пинг на главной', icon: IconChartPie },
|
{ id: 'ping-services', title: 'Пинг на главной', icon: IconChartPie },
|
||||||
|
{ id: 'uptime-monitor', title: 'Uptime Monitor', icon: IconClock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,6 +84,8 @@ export default function SettingsPage() {
|
|||||||
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
||||||
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
||||||
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
|
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
|
||||||
|
const [uptimeMonitorIntervalSeconds, setUptimeMonitorIntervalSeconds] = useState('120');
|
||||||
|
const [uptimeMonitorCheckType, setUptimeMonitorCheckType] = useState('http');
|
||||||
const [serversList, setServersList] = useState([]);
|
const [serversList, setServersList] = useState([]);
|
||||||
const [sidebarSearch, setSidebarSearch] = useState('');
|
const [sidebarSearch, setSidebarSearch] = useState('');
|
||||||
const [activeSection, setActiveSection] = useState(() => {
|
const [activeSection, setActiveSection] = useState(() => {
|
||||||
@@ -247,6 +251,15 @@ export default function SettingsPage() {
|
|||||||
? String(data.interfaceSpeedTestCacheMinutes)
|
? String(data.interfaceSpeedTestCacheMinutes)
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
setUptimeMonitorIntervalSeconds(
|
||||||
|
data?.uptimeMonitorIntervalSeconds != null
|
||||||
|
? String(data.uptimeMonitorIntervalSeconds)
|
||||||
|
: '120'
|
||||||
|
);
|
||||||
|
const checkType = String(data?.uptimeMonitorCheckType || 'http').toLowerCase();
|
||||||
|
setUptimeMonitorCheckType(
|
||||||
|
checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http'
|
||||||
|
);
|
||||||
const raw = data?.trafficInterfaces;
|
const raw = data?.trafficInterfaces;
|
||||||
setTrafficInterfacesSelected(
|
setTrafficInterfacesSelected(
|
||||||
Array.isArray(raw)
|
Array.isArray(raw)
|
||||||
@@ -349,6 +362,14 @@ export default function SettingsPage() {
|
|||||||
0,
|
0,
|
||||||
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
|
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
|
||||||
),
|
),
|
||||||
|
uptimeMonitorIntervalSeconds: Math.max(
|
||||||
|
30,
|
||||||
|
parseInt(uptimeMonitorIntervalSeconds, 10) || 120
|
||||||
|
),
|
||||||
|
uptimeMonitorCheckType:
|
||||||
|
uptimeMonitorCheckType === 'internal-ping' || uptimeMonitorCheckType === 'external-ping'
|
||||||
|
? uptimeMonitorCheckType
|
||||||
|
: 'http',
|
||||||
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
|
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
|
||||||
? trafficInterfacesSelected.map((p) => ({
|
? trafficInterfacesSelected.map((p) => ({
|
||||||
serverKey: p.serverKey,
|
serverKey: p.serverKey,
|
||||||
@@ -704,6 +725,47 @@ export default function SettingsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'uptime-monitor' && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="Uptime Monitor" icon={IconClock} />
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Настройки страницы «Uptime Monitor»: интервал проверки доступности Jumphost/Home и способ проверки.
|
||||||
|
</p>
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Интервал проверки (сек)"
|
||||||
|
name="uptimeMonitorIntervalSeconds"
|
||||||
|
type="number"
|
||||||
|
value={uptimeMonitorIntervalSeconds}
|
||||||
|
onChange={setUptimeMonitorIntervalSeconds}
|
||||||
|
placeholder="120"
|
||||||
|
helpText="Как часто проверять доступность (по умолчанию 120 сек)."
|
||||||
|
disabled={saving}
|
||||||
|
min={30}
|
||||||
|
max={86400}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Тип проверки</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={uptimeMonitorCheckType}
|
||||||
|
onChange={(e) => setUptimeMonitorCheckType(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="http">HTTP — доступность MikroTik REST API</option>
|
||||||
|
<option value="internal-ping">Внутренний пинг — по внутренним адресам туннелей</option>
|
||||||
|
<option value="external-ping">Внешний пинг — по внешним IP серверов</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text">
|
||||||
|
HTTP: подключение к RouterOS API. Внутренний: пинг через туннель (как на карте сети). Внешний: пинг внешнего IP с другого jumphost.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSection === 'ptr-zone' && (
|
{activeSection === 'ptr-zone' && (
|
||||||
<>
|
<>
|
||||||
<SectionHeading title="Настройка PTR зоны" icon={IconWorld} />
|
<SectionHeading title="Настройка PTR зоны" icon={IconWorld} />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
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 DEFAULT_CHECK_INTERVAL_SEC = 120;
|
||||||
const DELAY_BETWEEN_CHECKS_MS = 1500;
|
const DELAY_BETWEEN_CHECKS_MS = 1500;
|
||||||
const HISTORY_MAX = 500;
|
const HISTORY_MAX = 500;
|
||||||
const STORAGE_KEY = 'uptime-monitor-history';
|
const STORAGE_KEY = 'uptime-monitor-history';
|
||||||
@@ -89,6 +89,7 @@ export default function UptimeMonitorPage() {
|
|||||||
/** По serverId: массив { ts, ok, ms } */
|
/** По serverId: массив { ts, ok, ms } */
|
||||||
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
|
const [historyMap, setHistoryMap] = useState(loadHistoryFromStorage);
|
||||||
const [selectedServerId, setSelectedServerId] = useState(null);
|
const [selectedServerId, setSelectedServerId] = useState(null);
|
||||||
|
const [checkIntervalSeconds, setCheckIntervalSeconds] = useState(DEFAULT_CHECK_INTERVAL_SEC);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
const checkingRef = useRef(false);
|
const checkingRef = useRef(false);
|
||||||
|
|
||||||
@@ -104,8 +105,15 @@ export default function UptimeMonitorPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/servers');
|
const [serversRes, settingsRes] = await Promise.all([
|
||||||
|
api.get('/servers'),
|
||||||
|
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
const data = serversRes?.data;
|
||||||
setServers(Array.isArray(data) ? data : []);
|
setServers(Array.isArray(data) ? data : []);
|
||||||
|
const ui = settingsRes?.data || {};
|
||||||
|
const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC));
|
||||||
|
setCheckIntervalSeconds(interval);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
|
||||||
setServers([]);
|
setServers([]);
|
||||||
@@ -118,6 +126,8 @@ export default function UptimeMonitorPage() {
|
|||||||
fetchServers();
|
fetchServers();
|
||||||
}, [fetchServers]);
|
}, [fetchServers]);
|
||||||
|
|
||||||
|
const checkIntervalMs = checkIntervalSeconds * 1000;
|
||||||
|
|
||||||
const runChecks = useCallback(async () => {
|
const runChecks = useCallback(async () => {
|
||||||
if (jumphosts.length === 0) return;
|
if (jumphosts.length === 0) return;
|
||||||
if (checkingRef.current) return;
|
if (checkingRef.current) return;
|
||||||
@@ -130,11 +140,14 @@ export default function UptimeMonitorPage() {
|
|||||||
if (!serverId) continue;
|
if (!serverId) continue;
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
let ms = null;
|
||||||
try {
|
try {
|
||||||
await api.post('/mikrotik/test-connection', { serverId });
|
const { data: checkData } = await api.post('/uptime/check', { serverId });
|
||||||
ok = true;
|
ok = checkData?.ok === true;
|
||||||
} catch (_) {}
|
ms = typeof checkData?.ms === 'number' ? checkData.ms : Date.now() - t0;
|
||||||
const ms = Date.now() - t0;
|
} catch (_) {
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
}
|
||||||
results[serverId] = { ok, lastCheckTs: Date.now() };
|
results[serverId] = { ok, lastCheckTs: Date.now() };
|
||||||
newHistory[serverId] = [{ ts: Date.now(), ok, ms }];
|
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));
|
||||||
@@ -160,11 +173,11 @@ export default function UptimeMonitorPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jumphosts.length === 0) return;
|
if (jumphosts.length === 0) return;
|
||||||
runChecks();
|
runChecks();
|
||||||
intervalRef.current = setInterval(runChecks, CHECK_INTERVAL_MS);
|
intervalRef.current = setInterval(runChecks, checkIntervalMs);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
}, [jumphosts, runChecks]);
|
}, [jumphosts, runChecks, checkIntervalMs]);
|
||||||
|
|
||||||
// Сохраняем историю в localStorage при изменении
|
// Сохраняем историю в localStorage при изменении
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,7 +242,7 @@ export default function UptimeMonitorPage() {
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
|
<h3 className="card-title">Доступность Jumphost и домашних роутеров</h3>
|
||||||
<div className="card-actions text-muted small">
|
<div className="card-actions text-muted small">
|
||||||
Проверка каждые {CHECK_INTERVAL_MS / 60000} мин · Выберите сервер для графиков
|
Проверка каждые {checkIntervalSeconds} сек · Выберите сервер для графиков
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
|
|||||||
Reference in New Issue
Block a user