Files

271 lines
18 KiB
TypeScript

"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 { FlowHostPanel } from "./flow-host-panel"
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».",
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
"Установщик публикует 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 [automatic,setAutomatic] = useState(false)
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 || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} 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 || automatic} 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 || automatic} 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>
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</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 }