diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js
index bd958f7..889b86b 100644
--- a/backend/routes/mikrotikConfigRoutes.js
+++ b/backend/routes/mikrotikConfigRoutes.js
@@ -3,8 +3,9 @@
* Использует RouterOS REST API — требует RouterOS 7.1+ с www-ssl (443) или www (80)
*/
+const crypto = require('crypto');
const { GetObjectCommand } = require('@aws-sdk/client-s3');
-const { s3, BUCKET_NAME, streamToString, readS3TextObject } = require('../services/s3Service');
+const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
@@ -19,8 +20,9 @@ const { createRosClient, applyBlock } = require('../services/mikrotikApplyServic
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
+const PING_CACHE_PREFIX = 'ping-cache/';
-/** Загрузить UI-настройки из S3 (для pingDomain и др.) */
+/** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */
async function loadUiSettings() {
try {
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
@@ -31,6 +33,12 @@ async function loadUiSettings() {
}
}
+/** Ключ кеша пинга по serverId, gatewayIp, target */
+function pingCacheKey(serverId, gatewayIp, target) {
+ const payload = `${serverId}|${gatewayIp || ''}|${target || ''}`;
+ return PING_CACHE_PREFIX + crypto.createHash('sha256').update(payload, 'utf8').digest('hex') + '.json';
+}
+
/** Загрузить network-config из S3 */
async function loadNetworkConfig() {
try {
@@ -311,15 +319,41 @@ async function pingViaInterface(req, res) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
- // Цель пинга: из тела запроса, иначе из настроек интерфейса (Домен для пинга), иначе по умолчанию
+ const uiSettings = await loadUiSettings();
+
+ // Цель пинга: из тела запроса, иначе из настроек (Домен для пинга), иначе по умолчанию
if (target == null || String(target).trim() === '') {
- const uiSettings = await loadUiSettings();
const pingDomain = (uiSettings.pingDomain && String(uiSettings.pingDomain).trim()) || '';
target = pingDomain || 'www.gstatic.com';
} else {
target = String(target).trim();
}
+ const pingCacheMinutes = Math.max(0, parseInt(uiSettings.pingCacheMinutes, 10) || 0);
+ const cacheKey = pingCacheMinutes > 0 ? pingCacheKey(serverId, gatewayIp, target) : null;
+
+ if (cacheKey) {
+ try {
+ const data = await readS3TextObject(cacheKey).catch(() => null);
+ if (data?.body) {
+ const cached = JSON.parse(data.body);
+ const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0;
+ const ttlMs = pingCacheMinutes * 60 * 1000;
+ if (cachedAt && Date.now() - cachedAt < ttlMs) {
+ return res.json({
+ ok: true,
+ avgMs: cached.avgMs,
+ minMs: cached.minMs,
+ maxMs: cached.maxMs,
+ loss: cached.loss,
+ });
+ }
+ }
+ } catch (_) {
+ // кеш протух или невалиден — выполняем реальный пинг
+ }
+ }
+
const servers = await readServersFromS3();
const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId);
if (!server || server.type !== 'jumphost') {
@@ -486,15 +520,19 @@ async function pingViaInterface(req, res) {
? Number(String(summary['packet-loss']).replace('%', ''))
: null;
- // ВАЖНО: возвращаем данные напрямую, без sendOk,
- // чтобы фронтенд (EasySwitch) мог читать поля avgMs/minMs/maxMs/loss
- return res.json({
- ok: true,
- avgMs,
- minMs,
- maxMs,
- loss,
- });
+ const result = { ok: true, avgMs, minMs, maxMs, loss };
+
+ if (cacheKey) {
+ writeS3JsonObject(cacheKey, {
+ avgMs,
+ minMs,
+ maxMs,
+ loss,
+ cachedAt: Date.now(),
+ }).catch((err) => console.warn('[mikrotik][pingViaInterface] cache write failed:', err?.message));
+ }
+
+ return res.json(result);
} catch (error) {
const msg = error.response?.data?.message || error.message || 'Ping failed';
const status = error.response?.status;
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
index 8ce7715..e183cee 100644
--- a/frontend/src/components/SettingsModal.jsx
+++ b/frontend/src/components/SettingsModal.jsx
@@ -19,6 +19,7 @@ export default function SettingsModal({ open, onClose }) {
const [wsUrl, setWsUrl] = useState('');
const [baseAS, setBaseAS] = useState('65001');
const [pingDomain, setPingDomain] = useState('');
+ const [pingCacheMinutes, setPingCacheMinutes] = useState('');
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
@@ -37,6 +38,7 @@ export default function SettingsModal({ open, onClose }) {
setWsUrl(String(data?.wsUpdateUrl || ''));
setBaseAS(String(data?.baseAS || '65001'));
setPingDomain(String(data?.pingDomain || '').trim());
+ setPingCacheMinutes(data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '');
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
const e = res?.headers?.etag || res?.headers?.ETag || '';
@@ -100,6 +102,7 @@ export default function SettingsModal({ open, onClose }) {
wsUpdateUrl: String(wsUrl || '').trim(),
baseAS: String(baseAS || '65001').trim(),
pingDomain: String(pingDomain || '').trim(),
+ pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
};
@@ -212,6 +215,18 @@ export default function SettingsModal({ open, onClose }) {
disabled={loading || saving}
/>
+