import { useState, useEffect, useRef, useCallback } 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 { IconAlertTriangle } from '@tabler/icons-react'; 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 AddItemModal from './components/AddItemModal.jsx'; import LastSaved from './components/LastSaved.jsx'; import FormModal from './components/FormModal.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 [, setContentLength] = useState(null); const [newItem, setNewItem] = useState({ domain: '', community: '' }); const [, 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 [editingValue, setEditingValue] = useState(''); const [editModalOpen, setEditModalOpen] = useState(false); const [itemBeingEdited, setItemBeingEdited] = useState(null); 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('domain'); const [sortOrder, setSortOrder] = useState('asc'); const [filterCommunity, setFilterCommunity] = useState(''); const pageSize = 10; // Bulk operations const [selectedDomains, setSelectedDomains] = useState(new Set()); // Справочник community для подсказок const [communities, setCommunities] = useState([]); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; (async () => { try { const res = await api.get(`/communities`, { signal }); setCommunities(Array.isArray(res.data) ? res.data : []); } catch (err) { if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; } })(); (async () => { try { const r = await api.get('/ws/url', { signal }); setWsUrl(String(r.data?.url || '')); } catch (err) { if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; } })(); return () => controller.abort(); }, []); 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(''); const fetchItems = useCallback(async (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts }); 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) { if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; console.error('Error fetching domains-new:', error); setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.'); } finally { setLoading(false); } }, []); useEffect(() => { if (!didInit.current) { didInit.current = true; const controller = new AbortController(); fetchItems(controller.signal); return () => controller.abort(); } }, [fetchItems]); const handleAddItem = (item) => { // Если item передан (из модалки), используем его, иначе из state (старый способ) const domain = item?.domain || newItem.domain; const community = item?.community || newItem.community; const domainOk = isValidDomain(domain); const communityOk = isValidCommunity(community); if (!domainOk || !communityOk) { setError('Заполните корректный домен (example.com) и числовой community.'); return; } setError(''); const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() }; setItems([...items, newDomain]); if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки setNewInvalid({ domain: false, community: false }); }; const handleEdit = (item) => { setItemBeingEdited(item); setEditingValue(item.community); setEditModalOpen(true); }; const handleSaveEdit = () => { if (!itemBeingEdited) return; if (!isValidCommunity(editingValue)) { setError('Community должен быть числом.'); return; } const updatedItems = items.map(i => i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i ); setItems(updatedItems); setEditModalOpen(false); setItemBeingEdited(null); }; const handleCancelEdit = () => { setEditModalOpen(false); setItemBeingEdited(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 performSave = useCallback(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); } }, [items, originalItems, etag, fetchItems]); const handleSaveChanges = useCallback(async () => { 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(); } }, [items, originalItems, performSave]); // 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'); } }; // Глобальные хоткеи: 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, handleSaveChanges]); const handleEditKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(); } if (e.key === 'Escape') { e.preventDefault(); 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}
)} } meta={} actions={( { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }} disableClearCommunities={items.length === 0 || !filterCommunity} onSave={handleSaveChanges} disableSave={loading} onHistory={() => setHistoryOpen(true)} lastModified={lastModified} 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)); } }} /> )} /> setAddModalOpen(false)} onSubmit={handleAddItem} type="domain" communities={communities} loading={loading} />
{/* Bulk Actions Bar */}
Список {filtered.length}
Всего записей: {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)}
)}
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)} /> {/* Модальное окно редактирования community */} {itemBeingEdited && ( <>

Домен: {itemBeingEdited.domain}

{!isValidCommunity(editingValue) && (
Введите корректное значение community (число или N:N).
)}
)}
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;