feat: Обновить функциональность анализа IP-диапазонов, добавив предварительный просмотр результатов и возможность выбора сообщества; улучшить интерфейс с помощью селектора для выбора community и модального окна для подтверждения перезаписи диапазонов
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
This commit is contained in:
@@ -226,8 +226,9 @@ function IPRangesManager() {
|
|||||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
||||||
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
|
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
|
||||||
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
||||||
const [analysisResult, setAnalysisResult] = useState([]);
|
const [analysisPreview, setAnalysisPreview] = useState(null);
|
||||||
const [analyzeFilters, setAnalyzeFilters] = useState({ communities: [], minMask: 0, maxMask: 32, type: 'any' });
|
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
||||||
|
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any' });
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -432,43 +433,157 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const buildAnalysisConfirmText = () => {
|
const buildAnalysisConfirmText = () => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const cCount = analyzeFilters.communities.length;
|
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
||||||
parts.push(`Community: ${cCount ? cCount + ' выбрано' : 'все'}`);
|
|
||||||
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
||||||
const typeLabel = analyzeFilters.type === 'any' ? 'любой' : (analyzeFilters.type === 'private' ? 'только приватные' : (analyzeFilters.type === 'public' ? 'только публичные' : 'только CGNAT'));
|
const typeLabel = analyzeFilters.type === 'any' ? 'любой' : (analyzeFilters.type === 'private' ? 'только приватные' : (analyzeFilters.type === 'public' ? 'только публичные' : 'только CGNAT'));
|
||||||
parts.push(`Тип: ${typeLabel}`);
|
parts.push(`Тип: ${typeLabel}`);
|
||||||
return parts.join('\n');
|
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 runAnalysis = () => {
|
||||||
// Фильтрация
|
const community = String(analyzeFilters.community || '').trim();
|
||||||
const selectedSet = new Set(analyzeFilters.communities);
|
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
||||||
const filtered = items.filter(i => {
|
// исходные для community
|
||||||
if (!isValidCidr(i.ipRange)) return false;
|
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);
|
const mask = parseMask(i.ipRange);
|
||||||
if (mask < analyzeFilters.minMask || mask > analyzeFilters.maxMask) return false;
|
if (mask < analyzeFilters.minMask || mask > analyzeFilters.maxMask) return false;
|
||||||
if (selectedSet.size && !selectedSet.has(i.community)) return false;
|
|
||||||
const cat = getIpCategory(i.ipRange);
|
const cat = getIpCategory(i.ipRange);
|
||||||
if (analyzeFilters.type !== 'any' && cat !== analyzeFilters.type) return false;
|
if (analyzeFilters.type !== 'any' && cat !== analyzeFilters.type) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
const unchanged = allForCommunity.filter(i => !filtered.includes(i));
|
||||||
|
|
||||||
// Агрегация по community
|
const beforeCidrs = filtered.map(i => i.ipRange);
|
||||||
const agg = new Map();
|
const afterCidrs = summarizeCidrs(beforeCidrs);
|
||||||
for (const it of filtered) {
|
const processedBefore = beforeCidrs.length;
|
||||||
const mask = parseMask(it.ipRange);
|
const processedAfter = afterCidrs.length;
|
||||||
const addr = countAddresses(mask);
|
const totalAfter = processedAfter + unchanged.length;
|
||||||
const key = String(it.community);
|
|
||||||
const prev = agg.get(key) || { community: key, ranges: 0, totalAddresses: 0n };
|
setAnalysisPreview({
|
||||||
prev.ranges += 1;
|
community,
|
||||||
prev.totalAddresses = prev.totalAddresses + addr;
|
totalBefore,
|
||||||
agg.set(key, prev);
|
totalAfter,
|
||||||
}
|
processedBefore,
|
||||||
const rows = Array.from(agg.values()).sort((a, b) => String(a.community).localeCompare(String(b.community)));
|
processedAfter,
|
||||||
setAnalysisResult(rows);
|
unchangedCount: unchanged.length,
|
||||||
|
beforeCidrs,
|
||||||
|
afterCidrs,
|
||||||
|
unchangedCidrs: unchanged.map(i => i.ipRange)
|
||||||
|
});
|
||||||
setAnalysisResultsOpen(true);
|
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 exportAnalysisCsv = () => {
|
||||||
const header = ['community', 'ranges', 'totalAddresses'];
|
const header = ['community', 'ranges', 'totalAddresses'];
|
||||||
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
|
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
|
||||||
@@ -845,38 +960,14 @@ function IPRangesManager() {
|
|||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<label className="form-label">Community (мультивыбор)</label>
|
<label className="form-label">Community</label>
|
||||||
<div className="mb-2">
|
<select className="form-select" value={analyzeFilters.community} onChange={e => setAnalyzeFilters(f => ({ ...f, community: e.target.value }))}>
|
||||||
<button className="btn btn-sm btn-outline-primary me-2" onClick={() => setAnalyzeFilters(f => ({ ...f, communities: [...allCommunities] }))}>Выбрать все</button>
|
<option value="">Выберите community</option>
|
||||||
<button className="btn btn-sm" onClick={() => setAnalyzeFilters(f => ({ ...f, communities: [] }))}>Очистить</button>
|
{allCommunities.map(val => (
|
||||||
</div>
|
<option key={val} value={val}>{val}</option>
|
||||||
<div className="card border" style={{maxHeight: 220, overflow: 'auto'}}>
|
))}
|
||||||
<div className="list-group list-group-flush">
|
</select>
|
||||||
{allCommunities.length === 0 && (
|
<div className="form-text">Анализ выполняется в рамках одного выбранного community.</div>
|
||||||
<div className="list-group-item text-muted">Нет данных community</div>
|
|
||||||
)}
|
|
||||||
{allCommunities.map(val => {
|
|
||||||
const checked = analyzeFilters.communities.includes(val);
|
|
||||||
return (
|
|
||||||
<label key={val} className="list-group-item d-flex align-items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input me-2"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => {
|
|
||||||
setAnalyzeFilters(f => {
|
|
||||||
const set = new Set(f.communities);
|
|
||||||
if (e.target.checked) set.add(val); else set.delete(val);
|
|
||||||
return { ...f, communities: Array.from(set) };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{val || '—'}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -932,7 +1023,7 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="btn" onClick={() => setAnalyzeOpen(false)}>Отмена</button>
|
<button className="btn" onClick={() => setAnalyzeOpen(false)}>Отмена</button>
|
||||||
<button className="btn btn-primary" onClick={() => { setAnalyzeOpen(false); setAnalyzeConfirmOpen(true); }}>Продолжить</button>
|
<button className="btn btn-primary" disabled={!analyzeFilters.community} onClick={() => { setAnalyzeOpen(false); setAnalyzeConfirmOpen(true); }}>Предпросмотр</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -958,52 +1049,87 @@ function IPRangesManager() {
|
|||||||
<h3 className="modal-title">Результаты анализа</h3>
|
<h3 className="modal-title">Результаты анализа</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{analysisResult.length === 0 ? (
|
{!analysisPreview ? (
|
||||||
<div className="text-center text-muted">Нет данных по заданным фильтрам</div>
|
<div className="text-center text-muted">Нет данных по заданным фильтрам</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<>
|
||||||
<table className="table table-vcenter">
|
<div className="mb-2">
|
||||||
<thead>
|
<strong>Community:</strong> {renderCommunityBadge(analysisPreview.community)}
|
||||||
<tr>
|
</div>
|
||||||
<th>Community</th>
|
<div className="row g-3 mb-2">
|
||||||
<th>Количество диапазонов</th>
|
<div className="col-md-4">
|
||||||
<th>Суммарно адресов</th>
|
<div className="card"><div className="card-body">
|
||||||
</tr>
|
<div className="text-muted">Было (всего в community)</div>
|
||||||
</thead>
|
<div className="h2 m-0">{analysisPreview.totalBefore}</div>
|
||||||
<tbody>
|
</div></div>
|
||||||
{analysisResult.map(row => (
|
</div>
|
||||||
<tr key={row.community}>
|
<div className="col-md-4">
|
||||||
<td>{renderCommunityBadge(row.community)}</td>
|
<div className="card"><div className="card-body">
|
||||||
<td>{row.ranges}</td>
|
<div className="text-muted">Обработано (по фильтрам)</div>
|
||||||
<td>{formatBigInt(row.totalAddresses)}</td>
|
<div className="h2 m-0">{analysisPreview.processedBefore}</div>
|
||||||
</tr>
|
</div></div>
|
||||||
))}
|
</div>
|
||||||
</tbody>
|
<div className="col-md-4">
|
||||||
<tfoot>
|
<div className="card"><div className="card-body">
|
||||||
{(() => {
|
<div className="text-muted">Станет (после суммаризации)</div>
|
||||||
const totalRanges = analysisResult.reduce((acc, r) => acc + r.ranges, 0);
|
<div className="h2 m-0">{analysisPreview.totalAfter}</div>
|
||||||
const totalAddrs = analysisResult.reduce((acc, r) => acc + r.totalAddresses, 0n);
|
</div></div>
|
||||||
return (
|
</div>
|
||||||
<tr>
|
</div>
|
||||||
<th>Итого</th>
|
<div className="row g-3">
|
||||||
<th>{totalRanges}</th>
|
<div className="col-md-6">
|
||||||
<th>{formatBigInt(totalAddrs)}</th>
|
<div className="card">
|
||||||
</tr>
|
<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 ? (
|
||||||
</tfoot>
|
<div className="text-muted">—</div>
|
||||||
</table>
|
) : (
|
||||||
</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>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="btn btn-outline-primary" onClick={exportAnalysisCsv}><IconDownload className="icon me-2" />Экспорт CSV</button>
|
|
||||||
<button className="btn" onClick={() => setAnalysisResultsOpen(false)}>Закрыть</button>
|
<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>
|
||||||
</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 */}
|
{/* Diff Modal */}
|
||||||
{showDiff && (
|
{showDiff && (
|
||||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user