fix(netflow): сохранять черновики и показывать подтверждённые результаты
This commit is contained in:
1 parent
c9b0f9266e
commit
fcd8de2a96
9 files changed
+222
-156
No files matched your search
@@ -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({
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
onClick={(event) => { event.preventDefault(); onConfirm() }}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
{busy ? "Сброс…" : "Сбросить"}
|
||||
|
||||
@@ -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 <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 [busy, setBusy] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
const [formats, setFormats] = useState<CodeExportFormat[]>([])
|
||||
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<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
|
||||
}
|
||||
|
||||
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({
|
||||
<OpsPanel
|
||||
title="Traffic Flow / NetFlow (IPFIX)"
|
||||
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM."
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
{settings?.listenerBound ? (
|
||||
<Badge variant="success">listener {settings.listenerAddress}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">listener выкл</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
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>Ключи и UDP 4739</AlertTitle>
|
||||
<AlertDescription>
|
||||
Приватный ключ хранится в PostgreSQL панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||||
Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<FormToggle checked={ingestOn} onChange={setIngestOn} />
|
||||
<span className="text-sm">Принимать IPFIX</span>
|
||||
<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" value={collectorIp} onChange={(e) => setCollectorIp(e.target.value)} />
|
||||
<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" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
||||
<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" value={flowPort} onChange={(e) => setFlowPort(e.target.value)} />
|
||||
<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" value={wgPort} onChange={(e) => setWgPort(e.target.value)} />
|
||||
<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" required>
|
||||
<Input className="font-mono" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="203.0.113.10" />
|
||||
<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" readOnly value={settings?.hostPublicKey || "— сгенерируйте ключи —"} />
|
||||
<Input className="font-mono text-xs" aria-label="Public key хоста" readOnly value={settings ? settings.hostPublicKey || "Ключи не созданы" : "—"} />
|
||||
</FormField>
|
||||
<FormField label="Хранение (часов)">
|
||||
<Input value={retention} onChange={(e) => setRetention(e.target.value)} inputMode="numeric" />
|
||||
<Input aria-label="Хранение (часов)" disabled={disabled} value={draft.retentionHours} onChange={(e) => change("retentionHours", e.target.value)} inputMode="numeric" />
|
||||
</FormField>
|
||||
<FormField label="Top-N разговоров">
|
||||
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
||||
<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
|
||||
checked={shareOn}
|
||||
onChange={(on) => {
|
||||
setShareOn(on)
|
||||
if (on && (!sharePct || sharePct === "0")) setSharePct("5")
|
||||
}}
|
||||
ariaLabel="Порог доли на карте" disabled={disabled} checked={draft.shareOn}
|
||||
onChange={value => change("shareOn",value)}
|
||||
/>
|
||||
<span className="text-sm">Порог доли на карте</span>
|
||||
<span className="text-sm">Порог доли на карте{settings ? "" : ": —"}</span>
|
||||
</div>
|
||||
<FormField
|
||||
label="Минимум % окна"
|
||||
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
|
||||
>
|
||||
<Input
|
||||
value={sharePct}
|
||||
onChange={(e) => setSharePct(e.target.value)}
|
||||
aria-label="Минимум % окна" value={draft.mapServiceMinSharePct}
|
||||
onChange={(e) => change("mapServiceMinSharePct", e.target.value)}
|
||||
inputMode="decimal"
|
||||
disabled={!shareOn}
|
||||
disabled={disabled || !draft.shareOn}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last datagram:{" "}
|
||||
<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>
|
||||
@@ -246,20 +209,21 @@ function NetflowSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy || purgeBusy} onClick={() => { void handleSave() }}>
|
||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||
Сохранить NetFlow
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
|
||||
<Button size="sm" variant="outline" disabled={disabled || needsReview} onClick={() => { void handleKeys() }}>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Ключи хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
|
||||
<Button size="sm" variant="outline" disabled={disabled || !settings?.hasHostPrivateKey} aria-label="Скачать файлы хоста NetFlow" onClick={() => { void handleExport() }}>
|
||||
<DownloadIcon className="size-4" />
|
||||
wg-quick / compose / firewall
|
||||
Файлы хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy || readBusy || !enabled || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус NetFlow</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-destructive/30 px-4 py-3">
|
||||
<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">
|
||||
@@ -269,7 +233,7 @@ function NetflowSettingsPanel({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={busy || purgeBusy}
|
||||
disabled={disabled || needsReview}
|
||||
onClick={() => setPurgeOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
@@ -281,17 +245,17 @@ function NetflowSettingsPanel({
|
||||
|
||||
<CodeExportSheet
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
onClose={() => { setExportOpen(false);setFormats([]) }}
|
||||
title="Файлы для хоста Docker MM"
|
||||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||
description="Сохранённые настройки; экспорт содержит приватный ключ."
|
||||
formats={formats}
|
||||
/>
|
||||
|
||||
<NetflowPurgeConfirm
|
||||
open={purgeOpen}
|
||||
busy={purgeBusy}
|
||||
busy={busy}
|
||||
onConfirm={() => { void handlePurgeConfirm() }}
|
||||
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
|
||||
onCancel={() => { if (!busy) setPurgeOpen(false) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user