import { useState, useEffect, useRef } from 'react'; import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, IconSearch, IconEdit, IconTrash, IconCheck, IconX, IconDatabase, IconRefresh, IconUpload, IconDownload, IconDeviceFloppy, IconHash, IconClock, IconFileText } from '@tabler/icons-react'; import LockBanner from './components/LockBanner.jsx'; import TableSkeleton from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; import HistoryModal from './components/HistoryModal.jsx'; 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 [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 [sortField, setSortField] = useState('asn'); const [sortOrder, setSortOrder] = useState('asc'); const [filterCommunity, setFilterCommunity] = useState(''); const editInputRef = useRef(null); const pageSize = 10; // Справочник community для подсказок const [communities, setCommunities] = useState([]); useEffect(() => { (async () => { try { const res = await api.get(`/communities`); setCommunities(Array.isArray(res.data) ? res.data : []); } catch (e) { // тихо игнорируем } })(); }, []); const renderCommunityBadge = (value) => { const v = String(value ?? '').trim(); if (!v) return ; const meta = communities.find(c => c.value === v); const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, ''); const cls = `badge bg-${color}-lt text-${color}`; const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : ''; return ( {v} ); }; const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim()); // Допускаем как числовые, так и строковые (AS:NNN) community const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); useEffect(() => { // Acquire soft lock const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`; localStorage.setItem('uiOwner', owner); const resource = 'asns'; const acquire = async () => { try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} }; acquire(); const interval = setInterval(acquire, 60_000); fetchItems(); return () => { clearInterval(interval); api.delete(`/locks/${resource}`).catch(() => {}); }; }, []); const fetchItems = async () => { setLoading(true); try { const response = await api.get(`/asns`, { params: { offset: 0, limit: 0 } }); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []); const mapped = payload.map(item => ({ asn: item.domain, community: item.type })); setItems(mapped); setOriginalItems(mapped); 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); } }; 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 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 () => { 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)); setConfirmSaveOpen(true); }; 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 payload = { domains: unique.map(i => ({ domain: i.asn, type: i.community })), etag }; 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 || etag); setOriginalItems(unique); setItems(unique); setSuccess('Изменения успешно сохранены!'); setTimeout(() => setSuccess(''), 3000); } catch (error) { console.error('Error saving changes:', error); setError('Не удалось сохранить изменения.'); } finally { setLoading(false); } }; const handleImport = () => { const text = window.prompt('Вставьте строки: ASN ПРОБЕЛ COMMUNITY (по одной записи на строку)'); if (!text) return; 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 handleExport = () => { const header = ['asn', 'community']; const csv = [header, ...items.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); }; // 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 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; }); // Фильтрация по community const filteredByCommunity = filterCommunity ? sortedItems.filter(i => i.community === filterCommunity) : sortedItems; // Поиск const filteredItems = filteredByCommunity.filter(i => i.asn.toLowerCase().includes(searchTerm.toLowerCase()) ); // Пагинация const totalPages = Math.ceil(filteredItems.length / pageSize); const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize); // Для фильтра - список всех уникальных community const allCommunities = Array.from(new Set(items.map(i => i.community))); // Сортировка по клику 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]); const handleEditKeyDown = (e, asn) => { if (e.key === 'Enter') handleSaveEdit(asn); if (e.key === 'Escape') handleCancelEdit(); }; return ( <> {error && (
{error}
)} {success && (
{success}
)}

ASNs

Главная / Данные / ASNs
{/* Add New ASN Card */}

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

{ e.preventDefault(); handleAddItem(); }}>
setNewItem({ ...newItem, asn: e.target.value })} /> {newInvalid.asn &&
Только цифры
}
setNewItem({ ...newItem, community: v })} communities={communities} placeholder="Начните вводить номер или имя" className={`form-control${newInvalid.community ? ' is-invalid' : ''}`} /> {newInvalid.community &&
Только цифры
} {(() => { 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(', ')}) )}
); })()}
{/* Drag & Drop импорт */}
{ e.preventDefault(); }} onDrop={onDropImport} >
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
{/* Actions Card */}

Действия

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

{/* Фильтр по community */} {/* Поиск */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} />
{/* Мета-информация будет показана внизу карточки */} {loading ? ( ) : (
{paginatedItems.map((item) => ( ))}
handleSort('asn')}> Номер AS {sortField === 'asn' && ( {sortOrder === 'asc' ? '▲' : '▼'} )} handleSort('community')}> Community {sortField === 'community' && ( {sortOrder === 'asc' ? '▲' : '▼'} )}
{item.asn} {renderCommunityBadge(item.community)} {editingAsn === item.asn ? ( <> ) : ( <> )}
)} {/* Пагинация */} {totalPages > 1 && (
Показано {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, filteredItems.length)} из {filteredItems.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' ? (
  • ) : (
  • ) )); })()}
)}
{/* Delete Confirmation Modal */} {showDeleteModal && (

Удалить ASN?

Вы уверены, что хотите удалить "{itemToDelete?.asn}"? Это действие необратимо.
)} setConfirmSaveOpen(false)} /> setHistoryOpen(false)} onRolledBack={() => fetchItems()} /> {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } export default ASNsNewManager;