Files
MikrotikManager/components/traffic/netflow-settings-panel.tsx
T

268 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ApiClientError } from "@/shared/api/http-client"
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 <NetflowSettingsContent key={JSON.stringify([props.backendUrl,props.enabled])} {...props}/>
}
function NetflowSettingsContent({
backendUrl,
enabled,
}: {
backendUrl: string
enabled: boolean
}) {
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
const dirty = useRef(new Set<keyof NetflowDraft>())
const [dirtyCount,setDirtyCount] = useState(0)
const [receivedAt,setReceivedAt] = useState<string|null>(null)
const [readError,setReadError] = useState<string|null>(null)
const [opError,setOpError] = useState<string|null>(null)
const [outcome,setOutcome] = useState<string|null>(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<CodeExportFormat[]>([])
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<K extends keyof NetflowDraft>(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(error instanceof ApiClientError && error.status === 409 ? "Новый сброс не запущен: " + message(error) + ". Проверьте журнал перед новым сбросом." : 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 (
<>
<section id="netflow" aria-label="Настройки NetFlow">
<OpsPanel
title="Traffic Flow / NetFlow (IPFIX)"
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM."
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="flex flex-wrap gap-2"><Badge variant={settings?.listenerBound ? "success" : "secondary"}>{settings ? settings.listenerBound ? "Приём UDP: " + settings.listenerAddress : "Приём UDP не готов" : "Приём UDP: —"}</Badge></div>
<p role="status" className="text-xs text-muted-foreground">{!can("mm:traffic:read") ? "Нет доступа к данным NetFlow" : readBusy ? "Загрузка NetFlow…" : settings ? "Настройки NetFlow получены: " + new Date(receivedAt!).toLocaleString("ru-RU") : "Настройки NetFlow не получены"}</p>
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{settings ? "NetFlow не обновлён" : "NetFlow не получен"}</AlertTitle><AlertDescription>{readError}{settings ? ". Показаны последние подтверждённые данные: " + new Date(receivedAt!).toLocaleString("ru-RU") : ""}</AlertDescription></Alert> : null}
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true"/><AlertTitle>Операция NetFlow требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал и состояние NetFlow. Повтор запустит новую операцию.</p><Button size="sm" variant="outline" aria-label="Разрешить новую операцию NetFlow" disabled={disabled || readBusy} onClick={() => { setNeedsReview(false);setOpError(null) }}>Разрешить повтор NetFlow</Button></div> : null}
<Alert>
<InfoIcon />
<AlertTitle>Ключи и порт IPFIX</AlertTitle>
<AlertDescription>
Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
</AlertDescription>
</Alert>
<div className="flex items-center gap-3">
<FormToggle ariaLabel="Принимать IPFIX" disabled={disabled} checked={draft.enabled} onChange={value => change("enabled",value)} />
<span className="text-sm">Принимать IPFIX{settings ? "" : ": —"}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
</FormField>
<FormField label="UDP IPFIX">
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
</FormField>
<FormField label="WG listen">
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
</FormField>
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
</FormField>
<FormField label="Public key хоста">
<Input className="font-mono text-xs" aria-label="Public key хоста" readOnly value={settings ? settings.hostPublicKey || "Ключи не созданы" : "—"} />
</FormField>
<FormField label="Хранение (часов)">
<Input aria-label="Хранение (часов)" disabled={disabled} value={draft.retentionHours} onChange={(e) => change("retentionHours", e.target.value)} inputMode="numeric" />
</FormField>
<FormField label="Top-N разговоров">
<Input aria-label="Top-N разговоров" disabled={disabled} value={draft.topN} onChange={(e) => change("topN", e.target.value)} inputMode="numeric" />
</FormField>
<div className="sm:col-span-2 flex flex-col gap-2">
<div className="flex items-center gap-3">
<FormToggle
ariaLabel="Порог доли на карте" disabled={disabled} checked={draft.shareOn}
onChange={value => change("shareOn",value)}
/>
<span className="text-sm">Порог доли на карте{settings ? "" : ": —"}</span>
</div>
<FormField
label="Минимум % окна"
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
>
<Input
aria-label="Минимум % окна" value={draft.mapServiceMinSharePct}
onChange={(e) => change("mapServiceMinSharePct", e.target.value)}
inputMode="decimal"
disabled={disabled || !draft.shareOn}
/>
</FormField>
</div>
</div>
<p className="text-xs text-muted-foreground break-words">
Последний пакет:{" "}
{settings?.lastDatagramAt
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
: settings ? "Не получен" : "—"}
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
{settings?.lastError ? ` · ${settings.lastError}` : ""}
</p>
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
))}
</ol>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
Сохранить NetFlow
</Button>
<Button size="sm" variant="outline" disabled={disabled || needsReview} onClick={() => { void handleKeys() }}>
<KeyRoundIcon className="size-4" />
Ключи хоста
</Button>
<Button size="sm" variant="outline" disabled={disabled || !settings?.hasHostPrivateKey} aria-label="Скачать файлы хоста NetFlow" onClick={() => { void handleExport() }}>
<DownloadIcon className="size-4" />
Файлы хоста
</Button>
<Button size="sm" variant="outline" disabled={busy || readBusy || !enabled || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус NetFlow</Button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-md border border-destructive/30 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
<p className="text-xs text-muted-foreground mt-0.5">
Удалит сессии и агрегаты в PostgreSQL. Ключи WG и пиры не трогает.
</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={disabled || needsReview}
onClick={() => setPurgeOpen(true)}
>
Сбросить
</Button>
</div>
</OpsPanel>
</section>
<GeoipSettingsSection backendUrl={backendUrl} />
<CodeExportSheet
open={exportOpen}
onClose={() => { setExportOpen(false);setFormats([]) }}
title="Файлы для хоста Docker MM"
description="Сохранённые настройки; экспорт содержит приватный ключ."
formats={formats}
/>
<NetflowPurgeConfirm
open={purgeOpen}
busy={busy}
onConfirm={() => { void handlePurgeConfirm() }}
onCancel={() => { if (!busy) setPurgeOpen(false) }}
/>
</>
)
}
export { NetflowSettingsPanel }