feat(MikrotikBackupsManager): implement server selection UI with search, sorting, and pagination features
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m43s

This commit is contained in:
2026-02-10 15:38:44 +07:00
parent 0063e79a73
commit 7bfa7be02d
+185 -54
View File
@@ -6,6 +6,7 @@ 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 DataTable from './components/DataTable.jsx';
import {
IconRefresh,
IconAlertTriangle,
@@ -58,6 +59,13 @@ function MikrotikBackupsManager() {
const [diffResult, setDiffResult] = useState(null);
const [diffLoading, setDiffLoading] = useState(false);
// UI-состояние для таблицы выбора серверов автобэкапов
const [backupSearch, setBackupSearch] = useState('');
const [backupSortField, setBackupSortField] = useState('ip');
const [backupSortOrder, setBackupSortOrder] = useState('asc');
const [backupPage, setBackupPage] = useState(1);
const backupPageSize = 10;
useEffect(() => {
fetchServers();
fetchUiSettings();
@@ -91,6 +99,74 @@ function MikrotikBackupsManager() {
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 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 fetchServers = async () => {
try {
setLoadingServers(true);
@@ -342,21 +418,61 @@ function MikrotikBackupsManager() {
{/* Блок выбора серверов для автобэкапа */}
<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 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>
<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 ? (
@@ -364,47 +480,62 @@ function MikrotikBackupsManager() {
Нет серверов типа <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>
<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>