diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js
index eb195b7..dea73a8 100644
--- a/backend/routes/mikrotikConfigRoutes.js
+++ b/backend/routes/mikrotikConfigRoutes.js
@@ -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
* Body: { serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }
@@ -1344,4 +1453,5 @@ module.exports = {
loadNetworkConfig,
getAddressLists,
applyAddressListSummary,
+ uptimeCheck,
};
diff --git a/backend/server.js b/backend/server.js
index 272faed..4156350 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -470,6 +470,9 @@ app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
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) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx
index fc68a23..d521e12 100644
--- a/frontend/src/SettingsPage.jsx
+++ b/frontend/src/SettingsPage.jsx
@@ -13,6 +13,7 @@ import {
IconChartBar,
IconRefresh,
IconServer,
+ IconClock,
} from '@tabler/icons-react';
import FormField from './components/FormField';
import ErrorAlert from './components/ErrorAlert';
@@ -37,6 +38,7 @@ const SIDEBAR_GROUPS = [
items: [
{ id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud },
{ 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 [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
+ const [uptimeMonitorIntervalSeconds, setUptimeMonitorIntervalSeconds] = useState('120');
+ const [uptimeMonitorCheckType, setUptimeMonitorCheckType] = useState('http');
const [serversList, setServersList] = useState([]);
const [sidebarSearch, setSidebarSearch] = useState('');
const [activeSection, setActiveSection] = useState(() => {
@@ -247,6 +251,15 @@ export default function SettingsPage() {
? 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;
setTrafficInterfacesSelected(
Array.isArray(raw)
@@ -349,6 +362,14 @@ export default function SettingsPage() {
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)
? trafficInterfacesSelected.map((p) => ({
serverKey: p.serverKey,
@@ -704,6 +725,47 @@ export default function SettingsPage() {
>
)}
+ {activeSection === 'uptime-monitor' && (
+ <>
+
+ Настройки страницы «Uptime Monitor»: интервал проверки доступности Jumphost/Home и способ проверки. +
+