Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
1412 lines
64 KiB
React
1412 lines
64 KiB
React
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 S3MetaBar from './components/S3MetaBar.jsx';
|
||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||
import PageHeader from './components/PageHeader.jsx';
|
||
import EmptyState from './components/EmptyState.jsx';
|
||
import Breadcrumbs from './components/Breadcrumbs.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';
|
||
|
||
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 [editingIp, setEditingIp] = 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('ipRange');
|
||
const [sortOrder, setSortOrder] = useState('asc');
|
||
const [filterCommunity, setFilterCommunity] = useState('');
|
||
const editInputRef = useRef(null);
|
||
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(() => {
|
||
(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(`/ip-ranges`, { 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 ip-ranges:', error);
|
||
setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||
|
||
const handleAddItem = () => {
|
||
const cidrOk = isValidCidr(newItem.ipRange);
|
||
const communityOk = isValidCommunity(newItem.community);
|
||
setNewInvalid({ ipRange: !cidrOk, community: !communityOk });
|
||
if (!cidrOk || !communityOk) {
|
||
setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.');
|
||
return;
|
||
}
|
||
setError('');
|
||
setItems([...items, { ipRange: newItem.ipRange.trim(), community: String(newItem.community).trim() }]);
|
||
setNewItem({ ipRange: '', community: '' });
|
||
setNewInvalid({ ipRange: false, community: false });
|
||
};
|
||
|
||
const handleEdit = (item) => {
|
||
setEditingIp(item.ipRange);
|
||
setEditingValue(item.community);
|
||
};
|
||
|
||
const handleSaveEdit = (ipRange) => {
|
||
if (!isValidCommunity(editingValue)) {
|
||
setError('Community должен быть числом.');
|
||
return;
|
||
}
|
||
const updatedItems = items.map(i =>
|
||
i.ipRange === ipRange ? { ...i, community: String(editingValue).trim() } : i
|
||
);
|
||
setItems(updatedItems);
|
||
setEditingIp(null);
|
||
};
|
||
|
||
const handleCancelEdit = () => {
|
||
setEditingIp(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');
|
||
}
|
||
};
|
||
|
||
// Улучшенный инлайн-редакт
|
||
useEffect(() => {
|
||
if (editingIp && editInputRef.current) {
|
||
editInputRef.current.focus();
|
||
}
|
||
}, [editingIp]);
|
||
// Глобальные хоткеи: 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, ipRange) => {
|
||
if (e.key === 'Enter') handleSaveEdit(ipRange);
|
||
if (e.key === 'Escape') 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 && (
|
||
<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-диапазоны"
|
||
pretitle={(
|
||
<Breadcrumbs items={[
|
||
{ label: 'Главная', to: '/dashboard' },
|
||
{ label: 'Данные' },
|
||
{ label: 'IP ranges' },
|
||
]} />
|
||
)}
|
||
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)}
|
||
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));
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
<div className="row">
|
||
<div className="col-lg-3">
|
||
{/* Classic Add New Item Card (improved) */}
|
||
<div className="card card-md animate-in">
|
||
<div className="card-header bg-primary-lt">
|
||
<h3 className="card-title text-primary">
|
||
<IconPlus className="icon me-2" />
|
||
Добавить новый IP-диапазон
|
||
</h3>
|
||
</div>
|
||
<div className="card-body">
|
||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||
<div className="mb-3">
|
||
<label className="form-label required">IP-диапазон</label>
|
||
<div className="input-icon">
|
||
<span className="input-icon-addon">
|
||
<IconNetwork size={18} />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className={`form-control${newInvalid.ipRange ? ' is-invalid' : isValidCidr(newItem.ipRange) ? ' is-valid' : ''}`}
|
||
placeholder="192.168.1.0/24"
|
||
value={newItem.ipRange}
|
||
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
||
/>
|
||
</div>
|
||
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
|
||
{isValidCidr(newItem.ipRange) && !newInvalid.ipRange && (<div className="valid-feedback">Корректный CIDR</div>)}
|
||
<div className="form-text text-muted small">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label required">Community</label>
|
||
<div className="input-icon">
|
||
<span className="input-icon-addon">
|
||
<IconHash size={18} />
|
||
</span>
|
||
<CommunityAutocompleteInput
|
||
value={newItem.community}
|
||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||
communities={communities}
|
||
placeholder="Начните вводить номер или имя"
|
||
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
||
useShortValue={true}
|
||
/>
|
||
</div>
|
||
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
||
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
|
||
{(() => {
|
||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||
if (!match) return null;
|
||
return (
|
||
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||
<div className="d-flex">
|
||
<div>
|
||
<strong>{match.name || match.value}</strong>
|
||
{match.description && <div className="text-muted small">{match.description}</div>}
|
||
{match.tags && match.tags.length > 0 && (
|
||
<div className="mt-1">
|
||
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="form-footer">
|
||
<button type="submit" className="btn btn-primary w-100" disabled={!isValidCidr(newItem.ipRange) || !isValidCommunity(newItem.community)}>
|
||
<IconPlus className="icon me-2" />
|
||
Добавить диапазон
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Дополнительные варианты */}
|
||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
||
<QuickAddBar
|
||
placeholder={'192.168.1.0/24 65000:100'}
|
||
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
|
||
parseLine={(line) => { const [cidr, c] = String(line).split(/\s+/); return { ipRange: (cidr||'').trim(), community: (c||'').trim() }; }}
|
||
validateItem={(obj) => isValidCidr(obj.ipRange) && isValidCommunity(obj.community)}
|
||
onApply={(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());
|
||
});
|
||
}}
|
||
/>
|
||
<div className="card card-md border-dashed mt-3 animate-in"
|
||
style={{animationDelay: '0.15s'}}
|
||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
||
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
||
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
||
>
|
||
<div className="card-body text-center text-muted py-4">
|
||
<IconUpload size={32} className="mb-2 text-muted" />
|
||
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
||
<div className="small">Формат: CIDR community</div>
|
||
</div>
|
||
</div>
|
||
</AccordionCard>
|
||
|
||
|
||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||
</div>
|
||
|
||
<div className="col-lg-9">
|
||
{/* BulkActionsBar */}
|
||
<BulkActionsBar
|
||
selectedCount={selectedItems.size}
|
||
totalCount={paginatedItems.length}
|
||
onSelectAll={selectAll}
|
||
onDeselectAll={deselectAll}
|
||
onDelete={selectedItems.size > 0 ? () => {
|
||
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
|
||
handleBulkDelete();
|
||
}
|
||
} : null}
|
||
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||
/>
|
||
|
||
<div className="card">
|
||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||
<div className="d-flex gap-2 flex-wrap">
|
||
<SavedFilters
|
||
pageKey="ip-ranges"
|
||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||
onApplyFilter={(filters) => {
|
||
setSearchTerm(filters.searchTerm || '');
|
||
setFilterCommunity(filters.filterCommunity || '');
|
||
setSortField(filters.sortField || 'ipRange');
|
||
setSortOrder(filters.sortOrder || 'asc');
|
||
setCurrentPage(1);
|
||
}}
|
||
/>
|
||
<div className="d-flex gap-2 w-auto flex-grow-1" style={{minWidth: '300px'}}>
|
||
{/* Фильтр по community */}
|
||
<select
|
||
className="form-select w-auto"
|
||
value={filterCommunity}
|
||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||
title={(() => {
|
||
const meta = communities.find(c => c.value === filterCommunity);
|
||
if (!meta) return 'Все community';
|
||
const name = meta.name ? meta.name + ' — ' : '';
|
||
const desc = meta.description || '';
|
||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||
return name + desc + tags;
|
||
})()}
|
||
>
|
||
<option value="">Все community</option>
|
||
{allCommunities.map(community => {
|
||
const meta = communities.find(c => c.value === community);
|
||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||
return (
|
||
<option key={community} value={community} title={title}>{label}</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>
|
||
<Tooltip content="Анализ и суммаризация IP-диапазонов">
|
||
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
||
<IconDatabase className="icon me-2" />
|
||
Анализ
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
{/* Мета-информация будет показана внизу карточки */}
|
||
{loading ? (
|
||
<TableSkeleton rows={10} cols={3} />
|
||
) : paginatedItems.length === 0 ? (
|
||
<TableEmpty cols={3}>
|
||
<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 card-table 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, idx) => (
|
||
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||
<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">
|
||
{editingIp === item.ipRange ? (
|
||
<>
|
||
<CommunityAutocompleteInput
|
||
value={editingValue}
|
||
onChange={setEditingValue}
|
||
communities={communities}
|
||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||
style={{ width: '150px' }}
|
||
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
||
useShortValue={true}
|
||
/>
|
||
<Tooltip content="Сохранить (Enter)">
|
||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||
</Tooltip>
|
||
<Tooltip content="Отмена (Esc)">
|
||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||
</Tooltip>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Tooltip content="Редактировать" position="top">
|
||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
|
||
</Tooltip>
|
||
<Tooltip content="Копировать" position="top">
|
||
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
|
||
</Tooltip>
|
||
<Tooltip content="Удалить" position="top">
|
||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{/* Пагинация */}
|
||
<Pagination
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
totalItems={totalItems || filtered.length}
|
||
pageSize={pageSize}
|
||
onPageChange={setCurrentPage}
|
||
/>
|
||
<S3MetaBar keys={["ipRanges"]} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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'}
|
||
/>
|
||
<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)}
|
||
/>
|
||
{/* Analyze Settings Modal */}
|
||
{analyzeOpen && (
|
||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||
<div className="modal-content">
|
||
<button type="button" className="btn-close" onClick={() => setAnalyzeOpen(false)}></button>
|
||
<div className="modal-header">
|
||
<h3 className="modal-title">Анализ IP-диапазонов</h3>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="row g-3">
|
||
<div className="col-md-6">
|
||
<label className="form-label">Community</label>
|
||
<select className="form-select" value={analyzeFilters.community} onChange={e => setAnalyzeFilters(f => ({ ...f, community: e.target.value }))}>
|
||
<option value="">Выберите community</option>
|
||
{allCommunities.map(val => (
|
||
<option key={val} value={val}>{val}</option>
|
||
))}
|
||
</select>
|
||
<div className="form-text">Анализ выполняется в рамках одного выбранного community.</div>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<div className="mb-3">
|
||
<label className="form-label">Диапазон масок</label>
|
||
<div className="row g-2 align-items-center">
|
||
<div className="col-auto">
|
||
<span className="form-help">от</span>
|
||
</div>
|
||
<div className="col">
|
||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.minMask}
|
||
onChange={e => {
|
||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||
setAnalyzeFilters(f => ({ ...f, minMask: Math.min(v, f.maxMask) }));
|
||
}} />
|
||
</div>
|
||
<div className="col-auto">
|
||
<span className="form-help">до</span>
|
||
</div>
|
||
<div className="col">
|
||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.maxMask}
|
||
onChange={e => {
|
||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||
setAnalyzeFilters(f => ({ ...f, maxMask: Math.max(v, f.minMask) }));
|
||
}} />
|
||
</div>
|
||
</div>
|
||
<div className="form-text">Учитываются только CIDR внутри выбранного диапазона масок.</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">Тип адресов</label>
|
||
<div>
|
||
<label className="form-check form-check-inline">
|
||
<input className="form-check-input" type="radio" name="anType" value="any" checked={analyzeFilters.type === 'any'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'any' }))} />
|
||
<span className="form-check-label">Любой</span>
|
||
</label>
|
||
<label className="form-check form-check-inline">
|
||
<input className="form-check-input" type="radio" name="anType" value="public" checked={analyzeFilters.type === 'public'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'public' }))} />
|
||
<span className="form-check-label">Публичные</span>
|
||
</label>
|
||
<label className="form-check form-check-inline">
|
||
<input className="form-check-input" type="radio" name="anType" value="private" checked={analyzeFilters.type === 'private'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'private' }))} />
|
||
<span className="form-check-label">Приватные</span>
|
||
</label>
|
||
<label className="form-check form-check-inline">
|
||
<input className="form-check-input" type="radio" name="anType" value="cgnat" checked={analyzeFilters.type === 'cgnat'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'cgnat' }))} />
|
||
<span className="form-check-label">CGNAT</span>
|
||
</label>
|
||
</div>
|
||
<div className="form-text">Определение типа по первому IP в CIDR.</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">Агрессивная агрегация</label>
|
||
<label className="form-check">
|
||
<input className="form-check-input" type="checkbox" checked={analyzeFilters.supernet16} onChange={e => setAnalyzeFilters(f => ({ ...f, supernet16: e.target.checked }))} />
|
||
<span className="form-check-label">Если в одном /16 более 2 префиксов — свернуть в /16</span>
|
||
</label>
|
||
<div className="form-text">Например: 157.240.12.0/23, 157.240.15.0/24, 157.240.16.0/23 → 157.240.0.0/16</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setAnalyzeOpen(false)}>Отмена</button>
|
||
<button className="btn btn-primary" disabled={!analyzeFilters.community} onClick={() => { setAnalyzeOpen(false); setAnalyzeConfirmOpen(true); }}>Предпросмотр</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Analyze Confirm */}
|
||
<ConfirmDialog
|
||
open={analyzeConfirmOpen}
|
||
title={'Подтвердить анализ'}
|
||
message={buildAnalysisConfirmText()}
|
||
confirmText={'Запустить'}
|
||
cancelText={'Отмена'}
|
||
onConfirm={() => { setAnalyzeConfirmOpen(false); runAnalysis(); }}
|
||
onCancel={() => setAnalyzeConfirmOpen(false)}
|
||
/>
|
||
{/* Analysis Results Modal */}
|
||
{analysisResultsOpen && (
|
||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||
<div className="modal-content">
|
||
<button type="button" className="btn-close" onClick={() => setAnalysisResultsOpen(false)}></button>
|
||
<div className="modal-header">
|
||
<h3 className="modal-title">Результаты анализа</h3>
|
||
</div>
|
||
<div className="modal-body">
|
||
{!analysisPreview ? (
|
||
<div className="text-center text-muted">Нет данных по заданным фильтрам</div>
|
||
) : (
|
||
<>
|
||
<div className="mb-2">
|
||
<strong>Community:</strong> {renderCommunityBadge(analysisPreview.community)}
|
||
</div>
|
||
<div className="row g-3 mb-2">
|
||
<div className="col-md-4">
|
||
<div className="card"><div className="card-body">
|
||
<div className="text-muted">Было (всего в community)</div>
|
||
<div className="h2 m-0">{analysisPreview.totalBefore}</div>
|
||
</div></div>
|
||
</div>
|
||
<div className="col-md-4">
|
||
<div className="card"><div className="card-body">
|
||
<div className="text-muted">Обработано (по фильтрам)</div>
|
||
<div className="h2 m-0">{analysisPreview.processedBefore}</div>
|
||
</div></div>
|
||
</div>
|
||
<div className="col-md-4">
|
||
<div className="card"><div className="card-body">
|
||
<div className="text-muted">Станет (после суммаризации)</div>
|
||
<div className="h2 m-0">{analysisPreview.totalAfter}</div>
|
||
</div></div>
|
||
</div>
|
||
</div>
|
||
<div className="row g-3">
|
||
<div className="col-md-6">
|
||
<div className="card">
|
||
<div className="card-header"><h4 className="card-title">Исходные (учтенные по фильтрам) — {analysisPreview.processedBefore}</h4></div>
|
||
<div className="card-body" style={{maxHeight: 220, overflow: 'auto'}}>
|
||
{analysisPreview.beforeCidrs.length === 0 ? (
|
||
<div className="text-muted">—</div>
|
||
) : (
|
||
<ul className="list-unstyled m-0">
|
||
{analysisPreview.beforeCidrs.map(c => (<li key={c}><code>{c}</code></li>))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<div className="card">
|
||
<div className="card-header"><h4 className="card-title">После суммаризации — {analysisPreview.processedAfter}</h4></div>
|
||
<div className="card-body" style={{maxHeight: 220, overflow: 'auto'}}>
|
||
{analysisPreview.afterCidrs.length === 0 ? (
|
||
<div className="text-muted">—</div>
|
||
) : (
|
||
<ul className="list-unstyled m-0">
|
||
{analysisPreview.afterCidrs.map(c => (<li key={c}><code>{c}</code></li>))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="form-text mt-2">Неизменные (вне фильтров): {analysisPreview.unchangedCount}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setAnalysisResultsOpen(false)}>Закрыть</button>
|
||
<button className="btn btn-primary" disabled={!analysisPreview} onClick={() => setOverwriteConfirmOpen(true)}>
|
||
<IconDeviceFloppy className="icon me-2" />Применить изменения
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Overwrite confirm */}
|
||
<ConfirmDialog
|
||
open={overwriteConfirmOpen}
|
||
title={'Перезаписать диапазоны в community?'}
|
||
message={!analysisPreview ? '' : `Community: ${analysisPreview.community}\nБыло: ${analysisPreview.totalBefore}\nСтанет: ${analysisPreview.totalAfter}\nИзменится только выбранное community. Это действие изменит список, но не сохранит его в S3 до нажатия «Сохранить».`}
|
||
confirmText={'Перезаписать'}
|
||
cancelText={'Отмена'}
|
||
onConfirm={applyAnalysisToItems}
|
||
onCancel={() => setOverwriteConfirmOpen(false)}
|
||
/>
|
||
{/* Diff Modal */}
|
||
{showDiff && (
|
||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||
<div className="modal-content">
|
||
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
||
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
||
<div className="modal-body">
|
||
<div className="row g-3">
|
||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default IPRangesManager; |