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, IconNetwork, IconCopy, IconCopyCheck } from '@tabler/icons-react'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeader from './components/PageHeader.jsx'; import EmptyState from './components/EmptyState.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 LastSaved from './components/LastSaved.jsx'; import FormModal from './components/FormModal.jsx'; const API_URL = '/api'; function IPRangesManager() { 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({ ipRange: '', community: '' }); const [newInvalid, setNewInvalid] = useState({ ipRange: 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('ipRange'); const [sortOrder, setSortOrder] = useState('asc'); const [filterCommunity, setFilterCommunity] = useState(''); const pageSize = 10; 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) { // тихо игнорируем } })(); }, []); const renderCommunityBadge = (value) => (); const isValidIPv4 = (ip) => { const octets = ip.split('.'); if (octets.length !== 4) return false; return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255); }; const isValidCidr = (value) => { const v = String(value).trim(); const parts = v.split('/'); if (parts.length !== 2) return false; const [ip, mask] = parts; if (!isValidIPv4(ip)) return false; if (!/^\d{1,2}$/.test(mask)) return false; const m = Number(mask); return m >= 0 && m <= 32; }; // Допускаем как числовые, так и строковые (AS:NNN) community const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); const [wsOpen, setWsOpen] = useState(false); const [wsUrl, setWsUrl] = useState(''); useEffect(() => { const controller = new AbortController(); (async () => { try { const r = await api.get('/ws/url', { signal: controller.signal }); setWsUrl(String(r.data?.url || '')); } catch (e) { if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return; } })(); return () => controller.abort(); }, []); const fetchItems = async (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { const response = await api.get(`/ip-ranges`, { 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 ip-ranges:', error); setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.'); } finally { setLoading(false); } }; useEffect(() => { if (!didInit.current) { didInit.current = true; const controller = new AbortController(); fetchItems(controller.signal); return () => controller.abort(); } }, []); const handleAddItem = (item) => { const ipRange = item?.ipRange || newItem.ipRange; const community = item?.community || newItem.community; const cidrOk = isValidCidr(ipRange); const communityOk = isValidCommunity(community); if (!cidrOk || !communityOk) { setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.'); return; } setError(''); const newRange = { ipRange: ipRange.trim(), community: String(community).trim() }; setItems([...items, newRange]); if (!item) { setNewItem({ ipRange: '', community: '' }); setNewInvalid({ ipRange: 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.ipRange === itemBeingEdited.ipRange ? { ...i, community: String(editingValue).trim() } : i ); setItems(updatedItems); setEditModalOpen(false); setItemBeingEdited(null); }; const handleCancelEdit = () => { setEditModalOpen(false); setItemBeingEdited(null); }; const handleDeleteItem = (ipRangeToDelete) => { setItems(items.filter(i => i.ipRange !== ipRangeToDelete)); setSelectedItems(prev => { const next = new Set(prev); next.delete(ipRangeToDelete); return next; }); }; // Функции для работы с выбранными элементами const toggleSelectItem = (ipRange) => { setSelectedItems(prev => { const next = new Set(prev); if (next.has(ipRange)) { next.delete(ipRange); } else { next.add(ipRange); } return next; }); }; const selectAll = () => { setSelectedItems(new Set(paginatedItems.map(i => i.ipRange))); }; const deselectAll = () => { setSelectedItems(new Set()); }; const handleBulkDelete = () => { if (selectedItems.size === 0) return; const count = selectedItems.size; setItems(items.filter(i => !selectedItems.has(i.ipRange))); setSelectedItems(new Set()); window.notify?.success?.(`Удалено ${count} записей`); }; const handleBulkExport = () => { if (selectedItems.size === 0) return; const selected = items.filter(i => selectedItems.has(i.ipRange)); const header = ['ipRange', 'community']; const csv = [header, ...selected.map(i => [i.ipRange, 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 = `ip_ranges_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 duplicateItem = (item) => { setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]); window.notify?.success?.('Запись продублирована'); }; const confirmDelete = (item) => { setItemToDelete(item); setShowDeleteModal(true); }; const executeDelete = () => { if (itemToDelete) { handleDeleteItem(itemToDelete.ipRange); setShowDeleteModal(false); setItemToDelete(null); } }; const deduplicate = (arr) => { const seen = new Set(); const out = []; for (const i of arr) { const key = String(i.ipRange).trim(); if (!key) continue; if (seen.has(key)) continue; seen.add(key); out.push({ ipRange: key, community: String(i.community || '').trim() }); } return out; }; const computeDiff = (before, after) => { const mapBefore = new Map(before.map(i => [i.ipRange, i])); const mapAfter = new Map(after.map(i => [i.ipRange, 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 [analyzeOpen, setAnalyzeOpen] = useState(false); const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false); const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false); const [analysisPreview, setAnalysisPreview] = useState(null); const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false); const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false }); const handlePreviewDiff = () => { const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)) .map(i => ({ ipRange: i.ipRange.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 => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)) .map(i => ({ ipRange: i.ipRange.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 => isValidCidr(i.ipRange) && isValidCommunity(i.community)) .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); const unique = deduplicate(valid); // Загрузим полный список, совместим с изменениями текущей страницы и сохраним const fullRes = await api.get(`/ip-ranges`, { 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.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community || '').trim() }])); const originalPageMap = new Map(originalItems.map(i => [String(i.ipRange).trim(), true])); const uniqueMap = new Map(unique.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), 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(`/ip-ranges`, { ipRanges: 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); } }; const handleImport = () => { setImportOpen(true); }; const handleExport = async () => { try { const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : ''); const response = await api.get(`/ip-ranges`, { 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 = ['ipRange', 'community']; const csv = [header, ...all.map(i => [i.ipRange, 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 = `ip_ranges_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); } catch (e) { window.notify?.error('Экспорт IP-диапазонов не удался', 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 [cidr, c] = l.split(/\s+/); return { ipRange: (cidr || '').trim(), community: (c || '').trim() }; }).filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)); setItems(prev => { const merged = [...prev, ...parsed]; const map = new Map(); for (const it of merged) { const key = String(it.ipRange).trim(); if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() }); } return Array.from(map.values()); }); }; const clearInvalid = () => { setItems(prev => prev.filter(i => i != null && (i.ipRange || i.community)) .filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) ); }; // ===== Импорт через модалку ===== const [importOpen, setImportOpen] = useState(false); const parseLine = (line) => { const [cidr, c] = String(line).split(/\s+/); return { ipRange: (cidr || '').trim(), community: (c || '').trim() }; }; const validateItem = (obj) => isValidCidr(obj.ipRange) && 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.ipRange).trim(); if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() }); } return Array.from(map.values()); }); setImportOpen(false); }; // Шаблон CSV для пустого состояния const downloadSampleCsv = () => { const header = ['ipRange','community']; const lines = [header, ['192.168.1.0/24', '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 = 'ip_ranges_sample.csv'; a.click(); URL.revokeObjectURL(url); }; // Сортировка 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 => { const byCommunity = !filterCommunity || i.community === filterCommunity; const term = String(searchTerm || '').toLowerCase(); const bySearch = !term || String(i.ipRange).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.map(i => i.community))); // Сортировка по клику 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, items, originalItems, etag, sortField, sortOrder, filterCommunity]); const handleEditKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(); } if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit(); } }; // ===== Аналитика: утилиты ===== const parseMask = (cidr) => { const parts = String(cidr || '').split('/'); const mask = Number(parts[1]); return Number.isFinite(mask) ? mask : NaN; }; const countAddresses = (mask) => { if (!Number.isFinite(mask) || mask < 0 || mask > 32) return 0n; return 1n << BigInt(32 - mask); }; const parseOctets = (ip) => { const [a, b, c, d] = String(ip || '').split('.').map(x => Number(x)); return [a, b, c, d]; }; const isPrivateIPv4 = (ip) => { const [a, b] = parseOctets(ip); if (a === 10) return true; // 10.0.0.0/8 if (a === 192 && b === 168) return true; // 192.168.0.0/16 if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 return false; }; const isCGNATIPv4 = (ip) => { const [a, b] = parseOctets(ip); return a === 100 && b >= 64 && b <= 127; // 100.64.0.0/10 }; const getIpCategory = (cidr) => { const baseIp = String(cidr || '').split('/')[0]; if (isCGNATIPv4(baseIp)) return 'cgnat'; if (isPrivateIPv4(baseIp)) return 'private'; return 'public'; }; const formatBigInt = (n) => { try { const s = (n ?? 0n).toString(); return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } catch { return String(n); } }; const buildAnalysisConfirmText = () => { const parts = []; parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`); parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`); const typeLabel = analyzeFilters.type === 'any' ? 'любой' : (analyzeFilters.type === 'private' ? 'только приватные' : (analyzeFilters.type === 'public' ? 'только публичные' : 'только CGNAT')); parts.push(`Тип: ${typeLabel}`); parts.push(`Агрегировать >2 префиксов в одном /16 до /16: ${analyzeFilters.supernet16 ? 'да' : 'нет'}`); return parts.join('\n'); }; // ===== Суммаризация CIDR для выбранного community ===== const ipToInt = (ip) => { const [a, b, c, d] = parseOctets(ip); return (BigInt(a) << 24n) | (BigInt(b) << 16n) | (BigInt(c) << 8n) | BigInt(d); }; const intToIp = (num) => { const a = Number((num >> 24n) & 255n); const b = Number((num >> 16n) & 255n); const c = Number((num >> 8n) & 255n); const d = Number(num & 255n); return `${a}.${b}.${c}.${d}`; }; const cidrToRange = (cidr) => { const [ip, maskStr] = String(cidr).split('/'); const mask = Number(maskStr); const ipNum = ipToInt(ip); const hostBits = 32 - mask; const start = ipNum & (~0n << BigInt(hostBits)); const end = start | ((1n << BigInt(hostBits)) - 1n); return [start, end]; }; const countTrailingZeros32 = (n) => { if (n === 0n) return 32; let zeros = 0; for (let i = 0; i < 32; i++) { if ((n & (1n << BigInt(i))) !== 0n) { zeros = i; break; } } return zeros; }; const floorLog2 = (n) => { let p = -1; while (n > 0n) { n >>= 1n; p++; } return p < 0 ? 0 : p; }; const rangeToCidrs = (start, end) => { const res = []; let cur = start; while (cur <= end) { const maxAlignPow = countTrailingZeros32(cur); const remaining = end - cur + 1n; const remainingPow = floorLog2(remaining); const blockPow = Math.min(maxAlignPow, remainingPow); const mask = 32 - blockPow; res.push(`${intToIp(cur)}/${mask}`); cur += 1n << BigInt(blockPow); } return res; }; const mergeIntervals = (intervals) => { if (intervals.length === 0) return []; intervals.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); const merged = []; let [cs, ce] = intervals[0]; for (let i = 1; i < intervals.length; i++) { const [s, e] = intervals[i]; if (s <= ce + 1n) { if (e > ce) ce = e; } else { merged.push([cs, ce]); cs = s; ce = e; } } merged.push([cs, ce]); return merged; }; const summarizeCidrs = (cidrs) => { const ranges = cidrs.map(cidrToRange); const merged = mergeIntervals(ranges); const out = []; for (const [s, e] of merged) { out.push(...rangeToCidrs(s, e)); } return out; }; // Агрессивная агрегация: если в одном /16 > 2 префиксов — свернуть в /16 const get16BaseCidr = (cidr) => { const ip = String(cidr).split('/')[0]; const ipNum = ipToInt(ip); const start = ipNum & (~0n << 16n); return `${intToIp(start)}/16`; }; const applySupernet16 = (cidrs) => { const buckets = new Map(); for (const c of cidrs) { const base16 = get16BaseCidr(c); if (!buckets.has(base16)) buckets.set(base16, []); buckets.get(base16).push(c); } const out = []; for (const [base16, list] of buckets) { if (list.length > 2) out.push(base16); else out.push(...list); } return Array.from(new Set(out)); }; const runAnalysis = () => { const community = String(analyzeFilters.community || '').trim(); if (!community) { window.notify?.error?.('Выберите community'); return; } // исходные для community const allForCommunity = items.filter(i => i != null && i.community === community && isValidCidr(i.ipRange)); const totalBefore = allForCommunity.length; const filtered = allForCommunity.filter(i => { const mask = parseMask(i.ipRange); if (mask < analyzeFilters.minMask || mask > analyzeFilters.maxMask) return false; const cat = getIpCategory(i.ipRange); if (analyzeFilters.type !== 'any' && cat !== analyzeFilters.type) return false; return true; }); const unchanged = allForCommunity.filter(i => !filtered.includes(i)); const beforeCidrs = filtered.map(i => i.ipRange); let afterCidrs = summarizeCidrs(beforeCidrs); if (analyzeFilters.supernet16) { afterCidrs = applySupernet16(afterCidrs); } const processedBefore = beforeCidrs.length; const processedAfter = afterCidrs.length; const totalAfter = processedAfter + unchanged.length; setAnalysisPreview({ community, totalBefore, totalAfter, processedBefore, processedAfter, unchangedCount: unchanged.length, beforeCidrs, afterCidrs, unchangedCidrs: unchanged.map(i => i.ipRange) }); setAnalysisResultsOpen(true); }; const applyAnalysisToItems = () => { if (!analysisPreview) return; const { community, afterCidrs, unchangedCidrs } = analysisPreview; const keptOthers = items.filter(i => i != null && i.community !== community); const newCommunityItems = [ ...unchangedCidrs.map(ipRange => ({ ipRange, community })), ...afterCidrs.map(ipRange => ({ ipRange, community })) ]; // Удаляем дубликаты по ipRange, соблюдая глобальную уникальность ключа ipRange const map = new Map(); for (const it of [...keptOthers, ...newCommunityItems]) { const key = String(it.ipRange).trim(); if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() }); else { // Если ipRange уже существует в другой community, перезапишем значением текущего (по выбранному community) // но только если этот ipRange относится к выбранному community if (it.community === community) map.set(key, { ipRange: key, community: String(it.community).trim() }); } } const next = Array.from(map.values()); setItems(next); setAnalysisResultsOpen(false); setOverwriteConfirmOpen(false); window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.'); }; const exportAnalysisCsv = () => { const header = ['community', 'ranges', 'totalAddresses']; const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])] .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 = `ip_ranges_analysis_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); }; 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="ipRange" communities={communities} loading={loading} />
Список {filtered.length}
Всего записей: {filtered.length}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} aria-label="Поиск IP-диапазона" />
{loading ? ( ) : paginatedItems.length === 0 ? ( Импорт } secondaryAction={ } /> ) : (
{paginatedItems.map((item) => ( ))}
0 && paginatedItems.every((i) => selectedItems.has(i.ipRange)) } onChange={(e) => { if (e.target.checked) { selectAll(); } else { deselectAll(); } }} title="Выбрать все на странице" /> handleSort('ipRange')} >
IP-диапазон {sortField === 'ipRange' && ( {sortOrder === 'asc' ? ( ) : ( )} )}
handleSort('community')} >
Community {sortField === 'community' && ( {sortOrder === 'asc' ? ( ) : ( )} )}
Действия
toggleSelectItem(item.ipRange)} onClick={(e) => e.stopPropagation()} />
{item.ipRange}
{renderCommunityBadge(item.community)}
)}
setShowDeleteModal(false)} /> setConfirmSaveOpen(false)} /> setImportOpen(false)} sampleHeader={['ipRange', 'community']} placeholder={'192.168.1.0/24 65000:100'} /> {itemBeingEdited && ( <>

IP-диапазон:{' '} {itemBeingEdited.ipRange}

{!isValidCommunity(editingValue) && (
Введите корректное значение community (число или N:N).
)}
)}
setHistoryOpen(false)} onRolledBack={() => fetchItems()} /> setWsOpen(false)} /> { setItems((prev) => prev.filter((i) => i != null && i.community !== filterCommunity), ); setClearCommunitiesOpen(false); }} onCancel={() => setClearCommunitiesOpen(false)} />
); } export default IPRangesManager;