Files
router-lists-ui/frontend/src/IPRangesManager.jsx
T

1222 lines
46 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => (<CommunityBadge value={value} communities={communities} />);
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 (
<div>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>{success}</div>
</div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
></button>
</div>
)}
<PageHeader
title="IP-диапазоны"
icon={<IconNetwork size={24} />}
meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => {
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),
);
}
}}
/>
}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="ipRange"
communities={communities}
loading={loading}
/>
<div className="card card-flat">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconNetwork className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">
{filtered.length}
</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
<div className="col-12 mt-2"></div>
<div className="col d-flex gap-2 flex-wrap align-items-center">
<div
className="d-flex gap-2 w-auto flex-grow-1"
style={{ minWidth: '300px' }}
>
<select
className="form-select w-auto"
value={filterCommunity}
onChange={(e) => {
setFilterCommunity(e.target.value);
setCurrentPage(1);
}}
>
<option value="">Все community</option>
{allCommunities.map((community) => (
<option key={community} value={community}>
{community}
</option>
))}
</select>
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск IP-диапазона..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
aria-label="Поиск IP-диапазона"
/>
</div>
</div>
<div className="d-flex gap-2">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый IP-диапазон"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div>
</div>
</div>
</div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет IP-диапазонов"
description="Импортируйте или добавьте записи, чтобы начать."
action={
<button className="btn btn-primary" onClick={handleImport}>
Импорт
</button>
}
secondaryAction={
<button
className="btn btn-outline-primary"
onClick={downloadSampleCsv}
>
Шаблон CSV
</button>
}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={
paginatedItems.length > 0 &&
paginatedItems.every((i) => selectedItems.has(i.ipRange))
}
onChange={(e) => {
if (e.target.checked) {
selectAll();
} else {
deselectAll();
}
}}
title="Выбрать все на странице"
/>
</th>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('ipRange')}
>
<div className="d-flex align-items-center">
<IconNetwork size={16} className="me-1 text-muted" />
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
</th>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('community')}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item) => (
<tr
key={item.ipRange}
className={
selectedItems.has(item.ipRange) ? 'table-selected' : ''
}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedItems.has(item.ipRange)}
onChange={() => toggleSelectItem(item.ipRange)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="font-monospace" title="CIDR">
<div className="d-flex align-items-center">
<IconNetwork
size={16}
className="me-2 text-muted"
/>
<span>{item.ipRange}</span>
<Tooltip
content="Копировать IP-диапазон"
position="top"
>
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.ipRange)}
aria-label="Копировать"
title="Копировать IP-диапазон"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
<div
className="btn-group btn-group-sm"
role="group"
aria-label={`Действия с диапазоном ${item.ipRange}`}
>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-icon btn-outline-primary"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div>
<BulkActionsBar
selectedCount={selectedItems.size}
totalCount={paginatedItems.length}
onDeselectAll={deselectAll}
onDelete={handleBulkDelete}
onExport={handleBulkExport}
/>
<ConfirmDialog
open={showDeleteModal}
title={'Удалить IP-диапазон?'}
message={
!itemToDelete
? ''
: `Вы уверены, что хотите удалить "${itemToDelete.ipRange}"? Это действие необратимо.`
}
confirmText={'Удалить'}
cancelText={'Отмена'}
destructive
size={'sm'}
onConfirm={executeDelete}
onCancel={() => setShowDeleteModal(false)}
/>
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<ImportModal
show={importOpen}
title={'Импорт IP-диапазонов'}
description={'Формат: CIDR ПРОБЕЛ COMMUNITY'}
parseLine={parseLine}
validateItem={validateItem}
onConfirm={applyImportedItems}
onClose={() => setImportOpen(false)}
sampleHeader={['ipRange', 'community']}
placeholder={'192.168.1.0/24 65000:100'}
/>
<FormModal
show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit}
onSubmit={handleSaveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
centered
disabled={!isValidCommunity(editingValue)}
>
{itemBeingEdited && (
<>
<p className="text-muted mb-2">
IP-диапазон:{' '}
<span className="font-monospace">{itemBeingEdited.ipRange}</span>
</p>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={handleEditKeyDown}
className={`form-control${
isValidCommunity(editingValue) ? '' : ' is-invalid'
}`}
useShortValue={true}
/>
{!isValidCommunity(editingValue) && (
<div className="invalid-feedback d-block">
Введите корректное значение community (число или N:N).
</div>
)}
</div>
</>
)}
</FormModal>
<HistoryModal
resource="ip-ranges"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
<ConfirmDialog
open={clearCommunitiesOpen}
title={'Удалить записи community'}
message={`Будут удалены все IP-диапазоны с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
confirmText={'Удалить'}
cancelText={'Отмена'}
onConfirm={() => {
setItems((prev) =>
prev.filter((i) => i != null && i.community !== filterCommunity),
);
setClearCommunitiesOpen(false);
}}
onCancel={() => setClearCommunitiesOpen(false)}
/>
</div>
);
}
export default IPRangesManager;