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
+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;