feat(mikrotikBackup): add MikroTik backup functionality with S3 integration and implement backup scheduler
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s

This commit is contained in:
2026-02-09 15:13:56 +07:00
parent 3d9394e27d
commit 275f2f6929
6 changed files with 883 additions and 5 deletions
+204
View File
@@ -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,
};
+15
View File
@@ -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');
}
+185
View File
@@ -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,
};
+38 -1
View File
@@ -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,
};
+8 -4
View File
@@ -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() {
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
+433
View File
@@ -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 = (
<div className="d-flex flex-wrap gap-2 align-items-center">
<div style={{ minWidth: 260 }}>
<ServerAutocompleteInput
label="Jumphost для бэкапов"
placeholder="Выберите сервер Jumphost"
servers={jumphostServers}
value={selectedServer}
onChange={setSelectedServer}
size="sm"
/>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={() => selectedServer && fetchBackups(selectedServer.id || selectedServer.dns || selectedServer.ip)}
disabled={!selectedServer || loadingBackups}
>
<IconRefresh size={16} className="me-1" />
Обновить список
</button>
<Tooltip content="Выберите два бэкапа в таблице" position="top">
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center"
onClick={handleDiff}
disabled={selectedKeys.length !== 2 || diffLoading}
>
<IconArrowsLeftRight size={16} className="me-1" />
Сравнить выбранные
</button>
</Tooltip>
<div className="text-muted small ms-2">
Автобэкапы настраиваются через переменные окружения:
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code>,
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>,
<code className="ms-1">MIKROTIK_BACKUP_SERVERS</code>.
</div>
</div>
);
const currentServerLabel =
selectedServer?.dns || selectedServer?.ip || selectedServer?.id || 'Не выбран';
return (
<div className="page">
<PageHeader
title="MikroTik Backups"
pretitle="Бэкапы конфигурации через REST API + S3"
meta={`Текущий сервер: ${currentServerLabel}`}
actions={actions}
/>
{loadingServers ? (
<TableSkeleton rows={5} columns={4} />
) : !selectedServer ? (
<EmptyState
title="Нет доступных Jumphost-серверов"
description="Создайте хотя бы один сервер типа \"jumphost\" в разделе Серверы, чтобы включить бэкапы."
/>
) : loadingBackups ? (
<TableSkeleton rows={5} columns={4} />
) : backups.length === 0 ? (
<EmptyState
title="Для этого сервера бэкапов ещё нет"
description="Автоматические бэкапы создаются планировщиком на backend. Можно также отправить бэкап вручную через API."
/>
) : (
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<div>
<div className="card-title mb-0">Бэкапы для {currentServerLabel}</div>
<div className="text-muted small">
Всего: {backups.length}. Выбрано для diff: {selectedKeys.length}/2.
</div>
</div>
<div className="text-muted small d-flex align-items-center">
<IconAlertTriangle size={16} className="me-1 text-warning" />
Восстановление выполняется вручную через Winbox/SSH, используя экспортированный .rsc
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter table-striped">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Создан</th>
<th>Размер</th>
<th>Ключ S3</th>
<th style={{ width: 220 }}>Действия</th>
</tr>
</thead>
<tbody>
{backups.map((b) => {
const active = selectedKeys.includes(b.key);
const shortKey = b.key.split('/').slice(-1)[0] || b.key;
return (
<tr key={b.key} className={active ? 'table-active' : ''}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={active}
onChange={() => handleSelectKey(b.key)}
/>
</td>
<td>{formatDateTime(b.createdAt)}</td>
<td>{bytesToHuman(b.size)}</td>
<td className="text-truncate" style={{ maxWidth: 260 }} title={b.key}>
{shortKey}
</td>
<td>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleViewConfig(b.key)}
>
<IconEye size={16} className="me-1" />
Просмотр
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleDownload(b.key)}
>
<IconDownload size={16} className="me-1" />
Скачать
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{(viewConfigKey || diffResult) && (
<div className="row mt-4">
{viewConfigKey && (
<div className={diffResult ? 'col-md-6' : 'col-12'}>
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<div>
<div className="card-title mb-0">Содержимое бэкапа</div>
<div className="text-muted small text-truncate" title={viewConfigKey}>
{viewConfigKey}
</div>
</div>
<div className="btn-list">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => handleCopyToClipboard(viewConfigText)}
disabled={!viewConfigText}
>
<IconCopy size={16} className="me-1" />
Копировать
</button>
</div>
</div>
<div className="card-body">
{viewLoading ? (
<TableSkeleton rows={6} columns={1} />
) : (
<pre
className="mb-0"
style={{ maxHeight: 420, overflow: 'auto', fontSize: 12 }}
>
{viewConfigText || '# Нет данных для отображения'}
</pre>
)}
</div>
</div>
</div>
)}
{diffResult && (
<div className={viewConfigKey ? 'col-md-6 mt-3 mt-md-0' : 'col-12'}>
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<div>
<div className="card-title mb-0">Сравнение бэкапов</div>
<div className="text-muted small">
{diffResult.same
? 'Конфигурации идентичны'
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
</div>
</div>
</div>
<div className="card-body">
{diffLoading ? (
<TableSkeleton rows={6} columns={2} />
) : (
<div className="row">
<div className="col-md-6 mb-3 mb-md-0">
<div className="mb-2 small text-muted text-truncate" title={diffResult.a?.key}>
A: {diffResult.a?.key}
</div>
<pre
className="mb-0"
style={{ maxHeight: 360, overflow: 'auto', fontSize: 12 }}
>
{diffResult.configA || '# пусто'}
</pre>
</div>
<div className="col-md-6">
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
B: {diffResult.b?.key}
</div>
<pre
className="mb-0"
style={{ maxHeight: 360, overflow: 'auto', fontSize: 12 }}
>
{diffResult.configB || '# пусто'}
</pre>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
export default MikrotikBackupsManager;