diff --git a/components/traffic/netflow-purge-dialog.tsx b/components/traffic/netflow-purge-dialog.tsx index ee802dc7..9d308c11 100644 --- a/components/traffic/netflow-purge-dialog.tsx +++ b/components/traffic/netflow-purge-dialog.tsx @@ -19,7 +19,9 @@ export function formatFlowPurgeResult(result: FlowPurgeDto): string { result.deleted.buckets + result.deleted.minuteStats + result.deleted.minuteDims + - result.deleted.dailyDims + result.deleted.dailyDims + + (result.deleted.hourFacts ?? 0) + + (result.deleted.dailyFacts ?? 0) return `Удалено строк: ${rows}.` } @@ -53,7 +55,7 @@ export function NetflowPurgeConfirm({ { event.preventDefault(); onConfirm() }} > {busy ? : null} {busy ? "Сброс…" : "Сбросить"} diff --git a/components/traffic/netflow-settings-panel.tsx b/components/traffic/netflow-settings-panel.tsx index 9dca85df..9ef28e49 100644 --- a/components/traffic/netflow-settings-panel.tsx +++ b/components/traffic/netflow-settings-panel.tsx @@ -1,7 +1,8 @@ "use client" -import { useCallback, useEffect, useState } from "react" -import { toast } from "sonner" +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" @@ -19,7 +20,7 @@ import { putTrafficFlowSettings, } from "@/shared/api/traffic-flow" import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog" -import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react" +import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react" const HOST_STEPS = [ "На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).", @@ -30,122 +31,90 @@ const HOST_STEPS = [ "Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.", ] -function NetflowSettingsPanel({ +function NetflowSettingsPanel(props: {backendUrl:string;enabled:boolean}) { + return +} +function NetflowSettingsContent({ 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(() => { - queueMicrotask(() => { 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) - } + 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 } - - 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) - } + 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 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 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 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) - } + 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 ( @@ -153,85 +122,79 @@ function NetflowSettingsPanel({ - {settings?.listenerBound ? ( - listener {settings.listenerAddress} - ) : ( - listener выкл - )} - - } contentClassName="px-5 py-4 flex flex-col gap-4" > +
{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 - Приватный ключ хранится в PostgreSQL панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0. + Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
- - Принимать IPFIX + change("enabled",value)} /> + Принимать IPFIX{settings ? "" : ": —"}
- setCollectorIp(e.target.value)} /> + change("collectorIp", e.target.value)} /> - setPrefix(e.target.value)} /> + change("prefix", e.target.value)} /> - setFlowPort(e.target.value)} /> + change("flowListenPort", e.target.value)} /> - setWgPort(e.target.value)} /> + change("wgListenPort", e.target.value)} /> - - setEndpoint(e.target.value)} placeholder="203.0.113.10" /> + + change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" /> - + - setRetention(e.target.value)} inputMode="numeric" /> + change("retentionHours", e.target.value)} inputMode="numeric" /> - setTopN(e.target.value)} inputMode="numeric" /> + change("topN", e.target.value)} inputMode="numeric" />
{ - setShareOn(on) - if (on && (!sharePct || sharePct === "0")) setSharePct("5") - }} + ariaLabel="Порог доли на карте" disabled={disabled} checked={draft.shareOn} + onChange={value => change("shareOn",value)} /> - Порог доли на карте + Порог доли на карте{settings ? "" : ": —"}
setSharePct(e.target.value)} + aria-label="Минимум % окна" value={draft.mapServiceMinSharePct} + onChange={(e) => change("mapServiceMinSharePct", e.target.value)} inputMode="decimal" - disabled={!shareOn} + disabled={disabled || !draft.shareOn} />
-

- Last datagram:{" "} +

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

@@ -246,20 +209,21 @@ function NetflowSettingsPanel({
- - - +
-
+

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

@@ -269,7 +233,7 @@ function NetflowSettingsPanel({