From 213dc4212462adcc9100d9ed3e0afe1f4ba7a284 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 26 Aug 2025 20:20:09 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=20IP-=D0=B4=D0=B8=D0=B0=D0=BF?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=BD=D0=BE=D0=B2,=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=B2=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D1=81=D1=82=D0=B2=D0=B0;=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=BE=D1=89=D1=8C=D1=8E=20=D1=81=D0=B5=D0=BB=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=20community=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/IPRangesManager.jsx | 306 ++++++++++++++++++++++--------- 1 file changed, 216 insertions(+), 90 deletions(-) diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index d0c6811..2f80ca8 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -226,8 +226,9 @@ function IPRangesManager() { 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 [analysisPreview, setAnalysisPreview] = useState(null); + const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false); + const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any' }); const handlePreviewDiff = () => { const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) @@ -432,43 +433,157 @@ function IPRangesManager() { const buildAnalysisConfirmText = () => { const parts = []; - const cCount = analyzeFilters.communities.length; - parts.push(`Community: ${cCount ? cCount + ' выбрано' : 'все'}`); + 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}`); 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; + }; + const runAnalysis = () => { - // Фильтрация - const selectedSet = new Set(analyzeFilters.communities); - const filtered = items.filter(i => { - if (!isValidCidr(i.ipRange)) return false; + 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; - 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; }); + const unchanged = allForCommunity.filter(i => !filtered.includes(i)); - // Агрегация по 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); + const beforeCidrs = filtered.map(i => i.ipRange); + const afterCidrs = summarizeCidrs(beforeCidrs); + 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()])] @@ -845,38 +960,14 @@ function IPRangesManager() {
- -
- - -
-
-
- {allCommunities.length === 0 && ( -
Нет данных community
- )} - {allCommunities.map(val => { - const checked = analyzeFilters.communities.includes(val); - return ( - - ); - })} -
-
+ + +
Анализ выполняется в рамках одного выбранного community.
@@ -932,7 +1023,7 @@ function IPRangesManager() {
- +
@@ -958,52 +1049,87 @@ function IPRangesManager() {

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

- {analysisResult.length === 0 ? ( + {!analysisPreview ? (
Нет данных по заданным фильтрам
) : ( -
- - - - - - - - - - {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)}
-
+ <> +
+ Community: {renderCommunityBadge(analysisPreview.community)} +
+
+
+
+
Было (всего в community)
+
{analysisPreview.totalBefore}
+
+
+
+
+
Обработано (по фильтрам)
+
{analysisPreview.processedBefore}
+
+
+
+
+
Станет (после суммаризации)
+
{analysisPreview.totalAfter}
+
+
+
+
+
+
+

Исходные (учтенные по фильтрам) — {analysisPreview.processedBefore}

+
+ {analysisPreview.beforeCidrs.length === 0 ? ( +
+ ) : ( +
    + {analysisPreview.beforeCidrs.map(c => (
  • {c}
  • ))} +
+ )} +
+
+
+
+
+

После суммаризации — {analysisPreview.processedAfter}

+
+ {analysisPreview.afterCidrs.length === 0 ? ( +
+ ) : ( +
    + {analysisPreview.afterCidrs.map(c => (
  • {c}
  • ))} +
+ )} +
+
+
+
+
Неизменные (вне фильтров): {analysisPreview.unchangedCount}
+ )}
- +
)} + {/* Overwrite confirm */} + setOverwriteConfirmOpen(false)} + /> {/* Diff Modal */} {showDiff && (