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 [wsOpen, setWsOpen] = useState(false);
|
||||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = 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 handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
||||||
@@ -377,6 +384,105 @@ function IPRangesManager() {
|
|||||||
if (e.key === 'Escape') handleCancelEdit();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -542,7 +648,12 @@ function IPRangesManager() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
<div>
|
||||||
|
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
||||||
|
<IconDatabase className="icon me-2" />
|
||||||
|
Анализ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Мета-информация будет показана внизу карточки */}
|
{/* Мета-информация будет показана внизу карточки */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -722,6 +833,177 @@ function IPRangesManager() {
|
|||||||
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
|
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
|
||||||
onCancel={() => 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 */}
|
{/* 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