feat: Добавить функциональность анализа IP-диапазонов с фильтрацией по сообществам, маскам и типам адресов; интегрировать модальные окна для настройки и отображения результатов анализа
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
This commit is contained in:
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
||||
<IconDatabase className="icon me-2" />
|
||||
Анализ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{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 && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setAnalyzeOpen(false)}></button>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Анализ IP-диапазонов</h3>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Диапазон масок</label>
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col-auto">
|
||||
<span className="form-help">от</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.minMask}
|
||||
onChange={e => {
|
||||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||||
setAnalyzeFilters(f => ({ ...f, minMask: Math.min(v, f.maxMask) }));
|
||||
}} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<span className="form-help">до</span>
|
||||
</div>
|
||||
<div className="col">
|
||||
<input type="number" className="form-control" min={0} max={32} value={analyzeFilters.maxMask}
|
||||
onChange={e => {
|
||||
const v = Math.max(0, Math.min(32, Number(e.target.value)));
|
||||
setAnalyzeFilters(f => ({ ...f, maxMask: Math.max(v, f.minMask) }));
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-text">Учитываются только CIDR внутри выбранного диапазона масок.</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Тип адресов</label>
|
||||
<div>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="any" checked={analyzeFilters.type === 'any'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'any' }))} />
|
||||
<span className="form-check-label">Любой</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="public" checked={analyzeFilters.type === 'public'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'public' }))} />
|
||||
<span className="form-check-label">Публичные</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="private" checked={analyzeFilters.type === 'private'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'private' }))} />
|
||||
<span className="form-check-label">Приватные</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input className="form-check-input" type="radio" name="anType" value="cgnat" checked={analyzeFilters.type === 'cgnat'} onChange={() => setAnalyzeFilters(f => ({ ...f, type: 'cgnat' }))} />
|
||||
<span className="form-check-label">CGNAT</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-text">Определение типа по первому IP в CIDR.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={() => setAnalyzeOpen(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={() => { setAnalyzeOpen(false); setAnalyzeConfirmOpen(true); }}>Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Analyze Confirm */}
|
||||
<ConfirmDialog
|
||||
open={analyzeConfirmOpen}
|
||||
title={'Подтвердить анализ'}
|
||||
message={buildAnalysisConfirmText()}
|
||||
confirmText={'Запустить'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={() => { setAnalyzeConfirmOpen(false); runAnalysis(); }}
|
||||
onCancel={() => setAnalyzeConfirmOpen(false)}
|
||||
/>
|
||||
{/* Analysis Results Modal */}
|
||||
{analysisResultsOpen && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setAnalysisResultsOpen(false)}></button>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Результаты анализа</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{analysisResult.length === 0 ? (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
|
||||
Reference in New Issue
Block a user