Publish Docker image / build-and-push (push) Successful in 2m6s
- Changed the main entry point from main.jsx to main.tsx for TypeScript support. - Removed Tabler JS import and integrated Tailwind CSS for styling. - Updated package.json and package-lock.json to include new dependencies such as @fontsource-variable/geist and tailwindcss, while removing unused ones. - Enhanced Vite configuration with path aliasing for improved import management. - Deleted unused App.css and App.jsx files to streamline the project structure. Made-with: Cursor
1000 lines
38 KiB
React
1000 lines
38 KiB
React
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||
import * as Diff from 'diff';
|
||
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 { DataTable } from '@/components/shared/table';
|
||
import { LargeModal, FullscreenModal } from './components/Modal.jsx';
|
||
import { countryToFlag } from './utils/serverUtils.js';
|
||
import {
|
||
IconRefresh,
|
||
IconAlertTriangle,
|
||
IconEye,
|
||
IconArrowsLeftRight,
|
||
IconDownload,
|
||
IconCopy,
|
||
IconSearch,
|
||
} from '@/lib/icons';
|
||
|
||
/**
|
||
* Строит две колонки строк для side-by-side diff (как git diff).
|
||
* leftLines/rightLines — массивы { text, type: 'unchanged'|'removed'|'added'|'empty' }.
|
||
*/
|
||
function getDiffLines(oldStr, newStr) {
|
||
const left = [];
|
||
const right = [];
|
||
const changes = Diff.diffLines(oldStr || '', newStr || '');
|
||
for (const part of changes) {
|
||
const lines = (part.value || '').split('\n');
|
||
if (part.value && part.value.endsWith('\n') && lines.length > 0) lines.pop();
|
||
for (const line of lines) {
|
||
if (part.added) {
|
||
left.push({ text: '', type: 'empty' });
|
||
right.push({ text: line, type: 'added' });
|
||
} else if (part.removed) {
|
||
left.push({ text: line, type: 'removed' });
|
||
right.push({ text: '', type: 'empty' });
|
||
} else {
|
||
left.push({ text: line, type: 'unchanged' });
|
||
right.push({ text: line, type: 'unchanged' });
|
||
}
|
||
}
|
||
}
|
||
return { left, right };
|
||
}
|
||
|
||
const diffPanelStyle = {
|
||
maxHeight: 360,
|
||
overflow: 'auto',
|
||
fontSize: 12,
|
||
fontFamily: 'ui-monospace, monospace',
|
||
margin: 0,
|
||
padding: '0.5rem 0.75rem',
|
||
borderRadius: 4,
|
||
backgroundColor: 'var(--tblr-code-bg, #0f172a)', // как тёмный code-блок Tabler
|
||
border: '1px solid rgba(15, 23, 42, 0.9)',
|
||
};
|
||
const diffLineStyle = {
|
||
display: 'block',
|
||
minHeight: '1.25em',
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
padding: '0 4px',
|
||
margin: 0,
|
||
color: 'var(--tblr-code-color, #e5e7eb)',
|
||
};
|
||
const diffRemovedBg = {
|
||
background: 'rgba(248, 81, 73, 0.2)',
|
||
borderLeft: '3px solid rgba(248, 81, 73, 0.9)',
|
||
};
|
||
const diffAddedBg = {
|
||
background: 'rgba(63, 185, 80, 0.2)',
|
||
borderLeft: '3px solid rgba(63, 185, 80, 0.9)',
|
||
};
|
||
|
||
function DiffPanel({ lines, side, innerRef, onScroll }) {
|
||
return (
|
||
<pre style={diffPanelStyle} ref={innerRef} onScroll={onScroll}>
|
||
{lines.length === 0 ? (
|
||
<span style={diffLineStyle}># пусто</span>
|
||
) : (
|
||
lines.map((item, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
...diffLineStyle,
|
||
...(item.type === 'removed' && side === 'left' ? diffRemovedBg : null),
|
||
...(item.type === 'added' && side === 'right' ? diffAddedBg : null),
|
||
}}
|
||
>
|
||
{item.type === 'empty' ? '\u00A0' : item.text || ''}
|
||
</span>
|
||
))
|
||
)}
|
||
</pre>
|
||
);
|
||
}
|
||
|
||
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 [viewModalOpen, setViewModalOpen] = useState(false);
|
||
const [diffResult, setDiffResult] = useState(null);
|
||
const [diffLoading, setDiffLoading] = useState(false);
|
||
const [diffModalOpen, setDiffModalOpen] = useState(false);
|
||
const leftDiffRef = useRef(null);
|
||
const rightDiffRef = useRef(null);
|
||
const isSyncingScrollRef = useRef(false);
|
||
|
||
// UI-состояние выбора сервера для просмотра бэкапов (в стиле /filters)
|
||
const [serverSearch, setServerSearch] = useState('');
|
||
|
||
// UI-состояние для таблицы выбора серверов автобэкапов
|
||
const [backupSearch, setBackupSearch] = useState('');
|
||
const [backupSortField, setBackupSortField] = useState('ip');
|
||
const [backupSortOrder, setBackupSortOrder] = useState('asc');
|
||
const [backupPage, setBackupPage] = useState(1);
|
||
const backupPageSize = 10;
|
||
|
||
const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString();
|
||
|
||
const fetchServers = useCallback(async (abortSignal) => {
|
||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||
try {
|
||
setLoadingServers(true);
|
||
const res = await api.get('/servers', opts);
|
||
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) {
|
||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||
console.error('Error fetching servers for backups:', err);
|
||
notify.error('Не удалось загрузить список серверов для бэкапов');
|
||
} finally {
|
||
setLoadingServers(false);
|
||
}
|
||
}, [selectedServerId, notify]);
|
||
|
||
const fetchUiSettings = useCallback(async (abortSignal) => {
|
||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||
try {
|
||
const res = await api.get('/ui-settings', opts);
|
||
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) {
|
||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||
console.error('Error fetching UI settings for backups:', err);
|
||
}
|
||
}, []);
|
||
|
||
const fetchBackups = useCallback(async (serverId, abortSignal) => {
|
||
if (!serverId) return;
|
||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||
try {
|
||
setLoadingBackups(true);
|
||
setSelectedKeys([]);
|
||
setDiffResult(null);
|
||
const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts });
|
||
const items = res.data?.items || [];
|
||
setBackups(items);
|
||
} catch (err) {
|
||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||
console.error('Error fetching backups:', err);
|
||
notify.error('Не удалось загрузить список бэкапов MikroTik');
|
||
} finally {
|
||
setLoadingBackups(false);
|
||
}
|
||
}, [notify]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const signal = controller.signal;
|
||
fetchServers(signal);
|
||
fetchUiSettings(signal);
|
||
return () => controller.abort();
|
||
}, [fetchServers, fetchUiSettings]);
|
||
|
||
useEffect(() => {
|
||
if (selectedServerId) {
|
||
const controller = new AbortController();
|
||
fetchBackups(selectedServerId, controller.signal);
|
||
return () => controller.abort();
|
||
} else {
|
||
setBackups([]);
|
||
setSelectedKeys([]);
|
||
}
|
||
}, [selectedServerId, fetchBackups]);
|
||
|
||
const jumphostServers = useMemo(
|
||
() => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'),
|
||
[servers],
|
||
);
|
||
|
||
const jumphostInputServers = useMemo(
|
||
() => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })),
|
||
[jumphostServers],
|
||
);
|
||
|
||
// Список серверов для выбора "текущего сервера" бэкапов с поиском (как в /filters)
|
||
const visibleJumphostServers = useMemo(() => {
|
||
if (!serverSearch.trim()) return jumphostInputServers;
|
||
const q = serverSearch.trim().toLowerCase();
|
||
return jumphostInputServers.filter((s) => {
|
||
const fields = [
|
||
s.id,
|
||
s.ip,
|
||
s.dns,
|
||
s.extIp,
|
||
s.internalIp,
|
||
s.country,
|
||
s.provider,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return fields.includes(q);
|
||
});
|
||
}, [jumphostInputServers, serverSearch]);
|
||
|
||
const currentServer = useMemo(
|
||
() => jumphostServers.find((s) => makeServerId(s) === selectedServerId) || null,
|
||
[jumphostServers, selectedServerId],
|
||
);
|
||
|
||
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]);
|
||
|
||
// Отфильтрованный и отсортированный список Jumphost-серверов для блока автобэкапов
|
||
const filteredBackupServers = useMemo(() => {
|
||
let list = [...jumphostInputServers];
|
||
|
||
if (backupSearch.trim()) {
|
||
const q = backupSearch.trim().toLowerCase();
|
||
list = list.filter((s) => {
|
||
const fields = [
|
||
s.id,
|
||
s.ip,
|
||
s.dns,
|
||
s.extIp,
|
||
s.internalIp,
|
||
s.country,
|
||
s.provider,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return fields.includes(q);
|
||
});
|
||
}
|
||
|
||
list.sort((a, b) => {
|
||
const field = backupSortField || 'ip';
|
||
let av = a[field] ?? '';
|
||
let bv = b[field] ?? '';
|
||
if (typeof av === 'string') av = av.toLowerCase();
|
||
if (typeof bv === 'string') bv = bv.toLowerCase();
|
||
if (av < bv) return backupSortOrder === 'asc' ? -1 : 1;
|
||
if (av > bv) return backupSortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
return list;
|
||
}, [jumphostInputServers, backupSearch, backupSortField, backupSortOrder]);
|
||
|
||
const backupTotalPages = useMemo(
|
||
() => Math.max(1, Math.ceil(filteredBackupServers.length / backupPageSize)),
|
||
[filteredBackupServers.length],
|
||
);
|
||
|
||
const paginatedBackupServers = useMemo(() => {
|
||
const page = Math.min(Math.max(1, backupPage), backupTotalPages);
|
||
const start = (page - 1) * backupPageSize;
|
||
return filteredBackupServers.slice(start, start + backupPageSize);
|
||
}, [filteredBackupServers, backupPage, backupTotalPages]);
|
||
|
||
const diffComputed = useMemo(() => {
|
||
if (diffResult?.configA == null || diffResult?.configB == null) {
|
||
return {
|
||
left: [],
|
||
right: [],
|
||
stats: { removed: 0, added: 0, total: 0 },
|
||
};
|
||
}
|
||
const { left, right } = getDiffLines(diffResult.configA, diffResult.configB);
|
||
const removed = left.filter((l) => l.type === 'removed').length;
|
||
const added = right.filter((l) => l.type === 'added').length;
|
||
return {
|
||
left,
|
||
right,
|
||
stats: { removed, added, total: removed + added },
|
||
};
|
||
}, [diffResult?.configA, diffResult?.configB]);
|
||
|
||
const syncScroll = (source) => {
|
||
const leftEl = leftDiffRef.current;
|
||
const rightEl = rightDiffRef.current;
|
||
if (!leftEl || !rightEl) return;
|
||
const current = source === 'left' ? leftEl : rightEl;
|
||
const other = source === 'left' ? rightEl : leftEl;
|
||
|
||
if (isSyncingScrollRef.current) return;
|
||
isSyncingScrollRef.current = true;
|
||
|
||
const maxCurrent = current.scrollHeight - current.clientHeight;
|
||
const ratio = maxCurrent > 0 ? current.scrollTop / maxCurrent : 0;
|
||
const maxOther = other.scrollHeight - other.clientHeight;
|
||
other.scrollTop = ratio * (maxOther > 0 ? maxOther : 0);
|
||
|
||
isSyncingScrollRef.current = false;
|
||
};
|
||
|
||
const handleBackupSort = (field) => {
|
||
setBackupSortField((prevField) => {
|
||
if (prevField === field) {
|
||
setBackupSortOrder((prevOrder) => (prevOrder === 'asc' ? 'desc' : 'asc'));
|
||
return prevField;
|
||
}
|
||
setBackupSortOrder('asc');
|
||
return field;
|
||
});
|
||
};
|
||
|
||
const handleBackupSelectAll = () => {
|
||
const allIds = filteredBackupServers.map((s) => String(s.id));
|
||
setBackupServerIds(new Set(allIds));
|
||
};
|
||
|
||
const handleBackupDeselectAll = () => {
|
||
setBackupServerIds(new Set());
|
||
};
|
||
|
||
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('');
|
||
setViewModalOpen(true);
|
||
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 {
|
||
setDiffModalOpen(true);
|
||
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">
|
||
<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"
|
||
icon={<IconDownload size={24} />}
|
||
meta={`Текущий сервер: ${currentServerLabel}`}
|
||
actions={actions}
|
||
/>
|
||
|
||
{/* Выбор сервера для просмотра бэкапов (как в /filters) */}
|
||
<div className="card mb-3">
|
||
<div className="card-header">
|
||
<div className="row align-items-center g-2">
|
||
<div className="col">
|
||
<h3 className="card-title mb-1">Сервер для просмотра бэкапов</h3>
|
||
<div className="text-muted small">
|
||
Доступно Jumphost-серверов: {jumphostServers.length}.{' '}
|
||
{selectedServerId
|
||
? 'Кликните по другой карточке, чтобы сменить сервер.'
|
||
: 'Выберите сервер, чтобы увидеть его бэкапы.'}
|
||
</div>
|
||
</div>
|
||
<div className="col-auto">
|
||
<div className="input-icon" style={{ minWidth: 240 }}>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm"
|
||
placeholder="Поиск серверов по IP, DNS, провайдеру..."
|
||
value={serverSearch}
|
||
onChange={(e) => setServerSearch(e.target.value)}
|
||
/>
|
||
<span className="input-icon-addon">
|
||
<IconSearch size={16} />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{jumphostServers.length === 0 ? (
|
||
<div className="text-muted small">
|
||
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы», чтобы смотреть
|
||
бэкапы.
|
||
</div>
|
||
) : visibleJumphostServers.length === 0 ? (
|
||
<div className="text-muted small">
|
||
По вашему запросу серверы не найдены. Попробуйте изменить фильтр поиска.
|
||
</div>
|
||
) : (
|
||
<div className="row row-cards g-2">
|
||
{visibleJumphostServers.map((s) => {
|
||
const id = s.id;
|
||
const isActive = id === selectedServerId;
|
||
const flag = s.country ? countryToFlag(s.country) : '';
|
||
const line = [s.dns, s.ip].filter(Boolean).join(' · ');
|
||
return (
|
||
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={id}>
|
||
<div
|
||
className={`card card-sm h-100 cursor-pointer ${
|
||
isActive ? 'border-primary card-hover' : 'card-hover'
|
||
}`}
|
||
onClick={() => setSelectedServerId(id)}
|
||
>
|
||
<div className="card-body d-flex align-items-start gap-2">
|
||
<span className="avatar bg-primary-lt" title={s.country || ''}>
|
||
{flag || s.ip?.slice(0, 2) || '?'}
|
||
</span>
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<div className="fw-bold text-truncate" title={s.dns || s.ip || id}>
|
||
{s.dns || s.ip || id}
|
||
</div>
|
||
</div>
|
||
{line && (
|
||
<div className="text-muted small text-truncate" title={line}>
|
||
{line}
|
||
</div>
|
||
)}
|
||
<div className="text-muted small mt-1">
|
||
{[s.country, s.provider].filter(Boolean).join(' · ') || '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<div className="text-muted small mt-2">
|
||
Текущий сервер:
|
||
<span className="fw-semibold">{currentServerLabel}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Таблица бэкапов для выбранного сервера */}
|
||
{loadingServers ? (
|
||
<TableSkeleton rows={5} columns={4} />
|
||
) : !selectedServerId ? (
|
||
<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>
|
||
)}
|
||
|
||
{/* Блок выбора серверов для автобэкапа – ниже как "настройки" */}
|
||
<div className="card mb-4 mt-4">
|
||
<div className="card-header">
|
||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||
<div>
|
||
<div className="card-title mb-0">Настройки автоматических бэкапов</div>
|
||
<div className="text-muted small">
|
||
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend.
|
||
</div>
|
||
<div className="text-muted small mt-1">
|
||
Выбрано для автобэкапов:{' '}
|
||
<span className="fw-semibold">
|
||
{backupServerIds.size} из {jumphostServers.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-icon">
|
||
<span className="input-icon-addon">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="icon"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
strokeWidth="2"
|
||
stroke="currentColor"
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||
<path d="M10 4a6 6 0 1 0 4 10" />
|
||
<path d="M21 21l-6 -6" />
|
||
</svg>
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm"
|
||
placeholder="Поиск по IP, DNS, провайдеру..."
|
||
value={backupSearch}
|
||
onChange={(e) => {
|
||
setBackupSearch(e.target.value);
|
||
setBackupPage(1);
|
||
}}
|
||
/>
|
||
</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>
|
||
</div>
|
||
<div className="card-body">
|
||
{jumphostServers.length === 0 ? (
|
||
<div className="text-muted small">
|
||
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
|
||
</div>
|
||
) : (
|
||
<DataTable
|
||
columns={[
|
||
{
|
||
key: 'ip',
|
||
title: 'Сервер',
|
||
sortable: true,
|
||
render: (_value, item) => (
|
||
<div>
|
||
<div className="fw-medium">{item.dns || item.ip || item.id}</div>
|
||
<div className="small text-muted">
|
||
{item.ip}
|
||
{item.dns && item.ip ? ' · ' : ''}
|
||
{item.dns}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'country',
|
||
title: 'Страна',
|
||
sortable: true,
|
||
render: (value) => <span className="small">{value || '-'}</span>,
|
||
},
|
||
{
|
||
key: 'provider',
|
||
title: 'Провайдер',
|
||
sortable: true,
|
||
render: (value) => <span className="small">{value || '-'}</span>,
|
||
},
|
||
]}
|
||
items={paginatedBackupServers}
|
||
itemKey="id"
|
||
loading={loadingServers}
|
||
sortField={backupSortField}
|
||
sortOrder={backupSortOrder}
|
||
onSort={handleBackupSort}
|
||
selectedItems={backupServerIds}
|
||
onSelectItem={(id) => id && toggleBackupServer(String(id))}
|
||
onSelectAll={handleBackupSelectAll}
|
||
onDeselectAll={handleBackupDeselectAll}
|
||
pagination={{
|
||
currentPage: backupPage,
|
||
totalPages: backupTotalPages,
|
||
totalItems: filteredBackupServers.length,
|
||
pageSize: backupPageSize,
|
||
onPageChange: setBackupPage,
|
||
}}
|
||
emptyState={{
|
||
title: 'Серверы не найдены',
|
||
description:
|
||
backupSearch.trim().length > 0
|
||
? 'По вашему запросу нет Jumphost-серверов. Измените фильтры поиска.'
|
||
: 'Нет доступных Jumphost-серверов для автобэкапа.',
|
||
}}
|
||
skeletonRows={5}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<LargeModal
|
||
show={viewModalOpen}
|
||
onClose={() => {
|
||
setViewModalOpen(false);
|
||
}}
|
||
title="Содержимое бэкапа MikroTik"
|
||
footer={(
|
||
<div className="d-flex justify-content-between w-100">
|
||
<div className="text-muted small text-truncate" title={viewConfigKey || ''}>
|
||
{viewConfigKey || 'Бэкап не выбран'}
|
||
</div>
|
||
<div className="btn-list mb-0">
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-secondary btn-sm"
|
||
onClick={() => handleCopyToClipboard(viewConfigText)}
|
||
disabled={!viewConfigText}
|
||
>
|
||
<IconCopy size={16} className="me-1" />
|
||
Копировать
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => setViewModalOpen(false)}
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
>
|
||
{viewLoading ? (
|
||
<TableSkeleton rows={6} columns={1} />
|
||
) : (
|
||
<pre
|
||
className="bg-dark text-white p-3 rounded"
|
||
style={{ maxHeight: 500, overflow: 'auto', fontSize: 12 }}
|
||
>
|
||
<code>{viewConfigText || '# Нет данных для отображения'}</code>
|
||
</pre>
|
||
)}
|
||
</LargeModal>
|
||
|
||
<FullscreenModal
|
||
show={diffModalOpen}
|
||
onClose={() => {
|
||
setDiffModalOpen(false);
|
||
}}
|
||
title="Сравнение бэкапов MikroTik"
|
||
footer={(
|
||
<div className="d-flex justify-content-between w-100">
|
||
<div className="text-muted small">
|
||
{diffResult?.same
|
||
? 'Конфигурации идентичны'
|
||
: 'Конфигурации отличаются — проверьте различия перед откатом.'}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setDiffModalOpen(false)}
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
)}
|
||
>
|
||
{diffLoading ? (
|
||
<TableSkeleton rows={8} columns={2} />
|
||
) : !diffResult ? (
|
||
<div className="text-muted small">
|
||
Не удалось загрузить результат сравнения. Попробуйте ещё раз.
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="alert alert-info mb-3 small d-flex justify-content-between align-items-center">
|
||
<span>
|
||
{!diffResult.same ? (
|
||
<>
|
||
<IconAlertTriangle size={16} className="me-1 text-warning" />
|
||
Конфигурации отличаются — слева A, справа B. Проверьте различия перед откатом.
|
||
</>
|
||
) : (
|
||
'Конфигурации идентичны — изменений нет.'
|
||
)}
|
||
</span>
|
||
<span>
|
||
Всего изменений:{' '}
|
||
<span className="fw-semibold">{diffComputed.stats.total}</span>
|
||
<span className="text-success ms-2">+{diffComputed.stats.added}</span>
|
||
<span className="text-danger ms-2">−{diffComputed.stats.removed}</span>
|
||
</span>
|
||
</div>
|
||
<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>
|
||
<DiffPanel
|
||
lines={diffComputed.left}
|
||
side="left"
|
||
innerRef={leftDiffRef}
|
||
onScroll={() => syncScroll('left')}
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<div className="mb-2 small text-muted text-truncate" title={diffResult.b?.key}>
|
||
B: {diffResult.b?.key}
|
||
</div>
|
||
<DiffPanel
|
||
lines={diffComputed.right}
|
||
side="right"
|
||
innerRef={rightDiffRef}
|
||
onScroll={() => syncScroll('right')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{!diffResult.same && (
|
||
<div className="mt-3 text-muted small">
|
||
<span style={{ background: 'rgba(248, 81, 73, 0.35)', padding: '0 4px', borderRadius: 2 }}> удалено (A)</span>
|
||
{' · '}
|
||
<span style={{ background: 'rgba(63, 185, 80, 0.35)', padding: '0 4px', borderRadius: 2 }}> добавлено (B)</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</FullscreenModal>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default MikrotikBackupsManager;
|
||
|