Files
router-lists-ui/frontend/src/MikrotikBackupsManager.jsx
T
2026-02-10 01:05:07 +07:00

593 lines
22 KiB
React

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 {
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 [selectedServerId, setSelectedServerId] = useState('');
const [uiSettings, setUiSettings] = useState({});
const [uiSettingsEtag, setUiSettingsEtag] = useState('');
const [backupServerIds, setBackupServerIds] = useState(new Set());
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();
fetchUiSettings();
}, []);
useEffect(() => {
if (selectedServerId) {
fetchBackups(selectedServerId);
} else {
setBackups([]);
setSelectedKeys([]);
}
}, [selectedServerId]);
const jumphostServers = useMemo(
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
[servers],
);
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
const jumphostInputServers = useMemo(
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
[jumphostServers],
);
const currentServer = useMemo(
() => jumphostServers.find((s) => makeServerId(s) === selectedServerId) || null,
[jumphostServers, selectedServerId],
);
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]);
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 && !selectedServerId) {
setSelectedServerId(makeServerId(firstJumphost));
}
} catch (err) {
console.error('Error fetching servers for backups:', err);
notify.error('Не удалось загрузить список серверов для бэкапов');
} finally {
setLoadingServers(false);
}
};
const fetchUiSettings = async () => {
try {
const res = await api.get('/ui-settings');
const data = res?.data || {};
setUiSettings(data);
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
setBackupServerIds(new Set(list.map((v) => String(v))));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setUiSettingsEtag(e ? String(e) : '');
} catch (err) {
console.error('Error fetching UI settings for backups:', err);
// мягко игнорируем, просто будут дефолты
}
};
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 toggleBackupServer = (serverId) => {
setBackupServerIds((prev) => {
const next = new Set(prev);
if (next.has(serverId)) next.delete(serverId);
else next.add(serverId);
return next;
});
};
const saveBackupServers = async () => {
try {
const settingsPayload = {
...uiSettings,
mikrotikBackupServers: backupServerIdsList,
};
const payload = {
settings: settingsPayload,
etag: uiSettingsEtag,
};
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setUiSettings(settingsPayload);
setUiSettingsEtag(String(meta?.etag || ''));
notify.success('Список серверов для автобэкапа сохранён');
} catch (err) {
console.error('Error saving backup servers to ui-settings:', err);
notify.error(err?.response?.data?.message || 'Не удалось сохранить список серверов для автобэкапа');
}
};
const actions = (
<div className="d-flex flex-wrap gap-2 align-items-center">
<div style={{ minWidth: 260 }}>
<ServerAutocompleteInput
label="Jumphost для бэкапов"
placeholder="Выберите сервер Jumphost"
servers={jumphostInputServers}
value={selectedServerId}
onChange={setSelectedServerId}
size="sm"
/>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={() => selectedServerId && fetchBackups(selectedServerId)}
disabled={!selectedServerId || loadingBackups}
>
<IconRefresh size={16} className="me-1" />
Обновить список
</button>
<button
type="button"
className="btn btn-primary btn-sm d-inline-flex align-items-center"
onClick={async () => {
if (!selectedServerId) return;
try {
const serverId = selectedServerId;
notify.info('Запуск ручного бэкапа MikroTik...');
const res = await api.post('/mikrotik/backups/run', { serverId });
if (res?.data?.ok) {
notify.success('Ручной бэкап успешно создан');
await fetchBackups(serverId);
} else {
notify.error(res?.data?.message || 'Не удалось выполнить ручной бэкап');
}
} catch (err) {
console.error('Error running manual backup:', err);
const msg =
err?.response?.data?.message ||
err?.response?.data ||
'Ошибка при выполнении ручного бэкапа';
notify.error(msg);
}
}}
disabled={!selectedServerId || loadingBackups}
>
Создать бэкап сейчас
</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">
Автобэкапы включаются для выбранных ниже Jumphost-серверов. Интервал и включение/выключение
планировщика настраиваются через переменные окружения
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code> и
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>.
</div>
</div>
);
const currentServerLabel =
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
return (
<div className="page">
<PageHeader
title="MikroTik Backups"
pretitle="Бэкапы конфигурации через REST API + S3"
meta={`Текущий сервер: ${currentServerLabel}`}
actions={actions}
/>
{/* Блок выбора серверов для автобэкапа */}
<div className="card mb-4">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<div className="card-title mb-0">Сервера для автоматических бэкапов</div>
<div className="text-muted small">
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend.
</div>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
onClick={saveBackupServers}
disabled={jumphostServers.length === 0}
>
Сохранить список
</button>
</div>
<div className="card-body">
{jumphostServers.length === 0 ? (
<div className="text-muted small">
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
</div>
) : (
<div className="table-responsive">
<table className="table table-sm table-hover">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Сервер</th>
<th>IP / DNS</th>
<th>Страна</th>
<th>Провайдер</th>
</tr>
</thead>
<tbody>
{jumphostServers.map((s) => {
const id = s.id || s.dns || s.ip;
const checked = id && backupServerIds.has(String(id));
return (
<tr key={id || `srv-${s.ip}-${s.dns}`}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={!!checked}
onChange={() => id && toggleBackupServer(String(id))}
/>
</td>
<td>{s.dns || s.ip || id}</td>
<td>
<div className="small text-muted">
{s.ip}
{s.dns && s.ip ? ' · ' : ''}
{s.dns}
</div>
</td>
<td className="small">{s.country || '-'}</td>
<td className="small">{s.provider || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{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;