Files
router-lists-ui/frontend/src/MikrotikBackupsManager.jsx
T
denozord b0c8a0a4bb
Publish Docker image / build-and-push (push) Successful in 2m6s
Refactor frontend structure and update dependencies
- 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
2026-04-26 14:56:48 +07:00

1000 lines
38 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
Текущий сервер:&nbsp;
<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;