Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m23s
593 lines
22 KiB
React
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;
|
|
|