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 } 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 { 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 [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({}); // Справочник 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) => (); 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 = () => { const asnOk = isValidAsn(newItem.asn); const communityOk = isValidCommunity(newItem.community); setNewInvalid({ asn: !asnOk, community: !communityOk }); if (!asnOk || !communityOk) { setError('Введите корректный номер ASN и числовой community.'); return; } setError(''); setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]); 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)); }; 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 && ( setError('')} /> )} {success && (
{success}
)} )} actions={( { 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)); } }} /> )} />
{/* Classic Add New ASN Card (restored) */}

Добавить новый ASN

{ e.preventDefault(); handleAddItem(); }}>
setNewItem({ ...newItem, asn: e.target.value })} /> {newInvalid.asn &&
Только цифры
}
Формат: только цифры, например 12345
setNewItem({ ...newItem, community: v })} communities={communities} placeholder="Начните вводить номер или имя" className={`form-control${newInvalid.community ? ' is-invalid' : ''}`} /> {newInvalid.community &&
Только цифры
}
Формат: N или N:N (например 65000:100)
{(() => { const match = communities.find(c => c.value === String(newItem.community).trim()); if (!match) return null; return (
{match.name && (<>{match.name} — )} {match.description || ''} {match.tags && match.tags.length > 0 && ( <> ({match.tags.join(', ')}) )}
); })()}
{/* Дополнительные варианты */} { const [a, c] = String(line).split(/\s+/); return { asn: (a||'').trim(), community: (c||'').trim() }; }} validateItem={(obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community)} onApply={(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()); }); }} />
{ e.preventDefault(); }} onDrop={onDropImport} >
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
{/* Drag & Drop импорт */}
{ e.preventDefault(); }} onDrop={onDropImport} >
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
{/* Карточка действий больше не нужна — действия перенесены в page-header */}

Список ASN {items.length}

{/* Фильтр по community */} {/* Поиск */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} aria-label="Поиск ASN" />
{/* Мета-информация будет показана внизу карточки */} {loading ? ( ) : paginatedItems.length === 0 ? ( Импорт} secondaryAction={} /> ) : (
{paginatedItems.map((item) => ( ))}
handleSort('asn')}> Номер AS {sortField === 'asn' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} Имя AS handleSort('community')}> Community {sortField === 'community' && ( {sortOrder === 'asc' ? '▲' : '▼'} )}
{item.asn} {asnNameMap[item.asn] ? ( {asnNameMap[item.asn]} ) : ( )} {renderCommunityBadge(item.community)} {editingAsn === item.asn ? ( <> handleEditKeyDown(e, item.asn)} className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} /> ) : ( <> )}
)} {/* Пагинация */} {totalPages > 1 && (
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
  • {(() => { const pages = []; let start = Math.max(1, currentPage - 2); let end = Math.min(totalPages, currentPage + 2); if (currentPage <= 3) end = Math.min(totalPages, 5); if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4); if (start > 1) pages.push('start-ellipsis'); for (let p = start; p <= end; p++) pages.push(p); if (end < totalPages) pages.push('end-ellipsis'); return pages.map((p) => ( p === 'start-ellipsis' || p === 'end-ellipsis' ? (
  • ) : (
  • ) )); })()}
)}
setShowDeleteModal(false)} /> setConfirmSaveOpen(false)} /> setImportOpen(false)} sampleHeader={['asn','community']} placeholder={'12345 65000:100'} /> setHistoryOpen(false)} onRolledBack={() => fetchItems()} /> setWsOpen(false)} /> { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }} onCancel={() => setClearCommunitiesOpen(false)} /> {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } export default ASNsNewManager;