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 EmptyState from './components/EmptyState.jsx';
import Tooltip from './components/Tooltip.jsx'; import Tooltip from './components/Tooltip.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import DataTable from './components/DataTable.jsx';
import { import {
IconRefresh, IconRefresh,
IconAlertTriangle, IconAlertTriangle,
@@ -58,6 +59,13 @@ function MikrotikBackupsManager() {
const [diffResult, setDiffResult] = useState(null); const [diffResult, setDiffResult] = useState(null);
const [diffLoading, setDiffLoading] = useState(false); 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(() => { useEffect(() => {
fetchServers(); fetchServers();
fetchUiSettings(); fetchUiSettings();
@@ -91,6 +99,74 @@ function MikrotikBackupsManager() {
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]); 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 () => { const fetchServers = async () => {
try { try {
setLoadingServers(true); setLoadingServers(true);
@@ -342,21 +418,61 @@ function MikrotikBackupsManager() {
{/* Блок выбора серверов для автобэкапа */} {/* Блок выбора серверов для автобэкапа */}
<div className="card mb-4"> <div className="card mb-4">
<div className="card-header d-flex justify-content-between align-items-center"> <div className="card-header">
<div> <div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div className="card-title mb-0">Сервера для автоматических бэкапов</div> <div>
<div className="text-muted small"> <div className="card-title mb-0">Сервера для автоматических бэкапов</div>
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend. <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>
<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 className="card-body"> <div className="card-body">
{jumphostServers.length === 0 ? ( {jumphostServers.length === 0 ? (
@@ -364,47 +480,62 @@ function MikrotikBackupsManager() {
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы». Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
</div> </div>
) : ( ) : (
<div className="table-responsive"> <DataTable
<table className="table table-sm table-hover"> columns={[
<thead> {
<tr> key: 'ip',
<th style={{ width: 40 }}></th> title: 'Сервер',
<th>Сервер</th> sortable: true,
<th>IP / DNS</th> render: (_value, item) => (
<th>Страна</th> <div>
<th>Провайдер</th> <div className="fw-medium">{item.dns || item.ip || item.id}</div>
</tr> <div className="small text-muted">
</thead> {item.ip}
<tbody> {item.dns && item.ip ? ' · ' : ''}
{jumphostServers.map((s) => { {item.dns}
const id = s.id || s.dns || s.ip; </div>
const checked = id && backupServerIds.has(String(id)); </div>
return ( ),
<tr key={id || `srv-${s.ip}-${s.dns}`}> },
<td> {
<input key: 'country',
type="checkbox" title: 'Страна',
className="form-check-input" sortable: true,
checked={!!checked} render: (value) => <span className="small">{value || '-'}</span>,
onChange={() => id && toggleBackupServer(String(id))} },
/> {
</td> key: 'provider',
<td>{s.dns || s.ip || id}</td> title: 'Провайдер',
<td> sortable: true,
<div className="small text-muted"> render: (value) => <span className="small">{value || '-'}</span>,
{s.ip} },
{s.dns && s.ip ? ' · ' : ''} ]}
{s.dns} items={paginatedBackupServers}
</div> itemKey="id"
</td> loading={loadingServers}
<td className="small">{s.country || '-'}</td> sortField={backupSortField}
<td className="small">{s.provider || '-'}</td> sortOrder={backupSortOrder}
</tr> onSort={handleBackupSort}
); selectedItems={backupServerIds}
})} onSelectItem={(id) => id && toggleBackupServer(String(id))}
</tbody> onSelectAll={handleBackupSelectAll}
</table> onDeselectAll={handleBackupDeselectAll}
</div> 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>
</div> </div>