"use client" import { useCallback, useEffect, useState } from "react" import { toast } from "sonner" import { FormField, FormToggle } from "@/components/form-kit" import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" import { Badge } from "@/components/reui/badge" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet" import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow" import type { GeoipStatusDto } from "@mmapp/contracts/geoip" import { generateTrafficFlowKeys, getTrafficFlowHostFiles, getTrafficFlowSettings, purgeTrafficFlowData, putTrafficFlowSettings, } from "@/shared/api/traffic-flow" import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip" import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog" import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react" const HOST_STEPS = [ "На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).", "Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.", "wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).", "Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.", "В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.", "Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.", ] function fmtDate(iso: string | null | undefined): string { return iso ? new Date(iso).toLocaleString("ru-RU") : "—" } function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) { const [status, setStatus] = useState(null) const [busy, setBusy] = useState(false) const [updating, setUpdating] = useState(false) const [autoOn, setAutoOn] = useState(true) const [intervalHours, setIntervalHours] = useState("168") const load = useCallback(async () => { const s = await getGeoipStatus(backendUrl) setStatus(s) setAutoOn(s.settings.enabled) setIntervalHours(String(Math.round(s.settings.updateIntervalSec / 3600))) }, [backendUrl]) useEffect(() => { void load().catch((e: unknown) => { toast.error(e instanceof Error ? e.message : "Не удалось загрузить GeoIP") }) }, [load]) async function handleSave() { setBusy(true) try { const hours = Math.min(720, Math.max(6, Number.parseInt(intervalHours, 10) || 168)) const res = await putGeoipSettings(backendUrl, { enabled: autoOn, updateIntervalSec: hours * 3600, }) setStatus(res.status) toast.success("Настройки GeoIP сохранены") } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось сохранить") } finally { setBusy(false) } } async function handleUpdateNow() { setUpdating(true) try { const res = await runGeoipUpdateNow(backendUrl) if (res.ok || res.snapshot.downloaded > 0 || res.snapshot.skippedUnchanged > 0) { toast.success( res.snapshot.downloaded > 0 ? `Скачано баз: ${res.snapshot.downloaded} (${(res.snapshot.bytes / 1024 / 1024).toFixed(1)} МБ)` : "Базы актуальны, скачивание не требуется", ) } else { toast.error(res.snapshot.errors.join("; ") || "Обновление не выполнено") } await load() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось обновить базы") } finally { setUpdating(false) } } return ( country {status?.countryLoaded ? "ok" : "нет"} asn {status?.asnLoaded ? "ok" : "нет"} } contentClassName="px-5 py-4 flex flex-col gap-4" >
Автообновление
setIntervalHours(e.target.value)} inputMode="numeric" disabled={!autoOn} />

Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)} {" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)} {status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""}

Каталог: {status?.dir || "storage/geoip"}. До загрузки баз и при промахе lookup страна/ASN берутся из RIPEstat, как раньше.

Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.

) } function NetflowSettingsPanel({ backendUrl, enabled, }: { backendUrl: string enabled: boolean }) { const [settings, setSettings] = useState(null) const [busy, setBusy] = useState(false) const [exportOpen, setExportOpen] = useState(false) const [formats, setFormats] = useState([]) const [collectorIp, setCollectorIp] = useState("10.255.254.1") const [flowPort, setFlowPort] = useState("4739") const [wgPort, setWgPort] = useState("51821") const [prefix, setPrefix] = useState("10.255.254.0/24") const [endpoint, setEndpoint] = useState("") const [retention, setRetention] = useState("24") const [topN, setTopN] = useState("200") const [shareOn, setShareOn] = useState(true) const [sharePct, setSharePct] = useState("5") const [ingestOn, setIngestOn] = useState(false) const [purgeOpen, setPurgeOpen] = useState(false) const [purgeBusy, setPurgeBusy] = useState(false) const load = useCallback(async () => { if (!enabled) return const s = await getTrafficFlowSettings(backendUrl) setSettings(s) setCollectorIp(s.collectorIp) setFlowPort(String(s.flowListenPort)) setWgPort(String(s.wgListenPort)) setPrefix(s.prefix) setEndpoint(s.publicEndpoint) setRetention(String(s.retentionHours)) setTopN(String(s.topN)) const pct = Number(s.mapServiceMinSharePct ?? 5) setShareOn(pct > 0) setSharePct(String(pct > 0 ? pct : 5)) setIngestOn(s.enabled) }, [backendUrl, enabled]) useEffect(() => { void load().catch((e: unknown) => { toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow") }) }, [load]) async function handleSave() { setBusy(true) try { const res = await putTrafficFlowSettings(backendUrl, { enabled: ingestOn, collectorIp, flowListenPort: Number.parseInt(flowPort, 10) || 4739, wgListenPort: Number.parseInt(wgPort, 10) || 51821, prefix, publicEndpoint: endpoint, retentionHours: Number.parseInt(retention, 10) || 24, topN: Number.parseInt(topN, 10) || 200, mapServiceMinSharePct: shareOn ? Math.min(100, Math.max(1, Number.parseFloat(sharePct) || 5)) : 0, }) setSettings(res.settings) toast.success("Настройки NetFlow сохранены") } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось сохранить") } finally { setBusy(false) } } async function handleKeys() { setBusy(true) try { const res = await generateTrafficFlowKeys(backendUrl) setSettings(res.settings) toast.success(res.created ? "Ключи хоста созданы" : "Ключи уже есть") } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось сгенерировать ключи") } finally { setBusy(false) } } async function handlePurgeConfirm() { setPurgeBusy(true) try { const result = await purgeTrafficFlowData(backendUrl) toast.success(formatFlowPurgeResult(result)) setPurgeOpen(false) await load() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow") } finally { setPurgeBusy(false) } } async function handleExport() { setBusy(true) try { const res = await getTrafficFlowHostFiles(backendUrl) setFormats(res.files.map((f) => ({ id: f.id, label: f.label, filename: f.filename, code: f.code, }))) setExportOpen(true) await load() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось получить файлы") } finally { setBusy(false) } } return ( <> {settings?.listenerBound ? ( listener {settings.listenerAddress} ) : ( listener выкл )} } contentClassName="px-5 py-4 flex flex-col gap-4" > Ключи и UDP 4739 Приватный ключ хранится в PostgreSQL панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
Принимать IPFIX
setCollectorIp(e.target.value)} /> setPrefix(e.target.value)} /> setFlowPort(e.target.value)} /> setWgPort(e.target.value)} /> setEndpoint(e.target.value)} placeholder="203.0.113.10" /> setRetention(e.target.value)} inputMode="numeric" /> setTopN(e.target.value)} inputMode="numeric" />
{ setShareOn(on) if (on && (!sharePct || sharePct === "0")) setSharePct("5") }} /> Порог доли на карте
setSharePct(e.target.value)} inputMode="decimal" disabled={!shareOn} />

Last datagram:{" "} {settings?.lastDatagramAt ? new Date(settings.lastDatagramAt).toLocaleString("ru-RU") : "—"} {settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""} {settings?.lastError ? ` · ${settings.lastError}` : ""}

Туннель на сервере Docker MM

    {HOST_STEPS.map((s) => (
  1. {s}
  2. ))}

Сбросить данные NetFlow

Удалит сессии и агрегаты в PostgreSQL. Ключи WG и пиры не трогает.

setExportOpen(false)} title="Файлы для хоста Docker MM" description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend." formats={formats} /> { void handlePurgeConfirm() }} onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }} /> ) } export { NetflowSettingsPanel }