feat(MikrotikConfigRoutes, FirewallPage): add apply-summary endpoint and frontend analysis feature for address lists
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s

This commit is contained in:
2026-02-21 19:01:28 +07:00
parent 4ad5821b5e
commit 8e8ce7cec0
3 changed files with 265 additions and 1 deletions
+193
View File
@@ -7,6 +7,8 @@ import ErrorAlert from './components/ErrorAlert.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import Pagination from './components/Pagination.jsx';
import Tooltip from './components/Tooltip.jsx';
import Modal from './components/Modal.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import {
IconShield,
IconRefresh,
@@ -14,9 +16,59 @@ import {
IconCopy,
IconAlertCircle,
IconList,
IconChartBar,
} from '@tabler/icons-react';
const PAGE_SIZE = 15;
const DEFAULT_MIN_COUNT_N = 5;
/** Одиночный IP: без маски или /32 */
function isSingleIp(addr) {
const s = String(addr || '').trim();
if (!s) return false;
if (!s.includes('/')) return true;
const mask = s.split('/')[1];
return mask === '32' || mask === '128';
}
/** Базовая подсеть /24 для группировки */
function getBase24(addr) {
const s = String(addr || '').trim();
const ipPart = s.includes('/') ? s.split('/')[0] : s;
const parts = ipPart.split('.');
if (parts.length !== 4) return null;
const [a, b, c] = parts.map((x) => parseInt(x, 10));
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
return `${a}.${b}.${c}.0/24`;
}
/**
* Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N.
*/
function analyzeBanList(banEntries, minCountN) {
const groupBy = new Map();
for (const entry of banEntries || []) {
if (!entry?.address || !isSingleIp(entry.address)) continue;
const base = getBase24(entry.address);
if (!base) continue;
if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] });
const g = groupBy.get(base);
g.ids.push(entry.id);
g.entries.push(entry);
}
const suggestions = [];
for (const [, g] of groupBy) {
if (g.entries.length > minCountN) {
suggestions.push({
subnet: g.subnet,
count: g.entries.length,
ids: g.ids,
addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` },
});
}
}
return { suggestions };
}
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
function normalizeEntry(entry) {
@@ -172,6 +224,11 @@ export default function FirewallPage() {
const [loading, setLoading] = useState(false);
const [loadingServers, setLoadingServers] = useState(true);
const [error, setError] = useState('');
const [analysisOpen, setAnalysisOpen] = useState(false);
const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N);
const [analysisResult, setAnalysisResult] = useState(null);
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false);
const [applying, setApplying] = useState(false);
const routerServers = useMemo(
() =>
@@ -227,6 +284,37 @@ export default function FirewallPage() {
else setData(null);
}, [serverId]);
const runAnalysis = () => {
if (!data?.ban?.length) return;
const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N);
setMinCountN(n);
const result = analyzeBanList(data.ban, n);
setAnalysisResult(result);
};
const handleApplySummary = async () => {
if (!serverId || !analysisResult?.suggestions?.length) return;
setApplying(true);
try {
const removeIds = analysisResult.suggestions.flatMap((s) => s.ids);
const addEntries = analysisResult.suggestions.map((s) => s.addEntry);
await api.post('/mikrotik/address-lists/apply-summary', {
serverId,
removeIds,
addEntries,
});
window.notify?.success?.(`Удалено ${removeIds.length} записей, добавлено ${addEntries.length} подсетей.`);
setApplyConfirmOpen(false);
setAnalysisOpen(false);
setAnalysisResult(null);
await fetchAddressLists();
} catch (e) {
window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения');
} finally {
setApplying(false);
}
};
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
return (
@@ -241,6 +329,19 @@ export default function FirewallPage() {
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={() => {
setAnalysisOpen(true);
setAnalysisResult(null);
}}
disabled={!serverId || !data?.ban?.length || loading}
title="Анализ списка ban и суммаризация по /24"
>
<IconChartBar className="me-1" size={18} />
Анализ
</button>
<button
type="button"
className="btn btn-outline-primary"
@@ -314,6 +415,98 @@ export default function FirewallPage() {
</div>
</div>
)}
<Modal
show={analysisOpen}
onClose={() => {
setAnalysisOpen(false);
setAnalysisResult(null);
setApplyConfirmOpen(false);
}}
title="Анализ списка ban"
size="lg"
>
<div className="mb-3">
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
<div className="d-flex align-items-center gap-2">
<input
type="number"
className="form-control"
style={{ width: '80px' }}
min={2}
max={100}
value={minCountN}
onChange={(e) => setMinCountN(e.target.value)}
/>
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
</div>
</div>
<div className="mb-3">
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
<IconChartBar className="me-1" size={18} />
Анализировать
</button>
</div>
{analysisResult && (
<>
{analysisResult.suggestions.length === 0 ? (
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
) : (
<>
<p className="mb-2">
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
</p>
<div className="table-responsive">
<table className="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>Подсеть</th>
<th className="text-end">Кол-во IP</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{analysisResult.suggestions.map((s) => (
<tr key={s.subnet}>
<td className="font-monospace">{s.subnet}</td>
<td className="text-end">{s.count}</td>
<td className="text-muted small">Удалить {s.count} записей добавить 1</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3">
<button
type="button"
className="btn btn-warning"
onClick={() => setApplyConfirmOpen(true)}
>
Применить на роутере
</button>
</div>
</>
)}
</>
)}
</Modal>
<ConfirmDialog
open={applyConfirmOpen}
title="Применить суммаризацию на роутере?"
message={
analysisResult?.suggestions?.length
? `Будет удалено ${analysisResult.suggestions.reduce((acc, s) => acc + s.ids.length, 0)} записей и добавлено ${analysisResult.suggestions.length} записей (подсети /24). Продолжить?`
: ''
}
confirmText="Применить"
cancelText="Отмена"
destructive
size="md"
loading={applying}
onConfirm={handleApplySummary}
onCancel={() => setApplyConfirmOpen(false)}
/>
</div>
);
}