feat: Обновить функциональность анализа IP-диапазонов, добавив предварительный просмотр результатов и возможность выбора сообщества; улучшить интерфейс с помощью селектора для выбора community и модального окна для подтверждения перезаписи диапазонов
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s

This commit is contained in:
2025-08-26 20:20:09 +07:00
parent 9edd32526b
commit 213dc42124
+216 -90
View File
@@ -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">