Files
router-lists-ui/frontend/src/ASNsNewManager.jsx
T

885 lines
37 KiB
React

import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText,
IconFilter,
IconArrowUp,
IconArrowDown,
IconCopy
} from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
import ImportModal from './components/ImportModal.jsx';
import QuickAddBar from './components/QuickAddBar.jsx';
import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import Tooltip from './components/Tooltip.jsx';
import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx';
import { getAsnName, getAsnNameSync } from './lib/asn.js';
const API_URL = '/api';
function ASNsNewManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ asn: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const searchTimer = useRef(null);
const didInit = useRef(false);
const [editingAsn, setEditingAsn] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [sortField, setSortField] = useState('asn');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
const [asnNameMap, setAsnNameMap] = useState({});
const [selectedItems, setSelectedItems] = useState(new Set());
// Справочник community для подсказок
const [communities, setCommunities] = useState([]);
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
// тихо игнорируем
}
})();
}, []);
// Подгружаем имена ASN из кэша/внешних API (эффект будет размещен ниже после определения paginatedItems)
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
// Load WS URL once
(async () => {
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
})();
}, []);
const fetchItems = async () => {
setLoading(true);
try {
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
const total = mapped.length;
setItems(mapped);
setOriginalItems(mapped);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError('');
} catch (error) {
console.error('Error fetching ASNs:', error);
setError('Не удалось загрузить список ASN. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
};
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
const handleAddItem = (item) => {
const asn = item?.asn || newItem.asn;
const community = item?.community || newItem.community;
const asnOk = isValidAsn(asn);
const communityOk = isValidCommunity(community);
if (!asnOk || !communityOk) {
setError('Введите корректный номер ASN и числовой community.');
return;
}
setError('');
const newAsn = { asn: String(asn).trim(), community: String(community).trim() };
setItems([...items, newAsn]);
if (!item) {
setNewItem({ asn: '', community: '' });
setNewInvalid({ asn: false, community: false });
}
};
const handleEdit = (item) => {
setEditingAsn(item.asn);
setEditingValue(item.community);
};
const handleSaveEdit = (asn) => {
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.asn === asn ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditingAsn(null);
};
const handleCancelEdit = () => {
setEditingAsn(null);
};
const handleDeleteItem = (asnToDelete) => {
setItems(items.filter(i => i.asn !== asnToDelete));
setSelectedItems(prev => {
const next = new Set(prev);
next.delete(asnToDelete);
return next;
});
};
// Функции для работы с выбранными элементами
const toggleSelectItem = (asn) => {
setSelectedItems(prev => {
const next = new Set(prev);
if (next.has(asn)) {
next.delete(asn);
} else {
next.add(asn);
}
return next;
});
};
const selectAll = () => {
setSelectedItems(new Set(paginatedItems.map(i => i.asn)));
};
const deselectAll = () => {
setSelectedItems(new Set());
};
const handleBulkDelete = () => {
if (selectedItems.size === 0) return;
const count = selectedItems.size;
setItems(items.filter(i => !selectedItems.has(i.asn)));
setSelectedItems(new Set());
window.notify?.success?.(`Удалено ${count} записей`);
};
const handleBulkExport = () => {
if (selectedItems.size === 0) return;
const selected = items.filter(i => selectedItems.has(i.asn));
const header = ['asn', 'community'];
const csv = [header, ...selected.map(i => [i.asn, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `asns_selected_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
window.notify?.success?.(`Экспортировано ${selectedItems.size} записей`);
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
window.notify?.success?.('Скопировано в буфер обмена');
}).catch(() => {
window.notify?.error?.('Не удалось скопировать');
});
};
const confirmDelete = (item) => {
setItemToDelete(item);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (itemToDelete) {
handleDeleteItem(itemToDelete.asn);
setShowDeleteModal(false);
setItemToDelete(null);
}
};
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const i of arr) {
const key = String(i.asn).trim();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ asn: key, community: String(i.community || '').trim() });
}
return out;
};
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i.asn, i]));
const mapAfter = new Map(after.map(i => [i.asn, i]));
const added = [];
const removed = [];
const changed = [];
for (const [k, v] of mapAfter) {
if (!mapBefore.has(k)) { added.push(v); continue; }
const prev = mapBefore.get(k);
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
}
for (const [k, v] of mapBefore) {
if (!mapAfter.has(k)) removed.push(v);
}
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
const handlePreviewDiff = () => {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const handleSaveChanges = async () => {
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
// Умное подтверждение: показываем модалку только при массовых изменениях
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
// Массовое изменение - требуется подтверждение
setConfirmSaveOpen(true);
} else {
// Малое изменение - сохраняем сразу
performSave();
}
};
const performSave = async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Загрузим полный список, совместим и сохраним
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values()).map(i => ({ domain: i.asn, type: i.community }));
const et = fullRes?.headers?.etag || etag;
const payload = { domains: fullToSave, etag: et };
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
setEtag(response.headers?.etag || et);
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
const handleImport = () => {
setImportOpen(true);
};
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
const header = ['asn', 'community'];
const csv = [header, ...all.map(i => [i.asn, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `asns_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
window.notify?.error('Экспорт ASN не удался', String(e?.message || e));
}
};
// Drag&Drop импорт (быстрый путь)
const onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const text = await file.text();
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const parsed = lines.map(l => {
const [a, c] = l.split(/\s+/);
return { asn: (a || '').trim(), community: (c || '').trim() };
}).filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
setItems(prev => {
const merged = [...prev, ...parsed];
const map = new Map();
for (const it of merged) {
const key = String(it.asn).trim();
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
};
const clearInvalid = () => {
setItems(prev => prev.filter(i => i.asn || i.community)
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
);
};
// ===== Импорт через модалку =====
const [importOpen, setImportOpen] = useState(false);
const parseLine = (line) => {
const [a, c] = String(line).split(/\s+/);
return { asn: (a || '').trim(), community: (c || '').trim() };
};
const validateItem = (obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community);
const applyImportedItems = (list) => {
setItems(prev => {
const merged = [...prev, ...list];
const map = new Map();
for (const it of merged) {
const key = String(it.asn).trim();
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
setImportOpen(false);
};
// Сортировка
const sortedItems = [...items].sort((a, b) => {
let valA = a[sortField] || '';
let valB = b[sortField] || '';
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
// Локальная фильтрация и пагинация
const filtered = sortedItems.filter(i => {
if (!i) return false;
const byCommunity = !filterCommunity || i.community === filterCommunity;
const term = String(searchTerm || '').toLowerCase();
const bySearch = !term || String(i.asn || '').toLowerCase().includes(term);
return byCommunity && bySearch;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
useEffect(() => {
const visible = paginatedItems.filter(i => i != null).map(i => i.asn).filter(asn => asn != null);
visible.forEach(async (asn) => {
const cached = getAsnNameSync(asn);
if (cached !== null && typeof cached !== 'undefined') {
setAsnNameMap(prev => ({ ...prev, [asn]: cached }));
return;
}
const name = await getAsnName(asn);
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
});
}, [JSON.stringify(paginatedItems)]);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== '')));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingAsn && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingAsn]);
// Глобальные хоткеи: Ctrl+S — сохранить изменения
useEffect(() => {
const onKey = (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
if (!loading) handleSaveChanges();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
const handleEditKeyDown = (e, asn) => {
if (e.key === 'Enter') handleSaveEdit(asn);
if (e.key === 'Escape') handleCancelEdit();
};
return (
<>
{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>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<PageHeader
title="ASNs"
pretitle={(
<Breadcrumbs items={[
{ label: 'Главная', to: '/dashboard' },
{ label: 'Данные' },
{ label: 'ASNs' },
]} />
)}
actions={(
<div className="d-flex align-items-center gap-2">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый ASN"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
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));
}
}}
/>
</div>
)}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="asn"
communities={communities}
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">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="d-flex gap-2 flex-wrap">
<SavedFilters
pageKey="asns"
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
onApplyFilter={(filters) => {
setSearchTerm(filters.searchTerm || '');
setFilterCommunity(filters.filterCommunity || '');
setSortField(filters.sortField || 'asn');
setSortOrder(filters.sortOrder || 'asc');
setCurrentPage(1);
}}
/>
{/* Фильтр по community */}
<div className="input-icon">
<span className="input-icon-addon">
<IconFilter size={18} />
</span>
<select
className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
title={(() => {
const meta = communities.find(c => String(c.value) === String(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 => String(c.value) === String(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={String(community)} title={title}>{label}</option>
);
})}
</select>
</div>
{/* Поиск */}
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск ASN..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск ASN"
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
aria-label="Очистить поиск"
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
>
<IconX size={16} />
</button>
)}
</div>
</div>
<div></div>
</div>
</div>
{/* Таблица без карточной подложки */}
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['asn','community'];
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон 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.asn))}
onChange={(e) => {
if (e.target.checked) {
selectAll();
} else {
deselectAll();
}
}}
title="Выбрать все на странице"
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Номер AS
{sortField === 'asn' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th>
Имя AS
</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.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? '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.asn)}
onChange={() => toggleSelectItem(item.asn)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="font-monospace" title="ASN">
<div className="d-flex align-items-center">
<IconHash size={16} className="me-2 text-muted" />
<span>{item.asn}</span>
<Tooltip content="Копировать ASN" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.asn)}
aria-label="Копировать"
title="Копировать ASN"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td title={asnNameMap[item.asn] || ''}>
{asnNameMap[item.asn] ? (
<span>{asnNameMap[item.asn]}</span>
) : (
<span className="text-muted"></span>
)}
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingAsn === item.asn ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
/>
<Tooltip content="Сохранить (Enter)">
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
</Tooltip>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Пагинация */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
<S3MetaBar keys={["asns"]} />
</div>
</div>
<ConfirmDialog
open={showDeleteModal}
title={'Удалить ASN?'}
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`}
confirmText={'Удалить'}
cancelText={'Отмена'}
destructive
size={'sm'}
onConfirm={executeDelete}
onCancel={() => setShowDeleteModal(false)}
/>
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<ImportModal
show={importOpen}
title={'Импорт ASN'}
description={'Формат: ASN ПРОБЕЛ COMMUNITY'}
parseLine={parseLine}
validateItem={validateItem}
onConfirm={applyImportedItems}
onClose={() => setImportOpen(false)}
sampleHeader={['asn','community']}
placeholder={'12345 65000:100'}
/>
<HistoryModal
resource="asns"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
open={clearCommunitiesOpen}
title={'Удалить записи community'}
message={`Будут удалены все записи с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
confirmText={'Удалить'}
cancelText={'Отмена'}
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
onCancel={() => setClearCommunitiesOpen(false)}
/>
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</>
);
}
export default ASNsNewManager;