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
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user