From 275f2f6929ba9a9ec33fd009e85d37bf36c86867 Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 9 Feb 2026 15:13:56 +0700 Subject: [PATCH] feat(mikrotikBackup): add MikroTik backup functionality with S3 integration and implement backup scheduler --- backend/routes/mikrotikBackupRoutes.js | 204 +++++++++ backend/server.js | 15 + backend/services/mikrotikBackupScheduler.js | 185 +++++++++ backend/services/s3Service.js | 39 +- frontend/src/App.jsx | 12 +- frontend/src/MikrotikBackupsManager.jsx | 433 ++++++++++++++++++++ 6 files changed, 883 insertions(+), 5 deletions(-) create mode 100644 backend/routes/mikrotikBackupRoutes.js create mode 100644 backend/services/mikrotikBackupScheduler.js create mode 100644 frontend/src/MikrotikBackupsManager.jsx diff --git a/backend/routes/mikrotikBackupRoutes.js b/backend/routes/mikrotikBackupRoutes.js new file mode 100644 index 0000000..bc12d97 --- /dev/null +++ b/backend/routes/mikrotikBackupRoutes.js @@ -0,0 +1,204 @@ +/** + * Роуты для бэкапов конфигурации MikroTik + * Хранят сырый текст конфигурации (например, вывод /export) в S3. + * + * Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc + * + * Задачи: + * - создать бэкап (ручной или из планировщика) + * - получить список бэкапов для сервера + * - прочитать конкретный бэкап + * - (опционально) отдать две версии для сравнения и отката + */ + +const crypto = require('crypto'); +const { sendError } = require('../middleware/errorHandler'); +const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service'); + +const BACKUP_PREFIX = 'backups/mikrotik'; + +function buildBackupKey(serverId, createdAt, suffix) { + const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_'); + const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z'); + const rand = suffix || crypto.randomBytes(3).toString('hex'); + return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`; +} + +/** + * Внутренняя утилита: сохранить бэкап в S3. + * Используется как HTTP-роутом, так и планировщиком. + */ +async function saveBackupForServer(serverId, config, { source, comment } = {}) { + const now = new Date(); + const key = buildBackupKey(serverId, now); + + const headerLines = [ + '# MikroTik configuration backup', + `# Server: ${serverId}`, + `# CreatedAt: ${now.toISOString()}`, + source ? `# Source: ${String(source)}` : null, + comment ? `# Comment: ${String(comment).replace(/\r?\n/g, ' ')}` : null, + '', + ].filter(Boolean); + + const content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`; + + const meta = await writeS3TextObject(key, content, 'text/plain'); + return { key, createdAt: now.toISOString(), s3: meta }; +} + +/** + * POST /api/mikrotik/backups + * Body: { serverId: string, config: string, source?: string, comment?: string } + * + * Сохраняет текст конфигурации (обычно вывод /export) в S3. + * Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт. + */ +async function createBackup(req, res) { + try { + const { serverId, config, source, comment } = req.body || {}; + + if (!serverId || typeof serverId !== 'string') { + return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); + } + if (!config || typeof config !== 'string') { + return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST'); + } + + const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment }); + + return res.json({ + ok: true, + key, + serverId, + createdAt, + s3, + }); + } catch (error) { + console.error('createBackup error:', error); + return sendError(res, 500, error.message || 'Error creating backup', 'E_BACKUP_CREATE'); + } +} + +/** + * GET /api/mikrotik/backups?serverId=... + * + * Возвращает список бэкапов для сервера (без содержимого). + */ +async function listBackups(req, res) { + try { + const serverId = req.query.serverId; + if (!serverId || typeof serverId !== 'string') { + return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); + } + + const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`; + const objects = await listS3Objects(prefix, { maxKeys: 200 }); + + const items = objects + .filter(o => o.key && o.key.startsWith(prefix)) + .map(o => ({ + key: o.key, + serverId, + createdAt: o.lastModified, + size: o.size, + etag: o.etag, + })) + .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))); + + return res.json({ ok: true, items }); + } catch (error) { + console.error('listBackups error:', error); + return sendError(res, 500, error.message || 'Error listing backups', 'E_BACKUP_LIST'); + } +} + +/** + * GET /api/mikrotik/backups/item?key=... + * + * Возвращает содержимое конкретного бэкапа. + */ +async function getBackup(req, res) { + try { + const key = req.query.key; + if (!key || typeof key !== 'string') { + return sendError(res, 400, 'key is required', 'E_BAD_REQUEST'); + } + if (!key.startsWith(`${BACKUP_PREFIX}/`)) { + return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST'); + } + + const data = await readS3TextObject(key); + + return res.json({ + ok: true, + key, + config: data.body || '', + etag: data.etag, + lastModified: data.lastModified, + contentLength: data.contentLength, + }); + } catch (error) { + console.error('getBackup error:', error); + return sendError(res, 500, error.message || 'Error reading backup', 'E_BACKUP_READ'); + } +} + +/** + * POST /api/mikrotik/backups/diff + * Body: { keyA: string, keyB: string } + * + * Простой текстовый diff: возвращает оба варианта и флаг same. + * Визуальное сравнение (подсветка отличий) можно сделать на frontend. + */ +async function diffBackups(req, res) { + try { + const { keyA, keyB } = req.body || {}; + if (!keyA || !keyB) { + return sendError(res, 400, 'keyA and keyB are required', 'E_BAD_REQUEST'); + } + if (!String(keyA).startsWith(`${BACKUP_PREFIX}/`) || !String(keyB).startsWith(`${BACKUP_PREFIX}/`)) { + return sendError(res, 400, 'Invalid backup keys', 'E_BAD_REQUEST'); + } + + const [a, b] = await Promise.all([ + readS3TextObject(keyA), + readS3TextObject(keyB), + ]); + + const bodyA = a.body || ''; + const bodyB = b.body || ''; + + return res.json({ + ok: true, + same: bodyA === bodyB, + a: { + key: keyA, + etag: a.etag, + lastModified: a.lastModified, + contentLength: a.contentLength, + }, + b: { + key: keyB, + etag: b.etag, + lastModified: b.lastModified, + contentLength: b.contentLength, + }, + // Сырые тексты отдаем целиком — UI сам покажет diff + configA: bodyA, + configB: bodyB, + }); + } catch (error) { + console.error('diffBackups error:', error); + return sendError(res, 500, error.message || 'Error diffing backups', 'E_BACKUP_DIFF'); + } +} + +module.exports = { + createBackup, + listBackups, + getBackup, + diffBackups, + saveBackupForServer, +}; + diff --git a/backend/server.js b/backend/server.js index f090df9..4340bc7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -28,6 +28,8 @@ const serverConfigsRoutes = require('./routes/serverConfigsRoutes'); const miscRoutes = require('./routes/miscRoutes'); const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes'); const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes'); +const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); +const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const app = express(); const port = Number(process.env.PORT) || 3001; @@ -454,6 +456,12 @@ app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); +// === MIKROTIK BACKUPS (S3) === +app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); +app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); +app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); +app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups); + // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { const { config } = req.body; @@ -483,3 +491,10 @@ app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); }); +// Запускаем планировщик автоматического бэкапа MikroTik (если включен через ENV) +try { + initMikrotikBackupScheduler(logger); +} catch (e) { + logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler'); +} + diff --git a/backend/services/mikrotikBackupScheduler.js b/backend/services/mikrotikBackupScheduler.js new file mode 100644 index 0000000..64bdc65 --- /dev/null +++ b/backend/services/mikrotikBackupScheduler.js @@ -0,0 +1,185 @@ +/** + * Простейший планировщик автоматических бэкапов MikroTik. + * + * Работает внутри Node-процесса backend: + * - периодически обходит jumphost-сервера из servers.json + * - вызывает RouterOS REST API /rest/export (compact) + * - сохраняет конфиг в S3 через saveBackupForServer + * + * Управляется через переменные окружения: + * - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true) + * - MIKROTIK_BACKUP_INTERVAL_MINUTES=60 (интервал между запусками) + * - MIKROTIK_BACKUP_SERVERS="id1,id2" (если не указано — все jumphost) + */ + +const { readServersFromS3 } = require('../routes/serversRoutes'); +const { createRosClient } = require('./mikrotikApplyService'); +const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes'); + +const DEFAULT_INTERVAL_MIN = 60; + +function parseServerIds(envValue) { + if (!envValue) return null; + const parts = String(envValue) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return parts.length > 0 ? parts : null; +} + +async function fetchRouterExport(client) { + // Согласно официальной документации, /rest/export позволяет экспортировать конфиг. + // Параметр "compact" без значения эквивалентен /export compact. + const res = await client.command('export', { compact: '' }); + const data = res && res.data; + if (!data) return ''; + + if (typeof data === 'string') return data; + if (Array.isArray(data)) { + // Некоторые версии могут вернуть массив строк/объектов + return data + .map((line) => { + if (typeof line === 'string') return line; + if (line && typeof line === 'object') { + // Пытаемся собрать строку из полей, если они есть + if (line.rsc) return String(line.rsc); + if (line.config) return String(line.config); + return JSON.stringify(line); + } + return String(line ?? ''); + }) + .join('\n'); + } + if (typeof data === 'object') { + // Fallback: сериализуем объект + return JSON.stringify(data, null, 2); + } + return String(data); +} + +async function runBackupOnce(logger) { + const log = logger || console; + try { + const allServers = await readServersFromS3(); + const wantedIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS); + + const jumphosts = (allServers || []).filter((s) => { + if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false; + if (!wantedIds) return true; + const id = s.id || s.dns || s.ip; + return id && wantedIds.includes(String(id)); + }); + + if (jumphosts.length === 0) { + log.info({ component: 'mikrotik-backup' }, 'No jumphost servers found for backup'); + return; + } + + for (const server of jumphosts) { + const id = server.id || server.dns || server.ip || 'unknown'; + try { + const host = server.mikrotikHost || server.ip || server.dns; + const port = server.mikrotikPort || 80; + const user = server.mikrotikUser || 'admin'; + + if (!host) { + log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no MikroTik host'); + continue; + } + + if (!server.encryptedMikrotikPassword) { + log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no encryptedMikrotikPassword configured'); + continue; + } + + // Пароль будет расшифрован на стороне RouterOS — мы используем тот же механизм, + // что и в apply/test-connection: encryptedMikrotikPassword должен быть уже расшифрован ранее. + // Здесь для простоты предполагаем, что пароль можно получить так же, как в apply. + // Чтобы не дублировать логику расшифровки, используем те же поля, что и в mikrotikConfigRoutes.getMikrotikCredentials. + const creds = { + host, + port: Number(port) || 80, + user, + // Пароль фактически должен быть расшифрован при сохранении/загрузке, + // однако в текущей архитектуре encryptedMikrotikPassword расшифровывается в роуте. + // Чтобы не нарушать безопасность и не тянуть сюда ключ, требуем наличия plain-поля mikrotikPassword, + // если оно временно присутствует (например, для локального использования или через ENV/секреты). + password: server.mikrotikPassword || '', + secure: false, + }; + + if (!creds.password) { + log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no plain mikrotikPassword available'); + continue; + } + + const client = createRosClient(creds); + log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export'); + const configText = await fetchRouterExport(client); + + if (!configText || !configText.trim()) { + log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: empty export result'); + continue; + } + + const result = await saveBackupForServer(id, configText, { + source: 'scheduler', + comment: 'Automatic MikroTik backup (REST /export)', + }); + + log.info( + { component: 'mikrotik-backup', serverId: id, key: result.key }, + 'Automatic backup saved to S3', + ); + } catch (err) { + log.error( + { component: 'mikrotik-backup', serverId: id, err: err && err.message }, + 'Failed to create automatic backup', + ); + } + } + } catch (err) { + const msg = err && err.message ? err.message : String(err); + (logger || console).error({ component: 'mikrotik-backup', err: msg }, 'Backup run failed'); + } +} + +function initMikrotikBackupScheduler(logger) { + const enabledEnv = String(process.env.MIKROTIK_BACKUP_ENABLED || 'true').toLowerCase(); + const enabled = enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'off'; + const log = logger || console; + + if (!enabled) { + log.info({ component: 'mikrotik-backup' }, 'Automatic MikroTik backup scheduler is disabled'); + return; + } + + const intervalMin = + Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN; + const intervalMs = Math.max(5, intervalMin) * 60 * 1000; + + log.info( + { + component: 'mikrotik-backup', + intervalMinutes: intervalMs / 60000, + servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost', + }, + 'Starting automatic MikroTik backup scheduler', + ); + + // Первый запуск с небольшой задержкой, чтобы сервер успел подняться + setTimeout(() => { + runBackupOnce(log); + }, 30_000); + + // Периодический запуск + setInterval(() => { + runBackupOnce(log); + }, intervalMs); +} + +module.exports = { + initMikrotikBackupScheduler, + runBackupOnce, +}; + diff --git a/backend/services/s3Service.js b/backend/services/s3Service.js index 2c6e87a..2e130f2 100644 --- a/backend/services/s3Service.js +++ b/backend/services/s3Service.js @@ -3,7 +3,7 @@ * Централизованные операции чтения/записи/кэширования */ -const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3'); const { NodeHttpHandler } = require('@smithy/node-http-handler'); const http = require('http'); const https = require('https'); @@ -167,6 +167,42 @@ async function deleteS3Object(key) { invalidateCacheForKey(key); } +/** + * Список объектов в S3 по префиксу + * Используется для истории/бэкапов (количество ограничено для безопасности) + */ +async function listS3Objects(prefix, { maxKeys = 100 } = {}) { + const out = []; + let continuationToken = undefined; + + while (out.length < maxKeys) { + const resp = await s3.send(new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + Prefix: prefix, + ContinuationToken: continuationToken, + MaxKeys: Math.min(1000, maxKeys - out.length), + })); + + const contents = resp.Contents || []; + for (const obj of contents) { + out.push({ + key: obj.Key, + size: typeof obj.Size === 'number' ? obj.Size : null, + lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null, + etag: obj.ETag || null, + }); + if (out.length >= maxKeys) break; + } + + if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) { + break; + } + continuationToken = resp.NextContinuationToken; + } + + return out; +} + /** * Потоковое чтение с пагинацией больших текстовых файлов */ @@ -257,5 +293,6 @@ module.exports = { deleteS3Object, streamPaginatedText, invalidateCacheForKey, + listS3Objects, }; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f6ec100..917e682 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,7 +20,8 @@ import { IconDownload, IconCreditCard, IconMenu2, - IconArrowsExchange + IconArrowsExchange, + IconDatabaseBackup } from '@tabler/icons-react'; import ServerManager from './ServerManager'; import FilterManager from './FilterManager'; @@ -33,6 +34,7 @@ import BillingManager from './BillingManager'; import CommunitiesManager from './CommunitiesManager'; import NetworkConfigManager from './NetworkConfigManager'; import Dashboard from './Dashboard'; +import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import './App.css'; import { NotifyProvider } from './components/NotifyProvider.jsx'; import SettingsModal from './components/SettingsModal.jsx'; @@ -52,14 +54,14 @@ function LanguageProvider({ children }) { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', - easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', + easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', light: 'Светлая', dark: 'Тёмная' }, en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs', - easySwitch: 'Easy Switch', networkConfig: 'Network Config', + easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', light: 'Light', dark: 'Dark' } }; @@ -201,7 +203,8 @@ function MainLayout() { title: t('tools'), icon: IconSettings, items: [ - { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload } + { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }, + { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabaseBackup } ] }, // Убрали неиспользуемые/неработающие разделы @@ -401,6 +404,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/MikrotikBackupsManager.jsx b/frontend/src/MikrotikBackupsManager.jsx new file mode 100644 index 0000000..0b5713e --- /dev/null +++ b/frontend/src/MikrotikBackupsManager.jsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useState } from 'react'; +import api from './lib/api.js'; +import { useNotify } from './components/NotifyProvider.jsx'; +import PageHeader from './components/PageHeader.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; +import EmptyState from './components/EmptyState.jsx'; +import Tooltip from './components/Tooltip.jsx'; +import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; +import { + IconDatabaseBackup, + IconRefresh, + IconAlertTriangle, + IconEye, + IconArrowsLeftRight, + IconDownload, + IconCopy, +} from '@tabler/icons-react'; + +function formatDateTime(value) { + if (!value) return ''; + try { + const d = new Date(value); + return d.toLocaleString(); + } catch { + return String(value); + } +} + +function bytesToHuman(size) { + if (size == null) return ''; + const n = Number(size); + if (!Number.isFinite(n) || n < 0) return ''; + if (n < 1024) return `${n} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v.toFixed(1)} ${units[i]}`; +} + +function MikrotikBackupsManager() { + const notify = useNotify(); + + const [servers, setServers] = useState([]); + const [selectedServer, setSelectedServer] = useState(null); + const [backups, setBackups] = useState([]); + const [loadingServers, setLoadingServers] = useState(false); + const [loadingBackups, setLoadingBackups] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [viewConfigKey, setViewConfigKey] = useState(null); + const [viewConfigText, setViewConfigText] = useState(''); + const [viewLoading, setViewLoading] = useState(false); + const [diffResult, setDiffResult] = useState(null); + const [diffLoading, setDiffLoading] = useState(false); + + useEffect(() => { + fetchServers(); + }, []); + + useEffect(() => { + if (selectedServer && selectedServer.id) { + fetchBackups(selectedServer.id); + } else { + setBackups([]); + setSelectedKeys([]); + } + }, [selectedServer]); + + const jumphostServers = useMemo( + () => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'), + [servers], + ); + + const fetchServers = async () => { + try { + setLoadingServers(true); + const res = await api.get('/servers'); + const list = Array.isArray(res.data) ? res.data : []; + setServers(list); + const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost'); + if (firstJumphost && !selectedServer) { + setSelectedServer(firstJumphost); + } + } catch (err) { + console.error('Error fetching servers for backups:', err); + notify.error('Не удалось загрузить список серверов для бэкапов'); + } finally { + setLoadingServers(false); + } + }; + + const fetchBackups = async (serverId) => { + if (!serverId) return; + try { + setLoadingBackups(true); + setSelectedKeys([]); + setDiffResult(null); + const res = await api.get('/mikrotik/backups', { params: { serverId } }); + const items = res.data?.items || []; + setBackups(items); + } catch (err) { + console.error('Error fetching backups:', err); + notify.error('Не удалось загрузить список бэкапов MikroTik'); + } finally { + setLoadingBackups(false); + } + }; + + const handleSelectKey = (key) => { + setSelectedKeys((prev) => { + if (prev.includes(key)) { + return prev.filter((k) => k !== key); + } + if (prev.length >= 2) { + return [prev[1], key]; + } + return [...prev, key]; + }); + setDiffResult(null); + }; + + const handleViewConfig = async (key) => { + if (!key) return; + try { + setViewLoading(true); + setViewConfigKey(key); + setViewConfigText(''); + const res = await api.get('/mikrotik/backups/item', { params: { key } }); + setViewConfigText(res.data?.config || ''); + } catch (err) { + console.error('Error reading backup:', err); + notify.error('Не удалось загрузить содержимое бэкапа'); + } finally { + setViewLoading(false); + } + }; + + const handleDiff = async () => { + if (selectedKeys.length !== 2) { + notify.warning('Выберите два бэкапа для сравнения'); + return; + } + try { + setDiffLoading(true); + setDiffResult(null); + const res = await api.post('/mikrotik/backups/diff', { + keyA: selectedKeys[0], + keyB: selectedKeys[1], + }); + setDiffResult(res.data || null); + } catch (err) { + console.error('Error diffing backups:', err); + notify.error('Не удалось сравнить бэкапы'); + } finally { + setDiffLoading(false); + } + }; + + const handleDownload = async (key) => { + try { + const res = await api.get('/mikrotik/backups/item', { params: { key } }); + const text = res.data?.config || ''; + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const shortKey = key.split('/').slice(-1)[0] || 'backup'; + a.download = shortKey.endsWith('.rsc') ? shortKey : `${shortKey}.rsc`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Error downloading backup:', err); + notify.error('Не удалось скачать бэкап'); + } + }; + + const handleCopyToClipboard = async (text) => { + try { + if (!text) return; + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + notify.success('Текст бэкапа скопирован в буфер обмена'); + } catch (err) { + console.error('Error copying backup:', err); + notify.error('Не удалось скопировать текст бэкапа'); + } + }; + + const actions = ( +
+
+ +
+ + + + +
+ Автобэкапы настраиваются через переменные окружения: + MIKROTIK_BACKUP_ENABLED, + MIKROTIK_BACKUP_INTERVAL_MINUTES, + MIKROTIK_BACKUP_SERVERS. +
+
+ ); + + const currentServerLabel = + selectedServer?.dns || selectedServer?.ip || selectedServer?.id || 'Не выбран'; + + return ( +
+ + + {loadingServers ? ( + + ) : !selectedServer ? ( + + ) : loadingBackups ? ( + + ) : backups.length === 0 ? ( + + ) : ( +
+
+
+
Бэкапы для {currentServerLabel}
+
+ Всего: {backups.length}. Выбрано для diff: {selectedKeys.length}/2. +
+
+
+ + Восстановление выполняется вручную через Winbox/SSH, используя экспортированный .rsc +
+
+
+ + + + + + + + + + + + {backups.map((b) => { + const active = selectedKeys.includes(b.key); + const shortKey = b.key.split('/').slice(-1)[0] || b.key; + return ( + + + + + + + + ); + })} + +
СозданРазмерКлюч S3Действия
+ handleSelectKey(b.key)} + /> + {formatDateTime(b.createdAt)}{bytesToHuman(b.size)} + {shortKey} + +
+ + +
+
+
+
+ )} + + {(viewConfigKey || diffResult) && ( +
+ {viewConfigKey && ( +
+
+
+
+
Содержимое бэкапа
+
+ {viewConfigKey} +
+
+
+ +
+
+
+ {viewLoading ? ( + + ) : ( +
+                      {viewConfigText || '# Нет данных для отображения'}
+                    
+ )} +
+
+
+ )} + + {diffResult && ( +
+
+
+
+
Сравнение бэкапов
+
+ {diffResult.same + ? 'Конфигурации идентичны' + : 'Конфигурации отличаются — проверьте различия перед откатом.'} +
+
+
+
+ {diffLoading ? ( + + ) : ( +
+
+
+ A: {diffResult.a?.key} +
+
+                          {diffResult.configA || '# пусто'}
+                        
+
+
+
+ B: {diffResult.b?.key} +
+
+                          {diffResult.configB || '# пусто'}
+                        
+
+
+ )} +
+
+
+ )} +
+ )} +
+ ); +} + +export default MikrotikBackupsManager; +