refactor(IPRangesManager): improve code readability and structure by standardizing JSX formatting and enhancing layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m4s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m4s
This commit is contained in:
+277
-441
@@ -771,25 +771,27 @@ function IPRangesManager() {
|
||||
{error && (
|
||||
<ErrorAlert message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
<div>{success}</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={() => setSuccess('')}
|
||||
></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title="IP-диапазоны"
|
||||
meta={<LastSaved timestamp={lastModified} variant="compact" />}
|
||||
actions={(
|
||||
actions={
|
||||
<PageHeaderActions
|
||||
loading={loading}
|
||||
onPreview={handlePreviewDiff}
|
||||
@@ -801,7 +803,13 @@ function IPRangesManager() {
|
||||
disableExport={items.length === 0}
|
||||
onClear={clearInvalid}
|
||||
disableClear={items.length === 0}
|
||||
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
||||
onClearCommunities={() => {
|
||||
if (!filterCommunity) {
|
||||
setError('Выберите community в фильтре для очистки');
|
||||
return;
|
||||
}
|
||||
setClearCommunitiesOpen(true);
|
||||
}}
|
||||
disableClearCommunities={items.length === 0 || !filterCommunity}
|
||||
onSave={handleSaveChanges}
|
||||
disableSave={loading}
|
||||
@@ -810,18 +818,23 @@ function IPRangesManager() {
|
||||
onOnlineUpdate={() => setWsOpen(true)}
|
||||
onBackgroundUpdate={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
||||
const res = await fetch('/api/update-bgp/background', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||
}
|
||||
notifyMutationSuccess('Фоновое обновление запущено', data);
|
||||
} catch (e) {
|
||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||
window.notify?.error(
|
||||
'Не удалось запустить фоновое обновление',
|
||||
String(e),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<AddItemModal
|
||||
@@ -833,233 +846,255 @@ function IPRangesManager() {
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
{/* BulkActionsBar */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedItems.size}
|
||||
totalCount={paginatedItems.length}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
onDelete={selectedItems.size > 0 ? () => {
|
||||
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
|
||||
handleBulkDelete();
|
||||
}
|
||||
} : null}
|
||||
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||||
/>
|
||||
|
||||
<div className="card card-flat">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100 g-2">
|
||||
<div className="col">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<IconNetwork className="icon text-muted" size={18} />
|
||||
<span className="text-muted text-uppercase small">Список</span>
|
||||
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
|
||||
</div>
|
||||
<div className="d-none d-md-block text-muted small">
|
||||
Всего записей: {filtered.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-flat">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100 g-2">
|
||||
<div className="col">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<IconNetwork className="icon text-muted" size={18} />
|
||||
<span className="text-muted text-uppercase small">Список</span>
|
||||
<span className="badge bg-blue-lt text-blue">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-12 mt-2"></div>
|
||||
<div className="col d-flex gap-2 flex-wrap align-items-center">
|
||||
<SavedFilters
|
||||
pageKey="ip-ranges"
|
||||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||
onApplyFilter={(filters) => {
|
||||
setSearchTerm(filters.searchTerm || '');
|
||||
setFilterCommunity(filters.filterCommunity || '');
|
||||
setSortField(filters.sortField || 'ipRange');
|
||||
setSortOrder(filters.sortOrder || 'asc');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex gap-2 w-auto flex-grow-1" style={{minWidth: '300px'}}>
|
||||
{/* Фильтр по community */}
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
value={filterCommunity}
|
||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||
title={(() => {
|
||||
const meta = communities.find(c => c.value === filterCommunity);
|
||||
if (!meta) return 'Все community';
|
||||
const name = meta.name ? meta.name + ' — ' : '';
|
||||
const desc = meta.description || '';
|
||||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||||
return name + desc + tags;
|
||||
})()}
|
||||
>
|
||||
<option value="">Все community</option>
|
||||
{allCommunities.map(community => {
|
||||
const meta = communities.find(c => c.value === community);
|
||||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||||
return (
|
||||
<option key={community} value={community} title={title}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* Поиск */}
|
||||
<div className="input-icon flex-grow-1">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск IP-диапазона..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск IP-диапазона"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<Tooltip content="Анализ и суммаризация IP-диапазонов">
|
||||
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
||||
<IconDatabase className="icon me-2" />
|
||||
Анализ
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
title="Добавить новый IP-диапазон"
|
||||
>
|
||||
<IconPlus className="me-1" size={18} />
|
||||
Добавить
|
||||
</button>
|
||||
<div className="d-none d-md-block text-muted small">
|
||||
Всего записей: {filtered.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{/* Таблица на лёгкой карточной подложке */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет IP-диапазонов"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
|
||||
<div className="col-12 mt-2"></div>
|
||||
<div className="col d-flex gap-2 flex-wrap align-items-center">
|
||||
<div
|
||||
className="d-flex gap-2 w-auto flex-grow-1"
|
||||
style={{ minWidth: '300px' }}
|
||||
>
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
value={filterCommunity}
|
||||
onChange={(e) => {
|
||||
setFilterCommunity(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">Все community</option>
|
||||
{allCommunities.map((community) => (
|
||||
<option key={community} value={community}>
|
||||
{community}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="input-icon flex-grow-1">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск IP-диапазона..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
aria-label="Поиск IP-диапазона"
|
||||
/>
|
||||
</TableEmpty>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '40px'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-1 text-muted" />
|
||||
IP-диапазон
|
||||
{sortField === 'ipRange' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedItems.map((item, idx) => (
|
||||
<tr key={item.ipRange} className={selectedItems.has(item.ipRange) ? 'table-selected' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.ipRange)}
|
||||
onChange={() => toggleSelectItem(item.ipRange)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td className="font-monospace" title="CIDR">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-2 text-muted" />
|
||||
<span>{item.ipRange}</span>
|
||||
<Tooltip content="Копировать IP-диапазон" position="top">
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||
onClick={() => copyToClipboard(item.ipRange)}
|
||||
aria-label="Копировать"
|
||||
title="Копировать IP-диапазон"
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>{renderCommunityBadge(item.community)}</td>
|
||||
<td className="text-end">
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с диапазоном ${item.ipRange}`}>
|
||||
<Tooltip content="Редактировать" position="top">
|
||||
<button className="btn btn-icon btn-outline-primary" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать">
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Удалить" position="top">
|
||||
<button className="btn btn-icon btn-outline-danger" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить">
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer border-top-0">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems || filtered.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
title="Добавить новый IP-диапазон"
|
||||
>
|
||||
<IconPlus className="me-1" size={18} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body p-0">
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет IP-диапазонов"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={handleImport}>
|
||||
Импорт
|
||||
</button>
|
||||
}
|
||||
secondaryAction={
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={downloadSampleCsv}
|
||||
>
|
||||
Шаблон CSV
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</TableEmpty>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={
|
||||
paginatedItems.length > 0 &&
|
||||
paginatedItems.every((i) => selectedItems.has(i.ipRange))
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer user-select-none"
|
||||
onClick={() => handleSort('ipRange')}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-1 text-muted" />
|
||||
IP-диапазон
|
||||
{sortField === 'ipRange' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? (
|
||||
<IconArrowUp size={14} />
|
||||
) : (
|
||||
<IconArrowDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer user-select-none"
|
||||
onClick={() => handleSort('community')}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? (
|
||||
<IconArrowUp size={14} />
|
||||
) : (
|
||||
<IconArrowDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr
|
||||
key={item.ipRange}
|
||||
className={
|
||||
selectedItems.has(item.ipRange) ? 'table-selected' : ''
|
||||
}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.ipRange)}
|
||||
onChange={() => toggleSelectItem(item.ipRange)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td className="font-monospace" title="CIDR">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork
|
||||
size={16}
|
||||
className="me-2 text-muted"
|
||||
/>
|
||||
<span>{item.ipRange}</span>
|
||||
<Tooltip
|
||||
content="Копировать IP-диапазон"
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||
onClick={() => copyToClipboard(item.ipRange)}
|
||||
aria-label="Копировать"
|
||||
title="Копировать IP-диапазон"
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>{renderCommunityBadge(item.community)}</td>
|
||||
<td className="text-end">
|
||||
<div
|
||||
className="btn-group btn-group-sm"
|
||||
role="group"
|
||||
aria-label={`Действия с диапазоном ${item.ipRange}`}
|
||||
>
|
||||
<Tooltip content="Редактировать" position="top">
|
||||
<button
|
||||
className="btn btn-icon btn-outline-primary"
|
||||
aria-label="Редактировать"
|
||||
onClick={() => handleEdit(item)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Удалить" position="top">
|
||||
<button
|
||||
className="btn btn-icon btn-outline-danger"
|
||||
aria-label="Удалить"
|
||||
onClick={() => confirmDelete(item)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card-footer border-top-0">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems || filtered.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить IP-диапазон?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.ipRange}"? Это действие необратимо.`}
|
||||
message={
|
||||
!itemToDelete
|
||||
? ''
|
||||
: `Вы уверены, что хотите удалить "${itemToDelete.ipRange}"? Это действие необратимо.`
|
||||
}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
@@ -1067,12 +1102,14 @@ function IPRangesManager() {
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
diff={diff}
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
|
||||
<ImportModal
|
||||
show={importOpen}
|
||||
title={'Импорт IP-диапазонов'}
|
||||
@@ -1081,10 +1118,10 @@ function IPRangesManager() {
|
||||
validateItem={validateItem}
|
||||
onConfirm={applyImportedItems}
|
||||
onClose={() => setImportOpen(false)}
|
||||
sampleHeader={['ipRange','community']}
|
||||
sampleHeader={['ipRange', 'community']}
|
||||
placeholder={'192.168.1.0/24 65000:100'}
|
||||
/>
|
||||
{/* Модальное окно редактирования community */}
|
||||
|
||||
<FormModal
|
||||
show={editModalOpen && !!itemBeingEdited}
|
||||
onClose={handleCancelEdit}
|
||||
@@ -1099,7 +1136,8 @@ function IPRangesManager() {
|
||||
{itemBeingEdited && (
|
||||
<>
|
||||
<p className="text-muted mb-2">
|
||||
IP-диапазон: <span className="font-monospace">{itemBeingEdited.ipRange}</span>
|
||||
IP-диапазон:{' '}
|
||||
<span className="font-monospace">{itemBeingEdited.ipRange}</span>
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
@@ -1108,7 +1146,9 @@ function IPRangesManager() {
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
className={`form-control${
|
||||
isValidCommunity(editingValue) ? '' : ' is-invalid'
|
||||
}`}
|
||||
useShortValue={true}
|
||||
/>
|
||||
{!isValidCommunity(editingValue) && (
|
||||
@@ -1120,240 +1160,36 @@ function IPRangesManager() {
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
<HistoryModal
|
||||
resource="ip-ranges"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearCommunitiesOpen}
|
||||
title={'Удалить записи community'}
|
||||
message={`Будут удалены все IP-диапазоны с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={() => { setItems(prev => prev.filter(i => i != null && i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
|
||||
onConfirm={() => {
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => i != null && i.community !== filterCommunity),
|
||||
);
|
||||
setClearCommunitiesOpen(false);
|
||||
}}
|
||||
onCancel={() => setClearCommunitiesOpen(false)}
|
||||
/>
|
||||
{/* Analyze Settings Modal */}
|
||||
{analyzeOpen && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setAnalyzeOpen(false)}></button>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Анализ IP-диапазонов</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Community</label>
|
||||
<select className="form-select" value={analyzeFilters.community} onChange={e => setAnalyzeFilters(f => ({ ...f, community: e.target.value }))}>
|
||||
<option value="">Выберите community</option>
|
||||
{allCommunities.map(val => (
|
||||
<option key={val} value={val}>{val}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text">Анализ выполняется в рамках одного выбранного community.</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Диапазон масок</label>
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="form-help">от</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.minMask}
|
||||
onChange={e => {
|
||||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||||
setAnalyzeFilters(f => ({ ...f, minMask: Math.min(v, f.maxMask) }));
|
||||
}} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<span className="form-help">до</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.maxMask}
|
||||
onChange={e => {
|
||||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||||
setAnalyzeFilters(f => ({ ...f, maxMask: Math.max(v, f.minMask) }));
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-text">Учитываются только CIDR внутри выбранного диапазона масок.</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Тип адресов</label>
|
||||
<div>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="any" checked={analyzeFilters.type === 'any'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'any' }))} />
|
||||
<span className="form-check-label">Любой</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="public" checked={analyzeFilters.type === 'public'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'public' }))} />
|
||||
<span className="form-check-label">Публичные</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="private" checked={analyzeFilters.type === 'private'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'private' }))} />
|
||||
<span className="form-check-label">Приватные</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="cgnat" checked={analyzeFilters.type === 'cgnat'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'cgnat' }))} />
|
||||
<span className="form-check-label">CGNAT</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-text">Определение типа по первому IP в CIDR.</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Агрессивная агрегация</label>
|
||||
<label className="form-check">
|
||||
<input className="form-check-input" type="checkbox" checked={analyzeFilters.supernet16} onChange={e => setAnalyzeFilters(f => ({ ...f, supernet16: e.target.checked }))} />
|
||||
<span className="form-check-label">Если в одном /16 более 2 префиксов — свернуть в /16</span>
|
||||
</label>
|
||||
<div className="form-text">Например: 157.240.12.0/23, 157.240.15.0/24, 157.240.16.0/23 → 157.240.0.0/16</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={() => setAnalyzeOpen(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" disabled={!analyzeFilters.community} onClick={() => { setAnalyzeOpen(false); setAnalyzeConfirmOpen(true); }}>Предпросмотр</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Analyze Confirm */}
|
||||
<ConfirmDialog
|
||||
open={analyzeConfirmOpen}
|
||||
title={'Подтвердить анализ'}
|
||||
message={buildAnalysisConfirmText()}
|
||||
confirmText={'Запустить'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={() => { setAnalyzeConfirmOpen(false); runAnalysis(); }}
|
||||
onCancel={() => setAnalyzeConfirmOpen(false)}
|
||||
/>
|
||||
{/* Analysis Results Modal */}
|
||||
{analysisResultsOpen && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setAnalysisResultsOpen(false)}></button>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Результаты анализа</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{!analysisPreview ? (
|
||||
<div className="text-center text-muted">Нет данных по заданным фильтрам</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<strong>Community:</strong> {renderCommunityBadge(analysisPreview.community)}
|
||||
</div>
|
||||
<div className="row g-3 mb-2">
|
||||
<div className="col-md-4">
|
||||
<div className="card"><div className="card-body">
|
||||
<div className="text-muted">Было (всего в community)</div>
|
||||
<div className="h2 m-0">{analysisPreview.totalBefore}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card"><div className="card-body">
|
||||
<div className="text-muted">Обработано (по фильтрам)</div>
|
||||
<div className="h2 m-0">{analysisPreview.processedBefore}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card"><div className="card-body">
|
||||
<div className="text-muted">Станет (после суммаризации)</div>
|
||||
<div className="h2 m-0">{analysisPreview.totalAfter}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header"><h4 className="card-title">Исходные (учтенные по фильтрам) — {analysisPreview.processedBefore}</h4></div>
|
||||
<div className="card-body" style={{maxHeight: 220, overflow: 'auto'}}>
|
||||
{analysisPreview.beforeCidrs.length === 0 ? (
|
||||
<div className="text-muted">—</div>
|
||||
) : (
|
||||
<ul className="list-unstyled m-0">
|
||||
{analysisPreview.beforeCidrs.map(c => (<li key={c}><code>{c}</code></li>))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header"><h4 className="card-title">После суммаризации — {analysisPreview.processedAfter}</h4></div>
|
||||
<div className="card-body" style={{maxHeight: 220, overflow: 'auto'}}>
|
||||
{analysisPreview.afterCidrs.length === 0 ? (
|
||||
<div className="text-muted">—</div>
|
||||
) : (
|
||||
<ul className="list-unstyled m-0">
|
||||
{analysisPreview.afterCidrs.map(c => (<li key={c}><code>{c}</code></li>))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-text mt-2">Неизменные (вне фильтров): {analysisPreview.unchangedCount}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={() => setAnalysisResultsOpen(false)}>Закрыть</button>
|
||||
<button className="btn btn-primary" disabled={!analysisPreview} onClick={() => setOverwriteConfirmOpen(true)}>
|
||||
<IconDeviceFloppy className="icon me-2" />Применить изменения
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Overwrite confirm */}
|
||||
<ConfirmDialog
|
||||
open={overwriteConfirmOpen}
|
||||
title={'Перезаписать диапазоны в community?'}
|
||||
message={!analysisPreview ? '' : `Community: ${analysisPreview.community}\nБыло: ${analysisPreview.totalBefore}\nСтанет: ${analysisPreview.totalAfter}\nИзменится только выбранное community. Это действие изменит список, но не сохранит его в S3 до нажатия «Сохранить».`}
|
||||
confirmText={'Перезаписать'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={applyAnalysisToItems}
|
||||
onCancel={() => setOverwriteConfirmOpen(false)}
|
||||
/>
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
||||
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IPRangesManager;
|
||||
export default IPRangesManager;
|
||||
Reference in New Issue
Block a user