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

This commit is contained in:
2025-08-26 20:11:01 +07:00
parent 03d24e8746
commit 9edd32526b
+283 -1
View File
@@ -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">