diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index 4b24696..d0c6811 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -222,6 +222,13 @@ function IPRangesManager() { const [wsOpen, setWsOpen] = useState(false); const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false); + // Аналитика + const [analyzeOpen, setAnalyzeOpen] = useState(false); + const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false); + const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false); + const [analysisResult, setAnalysisResult] = useState([]); + const [analyzeFilters, setAnalyzeFilters] = useState({ communities: [], minMask: 0, maxMask: 32, type: 'any' }); + const handlePreviewDiff = () => { const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); @@ -377,6 +384,105 @@ function IPRangesManager() { 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 = []; + const cCount = analyzeFilters.communities.length; + parts.push(`Community: ${cCount ? cCount + ' выбрано' : 'все'}`); + parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`); + const typeLabel = analyzeFilters.type === 'any' ? 'любой' : (analyzeFilters.type === 'private' ? 'только приватные' : (analyzeFilters.type === 'public' ? 'только публичные' : 'только CGNAT')); + parts.push(`Тип: ${typeLabel}`); + return parts.join('\n'); + }; + + const runAnalysis = () => { + // Фильтрация + const selectedSet = new Set(analyzeFilters.communities); + const filtered = items.filter(i => { + if (!isValidCidr(i.ipRange)) return false; + const mask = parseMask(i.ipRange); + if (mask < analyzeFilters.minMask || mask > analyzeFilters.maxMask) return false; + if (selectedSet.size && !selectedSet.has(i.community)) return false; + const cat = getIpCategory(i.ipRange); + if (analyzeFilters.type !== 'any' && cat !== analyzeFilters.type) return false; + return true; + }); + + // Агрегация по community + const agg = new Map(); + for (const it of filtered) { + const mask = parseMask(it.ipRange); + const addr = countAddresses(mask); + const key = String(it.community); + const prev = agg.get(key) || { community: key, ranges: 0, totalAddresses: 0n }; + prev.ranges += 1; + prev.totalAddresses = prev.totalAddresses + addr; + agg.set(key, prev); + } + const rows = Array.from(agg.values()).sort((a, b) => String(a.community).localeCompare(String(b.community))); + setAnalysisResult(rows); + setAnalysisResultsOpen(true); + }; + + 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 && ( @@ -542,7 +648,12 @@ function IPRangesManager() { /> -
+
+ +
{/* Мета-информация будет показана внизу карточки */} {loading ? ( @@ -722,6 +833,177 @@ function IPRangesManager() { onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }} onCancel={() => setClearCommunitiesOpen(false)} /> + {/* Analyze Settings Modal */} + {analyzeOpen && ( +
+
+
+ +
+

Анализ IP-диапазонов

+
+
+
+
+ +
+ + +
+
+
+ {allCommunities.length === 0 && ( +
Нет данных community
+ )} + {allCommunities.map(val => { + const checked = analyzeFilters.communities.includes(val); + return ( + + ); + })} +
+
+
+
+
+ +
+
+ от +
+
+ { + const v = Math.max(0, Math.min(32, Number(e.target.value))); + setAnalyzeFilters(f => ({ ...f, minMask: Math.min(v, f.maxMask) })); + }} /> +
+
+ до +
+
+ { + const v = Math.max(0, Math.min(32, Number(e.target.value))); + setAnalyzeFilters(f => ({ ...f, maxMask: Math.max(v, f.minMask) })); + }} /> +
+
+
Учитываются только CIDR внутри выбранного диапазона масок.
+
+
+ +
+ + + + +
+
Определение типа по первому IP в CIDR.
+
+
+
+
+
+ + +
+
+
+
+ )} + {/* Analyze Confirm */} + { setAnalyzeConfirmOpen(false); runAnalysis(); }} + onCancel={() => setAnalyzeConfirmOpen(false)} + /> + {/* Analysis Results Modal */} + {analysisResultsOpen && ( +
+
+
+ +
+

Результаты анализа

+
+
+ {analysisResult.length === 0 ? ( +
Нет данных по заданным фильтрам
+ ) : ( +
+ + + + + + + + + + {analysisResult.map(row => ( + + + + + + ))} + + + {(() => { + const totalRanges = analysisResult.reduce((acc, r) => acc + r.ranges, 0); + const totalAddrs = analysisResult.reduce((acc, r) => acc + r.totalAddresses, 0n); + return ( + + + + + + ); + })()} + +
CommunityКоличество диапазоновСуммарно адресов
{renderCommunityBadge(row.community)}{row.ranges}{formatBigInt(row.totalAddresses)}
Итого{totalRanges}{formatBigInt(totalAddrs)}
+
+ )} +
+
+ + +
+
+
+
+ )} {/* Diff Modal */} {showDiff && (