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

1209 lines
54 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 {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText
} from '@tabler/icons-react';
import TableSkeleton from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
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;
// Справочник 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 v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue"></span>;
const meta = communities.find(c => c.value === v);
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
};
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(() => {
// Acquire soft lock
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
localStorage.setItem('uiOwner', owner);
const resource = 'ip-ranges';
const acquire = async () => {
try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {}
};
acquire();
const interval = setInterval(acquire, 60_000);
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
return () => {
clearInterval(interval);
api.delete(`/locks/${resource}`).catch(() => {});
};
}, []);
const fetchItems = async () => {
setLoading(true);
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/ip-ranges`, { params: { q: effectiveQ, offset: Math.max(0, (currentPage - 1) * pageSize), limit: pageSize, format: 'std' } });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const total = Number(response.data?.total ?? (Array.isArray(response.data) ? response.data.length : 0));
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(); return; }
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => { fetchItems(); }, 350);
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchTerm, filterCommunity]);
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));
};
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 => 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 () => {
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);
setDiff(computeDiff(originalItems, unique));
setConfirmSaveOpen(true);
};
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 response = await api.post(`/ip-ranges`, { ipRanges: unique, etag }, { 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 || etag);
setOriginalItems(unique);
setItems(unique);
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
const handleImport = () => {
const text = window.prompt('Вставьте строки: CIDR ПРОБЕЛ COMMUNITY (по одной записи на строку)');
if (!text) return;
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 => 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 handleExport = () => {
const header = ['ipRange', 'community'];
const csv = [header, ...items.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);
};
// 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 => 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.ipRange || i.community)
.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
);
};
// Сортировка
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;
});
// Фильтрация по community
const filteredByCommunity = filterCommunity
? sortedItems.filter(i => i.community === filterCommunity)
: sortedItems;
// Пагинация теперь серверная
const paginatedItems = filteredByCommunity;
const totalPages = Math.max(1, Math.ceil((totalItems || paginatedItems.length) / 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.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.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>
)}
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">IP-диапазоны</h2>
<div className="page-pretitle">Главная / Данные / IP ranges</div>
</div>
<div className="col-auto ms-auto d-print-none">
<PageHeaderActions
loading={loading}
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>
</div>
</div>
<div className="row">
<div className="col-lg-3">
{/* Add New Item Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<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">IP-диапазон</label>
<input
type="text"
className={`form-control${newInvalid.ipRange ? ' is-invalid' : ''}`}
placeholder="192.168.1.0/24"
value={newItem.ipRange}
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
/>
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
<div className="form-text">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
</div>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
/>
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
{(() => {
const match = communities.find(c => c.value === String(newItem.community).trim());
if (!match) return null;
return (
<div className="form-text">
{match.name && (<><strong>{match.name}</strong> </>)}
{match.description || ''}
{match.tags && match.tags.length > 0 && (
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
)}
</div>
);
})()}
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100">
<IconPlus className="icon me-2" />
Добавить
</button>
</div>
</form>
</div>
</div>
{/* Drag & Drop импорт */}
<div
className="card card-md border-dashed"
onDragOver={(e) => { e.preventDefault(); }}
onDrop={onDropImport}
>
<div className="card-body text-center text-muted">
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<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 w-50">
{/* Фильтр по community */}
<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>
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
<IconDatabase className="icon me-2" />
Анализ
</button>
</div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={3} />
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th className="cursor-pointer" onClick={() => handleSort('ipRange')}>
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-end"> </th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item) => (
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
<td className="font-monospace" title="CIDR">{item.ipRange}</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 w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
/>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
</div>
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(1)} tabIndex={-1} disabled={currentPage === 1}>Первая</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>Назад</button>
</li>
{(() => {
const pages = [];
const maxToShow = 7; // 1 .. 5 .. N
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, currentPage + 2);
if (currentPage <= 3) { end = Math.min(totalPages, 5); }
if (currentPage >= totalPages - 2) { start = Math.max(1, totalPages - 4); }
if (start > 1) pages.push('start-ellipsis');
for (let p = start; p <= end; p++) pages.push(p);
if (end < totalPages) pages.push('end-ellipsis');
const renderItem = (p) => {
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
return (
<li key={p} className="page-item disabled"><span className="page-link"></span></li>
);
}
return (
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
</li>
);
};
return pages.map(renderItem);
})()}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>Вперед</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(totalPages)} tabIndex={-1} disabled={currentPage === totalPages}>Последняя</button>
</li>
</ul>
</div>
)}
<S3MetaBar keys={["ipRanges"]} />
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content">
<button
type="button"
className="btn-close"
onClick={() => setShowDeleteModal(false)}
></button>
<div className="modal-status bg-danger"></div>
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить IP-диапазон?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.ipRange}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<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.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;