"use client" import { useCallback, useEffect, useRef, useState } from "react" import { can } from "@/lib/auth" import { emptyNetflowDraft, netflowDraftFromSettings, netflowDraftPatch, type NetflowDraft } from "@/shared/api/netflow-settings-form" 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 { GeoipSettingsSection } from "./geoip-settings-section" import { generateTrafficFlowKeys, getTrafficFlowHostFiles, getTrafficFlowSettings, purgeTrafficFlowData, putTrafficFlowSettings, } from "@/shared/api/traffic-flow" import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog" import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } 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 NetflowSettingsPanel(props: {backendUrl:string;enabled:boolean}) { return } function NetflowSettingsContent({ backendUrl, enabled, }: { backendUrl: string enabled: boolean }) { const [settings,setSettings] = useState(null) const [draft,setDraft] = useState(emptyNetflowDraft) const dirty = useRef(new Set()) const [dirtyCount,setDirtyCount] = useState(0) const [receivedAt,setReceivedAt] = useState(null) const [readError,setReadError] = useState(null) const [opError,setOpError] = useState(null) const [outcome,setOutcome] = useState(null) const [needsReview,setNeedsReview] = useState(false) const [busy,setBusy] = useState(false) const [readBusy,setReadBusy] = useState(false) const [exportOpen,setExportOpen] = useState(false) const [formats,setFormats] = useState([]) const [purgeOpen,setPurgeOpen] = useState(false) const lock = useRef(false) const reader = useRef<{active:boolean;revision:number;controller?:AbortController;operation?:AbortController}>({active:true,revision:0}) useEffect(()=>{const state=reader.current;state.active=true;return()=>{state.active=false;state.revision++;state.controller?.abort();state.operation?.abort()}},[]) const accept = useCallback((data:TrafficFlowSettingsDto)=>{ setSettings(data);setReceivedAt(new Date().toISOString());setReadError(null) const fresh=netflowDraftFromSettings(data) setDraft(current=>{const next={...fresh};for(const field of dirty.current)Object.assign(next,{[field]:current[field]});return next}) },[]) const load = useCallback(async()=>{ if(!enabled||!can("mm:traffic:read")||!reader.current.active||lock.current)return const revision=++reader.current.revision;reader.current.controller?.abort() const controller=new AbortController();reader.current.controller=controller const current=()=>reader.current.active&&reader.current.revision===revision&&!controller.signal.aborted setReadBusy(true) try{const data=await getTrafficFlowSettings(backendUrl,{signal:controller.signal});if(current())accept(data)} catch(error){if(current())setReadError(error instanceof Error?error.message:"Не удалось загрузить NetFlow")} finally{if(current())setReadBusy(false)} },[accept,backendUrl,enabled]) useEffect(()=>{queueMicrotask(()=>{void load()})},[load]) const writable=enabled&&can("mm:traffic:write")&&Boolean(settings)&&!readError const disabled=!writable||busy function change(field:K,value:NetflowDraft[K]){dirty.current.add(field);setDirtyCount(dirty.current.size);setDraft(current=>({...current,[field]:value}))} function begin(){ if(!reader.current.active||lock.current||!writable)return null lock.current=true;reader.current.revision++;reader.current.controller?.abort();setReadBusy(false);setBusy(true);setOpError(null);setOutcome(null) const controller=new AbortController();reader.current.operation=controller;return controller } function finish(){lock.current=false;if(reader.current.active)setBusy(false)} const message=(error:unknown)=>error instanceof Error?error.message:"Не удалось выполнить операцию NetFlow" async function handleSave(){ if(!dirty.current.size)return let patch;try{patch=netflowDraftPatch(draft,dirty.current)}catch(error){setOpError(message(error));return} const controller=begin();if(!controller)return try{const data=await putTrafficFlowSettings(backendUrl,patch,{signal:controller.signal});if(!reader.current.active)return dirty.current.clear();setDirtyCount(0);accept(data.settings);setOutcome("Сохранение настроек NetFlow подтверждено") if(data.listenerError!==undefined)setOpError("Настройки сохранены; запуск приёма требует внимания: "+data.listenerError) }catch(error){if(reader.current.active)setOpError(message(error)+". Подтверждение сохранения не получено; черновик сохранён.")} finally{finish()} } async function handleKeys(){ if(needsReview)return;const controller=begin();if(!controller)return try{const data=await generateTrafficFlowKeys(backendUrl,{signal:controller.signal});if(!reader.current.active)return;accept(data.settings);setOutcome(data.created?"Создание ключей NetFlow подтверждено":"Ключи NetFlow уже созданы")} catch(error){if(reader.current.active){setNeedsReview(true);setOpError(message(error)+". Результат операции неизвестен; проверьте статус и журнал перед повтором.")}} finally{finish()} } async function handlePurgeConfirm(){ if(needsReview)return;const controller=begin();if(!controller)return try{const data=await purgeTrafficFlowData(backendUrl,{signal:controller.signal});if(!reader.current.active)return setPurgeOpen(false);setOutcome("Сброс данных NetFlow подтверждён. "+formatFlowPurgeResult(data)) const warnings=[data.partitionError===undefined?null:"Обслуживание таблиц: "+data.partitionError,data.listenerError===undefined?null:"Запуск приёма: "+data.listenerError,data.eventError===undefined?null:"Запись журнала: "+data.eventError].filter(Boolean) if(warnings.length)setOpError(warnings.join("; ")) }catch(error){if(reader.current.active){setPurgeOpen(false);setNeedsReview(true);setOpError(message(error)+". Результат операции неизвестен; проверьте статус и журнал перед повтором.")}} finally{finish()} if(reader.current.active)await load() } async function handleExport(){ if(!settings?.hasHostPrivateKey)return;const controller=begin();if(!controller)return try{const data=await getTrafficFlowHostFiles(backendUrl,{signal:controller.signal});if(!reader.current.active)return;setFormats(data.files);setExportOpen(true)} catch(error){if(reader.current.active)setOpError(message(error))} finally{finish()} } return ( <>
{settings ? settings.listenerBound ? "Приём UDP: " + settings.listenerAddress : "Приём UDP не готов" : "Приём UDP: —"}

{!can("mm:traffic:read") ? "Нет доступа к данным NetFlow" : readBusy ? "Загрузка NetFlow…" : settings ? "Настройки NetFlow получены: " + new Date(receivedAt!).toLocaleString("ru-RU") : "Настройки NetFlow не получены"}

{readError ? : null} {opError ? : null} {outcome ?

{outcome}

: null} {needsReview ?

Проверьте журнал и состояние NetFlow. Повтор запустит новую операцию.

: null} Ключи и UDP 4739 Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
change("enabled",value)} /> Принимать IPFIX{settings ? "" : ": —"}
change("collectorIp", e.target.value)} /> change("prefix", e.target.value)} /> change("flowListenPort", e.target.value)} /> change("wgListenPort", e.target.value)} /> change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" /> change("retentionHours", e.target.value)} inputMode="numeric" /> change("topN", e.target.value)} inputMode="numeric" />
change("shareOn",value)} /> Порог доли на карте{settings ? "" : ": —"}
change("mapServiceMinSharePct", e.target.value)} inputMode="decimal" disabled={disabled || !draft.shareOn} />

Последний пакет:{" "} {settings?.lastDatagramAt ? new Date(settings.lastDatagramAt).toLocaleString("ru-RU") : settings ? "Не получен" : "—"} {settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""} {settings?.lastError ? ` · ${settings.lastError}` : ""}

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

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

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

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

{ setExportOpen(false);setFormats([]) }} title="Файлы для хоста Docker MM" description="Сохранённые настройки; экспорт содержит приватный ключ." formats={formats} /> { void handlePurgeConfirm() }} onCancel={() => { if (!busy) setPurgeOpen(false) }} /> ) } export { NetflowSettingsPanel }