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 ConfirmModal from './components/ConfirmModal.jsx'; import { IconPlus, IconSearch, IconEdit, IconTrash, IconCheck, IconX, IconDatabase, IconRefresh, IconUpload, IconDownload, IconDeviceFloppy, IconHash, IconClock, IconFileText, IconFilter, IconWorld, IconTrendingUp, IconUsers, IconArrowUp, IconArrowDown, IconCopy } from '@tabler/icons-react'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import PageHeader from './components/PageHeader.jsx'; import EmptyState from './components/EmptyState.jsx'; import Breadcrumbs from './components/Breadcrumbs.jsx'; import { IconAlertTriangle } from '@tabler/icons-react'; import S3MetaBar from './components/S3MetaBar.jsx'; import PageHeaderActions from './components/PageHeaderActions.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 FormField from './components/FormField.jsx'; import Tooltip from './components/Tooltip.jsx'; import Pagination from './components/Pagination.jsx'; import { useToast } from './components/ToastContainer.jsx'; const API_URL = '/api'; function DomainsNewManager() { const toast = useToast(); 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({ domain: '', community: '' }); const [newInvalid, setNewInvalid] = useState({ domain: false, community: false }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const searchTimer = useRef(null); const didInit = useRef(false); const [editingDomain, setEditingDomain] = 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('domain'); const [sortOrder, setSortOrder] = useState('asc'); const [filterCommunity, setFilterCommunity] = useState(''); const editInputRef = useRef(null); const pageSize = 10; // Bulk operations const [selectedDomains, setSelectedDomains] = 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) { // тихо игнорируем } })(); }, []); const renderCommunityBadge = (value) => (); const isValidDomain = (value) => { const v = String(value).trim().toLowerCase(); // Простой валидатор FQDN: sub.domain.tld return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v); }; // Допускаем как числовые, так и строковые (AS:NNN) community const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); const [wsOpen, setWsOpen] = useState(false); const [wsUrl, setWsUrl] = useState(''); useEffect(() => { (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(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } }); const payload = Array.isArray(response.data?.items) ? response.data.items : []; const total = payload.length; setItems(payload); setOriginalItems(payload); 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 domains-new:', error); setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.'); } finally { setLoading(false); } }; // Подгрузка при изменении страницы/поиска/фильтра useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []); const handleAddItem = () => { const domainOk = isValidDomain(newItem.domain); const communityOk = isValidCommunity(newItem.community); setNewInvalid({ domain: !domainOk, community: !communityOk }); if (!domainOk || !communityOk) { setError('Заполните корректный домен (example.com) и числовой community.'); return; } setError(''); setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]); setNewItem({ domain: '', community: '' }); setNewInvalid({ domain: false, community: false }); }; const handleEdit = (item) => { setEditingDomain(item.domain); setEditingValue(item.community); }; const handleSaveEdit = (domainName) => { if (!isValidCommunity(editingValue)) { setError('Community должен быть числом.'); return; } const updatedItems = items.map(i => i.domain === domainName ? { ...i, community: String(editingValue).trim() } : i ); setItems(updatedItems); setEditingDomain(null); }; const handleCancelEdit = () => { setEditingDomain(null); }; const handleDeleteItem = (domainNameToDelete) => { setItems(items.filter(i => i.domain !== domainNameToDelete)); setSelectedDomains(prev => { const next = new Set(prev); next.delete(domainNameToDelete); return next; }); }; const copyToClipboard = (text) => { navigator.clipboard.writeText(text).then(() => { toast.success('Скопировано в буфер обмена'); }).catch(() => { toast.error('Не удалось скопировать'); }); }; const confirmDelete = (item) => { setItemToDelete(item); setShowDeleteModal(true); }; const executeDelete = () => { if (itemToDelete) { handleDeleteItem(itemToDelete.domain); setShowDeleteModal(false); setItemToDelete(null); } }; const deduplicate = (arr) => { const seen = new Set(); const out = []; for (const i of arr) { const key = String(i.domain).trim().toLowerCase(); if (!key) continue; if (seen.has(key)) continue; seen.add(key); out.push({ domain: key, community: String(i.community || '').trim() }); } return out; }; const computeDiff = (before, after) => { const mapBefore = new Map(before.map(i => [i.domain, i])); const mapAfter = new Map(after.map(i => [i.domain, 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 => i != null && isValidDomain(i.domain) && isValidCommunity(i.community)) .map(i => ({ domain: i.domain.trim().toLowerCase(), 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 => i != null && isValidDomain(i.domain) && isValidCommunity(i.community)) .map(i => ({ domain: i.domain.trim().toLowerCase(), 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 => isValidDomain(i.domain) && isValidCommunity(i.community)) .map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() })); const unique = deduplicate(valid); // Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } }); const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []); const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }])); const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true])); const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }])); // Удаления: всё, что было на странице, но отсутствует в изменённом наборе 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()); const et = fullRes?.headers?.etag || etag; const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { 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); } }; // 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 [d, c] = l.split(/\s+/); return { domain: (d || '').toLowerCase(), community: (c || '').trim() }; }).filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community)); setItems(prev => { const merged = [...prev, ...parsed]; const map = new Map(); for (const it of merged) { const key = String(it.domain).trim().toLowerCase(); if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() }); } return Array.from(map.values()); }); }; const handleImport = () => { setImportOpen(true); }; const handleExport = async () => { try { const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : ''); const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } }); const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []); const header = ['domain', 'community']; const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', 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 = `domains_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); } catch (e) { window.notify?.error('Экспорт доменов не удался', String(e?.message || e)); } }; const clearInvalid = () => { setItems(prev => prev.filter(i => i != null && (i.domain || i.community)) .filter(i => isValidDomain(i.domain) && isValidCommunity(i.community)) ); }; // ===== Импорт через модалку ===== const [importOpen, setImportOpen] = useState(false); const parseLine = (line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d || '').toLowerCase(), community: (c || '').trim() }; }; const validateItem = (obj) => isValidDomain(obj.domain) && 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.domain).trim().toLowerCase(); if (!map.has(key)) map.set(key, { domain: 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.domain || '').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); // Для фильтра - список всех уникальных community const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => i.community).filter(c => c != null))); // Сортировка по клику const handleSort = (field) => { if (sortField === field) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortOrder('asc'); } }; // Улучшенный инлайн-редакт useEffect(() => { if (editingDomain && editInputRef.current) { editInputRef.current.focus(); } }, [editingDomain]); // Глобальные хоткеи: 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, domainName) => { if (e.key === 'Enter') handleSaveEdit(domainName); if (e.key === 'Escape') handleCancelEdit(); }; // Bulk operations handlers const handleSelectAll = () => { setSelectedDomains(new Set(paginatedItems.map(i => i.domain))); }; const handleDeselectAll = () => { setSelectedDomains(new Set()); }; const handleToggleSelect = (domain) => { const newSet = new Set(selectedDomains); if (newSet.has(domain)) { newSet.delete(domain); } else { newSet.add(domain); } setSelectedDomains(newSet); }; const handleBulkDelete = () => { if (selectedDomains.size === 0) return; const count = selectedDomains.size; const newItems = items.filter(i => !selectedDomains.has(i.domain)); setItems(newItems); setSelectedDomains(new Set()); toast.success(`Удалено ${count} доменов`); }; const handleBulkExport = () => { if (selectedDomains.size === 0) return; const selectedItems = items.filter(i => i != null && selectedDomains.has(i.domain)); const text = selectedItems.map(i => `${i.domain || ''} ${i.community || ''}`).join('\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `domains-selected-${selectedDomains.size}.txt`; a.click(); URL.revokeObjectURL(url); toast.success(`Экспортировано ${selectedDomains.size} доменов`); }; 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 Domain Card (improved) */}

Добавить новый домен

{ e.preventDefault(); handleAddItem(); }}>
setNewItem({ ...newItem, domain: e.target.value })} />
{newInvalid.domain &&
Неверный формат домена
} {isValidDomain(newItem.domain) && !newInvalid.domain && (
Корректный формат домена
)}
Формат: sub.domain.tld (например foo.example.org)
setNewItem({ ...newItem, community: v })} communities={communities} placeholder="Начните вводить номер или имя" className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`} useShortValue={true} />
{newInvalid.community &&
Неверный формат community
} {isValidCommunity(newItem.community) && !newInvalid.community && (
Корректный формат 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.value} {match.description &&
{match.description}
} {match.tags && match.tags.length > 0 && (
{match.tags.map((tag, idx) => ( {tag} ))}
)}
); })()}
{/* Дополнительные варианты */} { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }} validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)} onApply={(list) => { setItems(prev => { const merged = [...prev, ...list]; const map = new Map(); for (const it of merged) { const key = String(it.domain).trim().toLowerCase(); if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() }); } return Array.from(map.values()); }); }} />
{ e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }} onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }} onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }} >
Перетащите файл TXT/CSV сюда
Формат: domain community
{/* Bulk Actions Bar */}

Список доменов {filtered.length}

{/* Сохранённые фильтры */} { if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm); if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity); if (filters.sortField !== undefined) setSortField(filters.sortField); if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder); setCurrentPage(1); }} /> {/* Фильтр по community */}
{/* Поиск */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} aria-label="Поиск домена" style={{ paddingRight: searchTerm ? '2.5rem' : undefined }} /> {searchTerm && ( )}
{/* Мета-информация будет показана внизу карточки */} {loading ? ( ) : paginatedItems.length === 0 ? ( Импорт} secondaryAction={} /> ) : (
{paginatedItems.map((item, idx) => ( ))}
0} onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()} /> handleSort('domain')}>
Домен {sortField === 'domain' && ( {sortOrder === 'asc' ? : } )}
handleSort('community')}>
Community {sortField === 'community' && ( {sortOrder === 'asc' ? : } )}
Действия
handleToggleSelect(item.domain)} aria-label={`Выбрать ${item.domain}`} onClick={(e) => e.stopPropagation()} />
{item.domain}
{renderCommunityBadge(item.community)}
{editingDomain === item.domain ? ( <> handleEditKeyDown(e, item.domain)} className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} style={{ width: '150px' }} useShortValue={true} /> ) : ( <> )}
)} {/* Пагинация */}
setShowDeleteModal(false)} onConfirm={executeDelete} title="Удалить домен?" message={ itemToDelete ? ( <>

Вы уверены, что хотите удалить "{itemToDelete.domain}"?

Это действие необратимо.

) : null } confirmLabel="Удалить" variant="danger" icon={IconTrash} /> {/* Confirm Save Modal */} setConfirmSaveOpen(false)} /> setImportOpen(false)} sampleHeader={['domain','community']} placeholder={'example.com 65000:100'} /> setHistoryOpen(false)} onRolledBack={() => fetchItems()} /> setWsOpen(false)} /> setClearCommunitiesOpen(false)} onConfirm={() => { setItems(prev => prev.filter(i => i != null && i.community !== filterCommunity)); setClearCommunitiesOpen(false); }} title="Удалить записи community?" message={ <>

Будут удалены все домены с community "{filterCommunity}".

Действие НЕ сохраняет изменения автоматически.

} confirmLabel="Удалить" variant="warning" /> {/* Diff Modal */} {showDiff && (

Изменения

Добавлено
{diff.added.length}
Удалено
{diff.removed.length}
Изменено
{diff.changed.length}
)} {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } export default DomainsNewManager;