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 [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() {
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Community (мультивыбор)</label>
|
||||
<div className="mb-2">
|
||||
<button className="btn btn-sm btn-outline-primary me-2" onClick={() => setAnalyzeFilters(f => ({ ...f, communities: [...allCommunities] }))}>Выбрать все</button>
|
||||
<button className="btn btn-sm" onClick={() => setAnalyzeFilters(f => ({ ...f, communities: [] }))}>Очистить</button>
|
||||
</div>
|
||||
<div className="card border" style={{maxHeight: 220, overflow: 'auto'}}>
|
||||
<div className="list-group list-group-flush">
|
||||
{allCommunities.length === 0 && (
|
||||
<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>
|
||||
<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">
|
||||
@@ -932,7 +1023,7 @@ function IPRangesManager() {
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<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>
|
||||
@@ -958,52 +1049,87 @@ function IPRangesManager() {
|
||||
<h3 className="modal-title">Результаты анализа</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{analysisResult.length === 0 ? (
|
||||
{!analysisPreview ? (
|
||||
<div className="text-center text-muted">Нет данных по заданным фильтрам</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Community</th>
|
||||
<th>Количество диапазонов</th>
|
||||
<th>Суммарно адресов</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysisResult.map(row => (
|
||||
<tr key={row.community}>
|
||||
<td>{renderCommunityBadge(row.community)}</td>
|
||||
<td>{row.ranges}</td>
|
||||
<td>{formatBigInt(row.totalAddresses)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{(() => {
|
||||
const totalRanges = analysisResult.reduce((acc, r) => acc + r.ranges, 0);
|
||||
const totalAddrs = analysisResult.reduce((acc, r) => acc + r.totalAddresses, 0n);
|
||||
return (
|
||||
<tr>
|
||||
<th>Итого</th>
|
||||
<th>{totalRanges}</th>
|
||||
<th>{formatBigInt(totalAddrs)}</th>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
</tfoot>
|
||||
</table>
|
||||
</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 btn-outline-primary" onClick={exportAnalysisCsv}><IconDownload className="icon me-2" />Экспорт CSV</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>
|
||||
)}
|
||||
{/* 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">
|
||||
|
||||
Reference in New Issue
Block a user