fix(netflow): сохранять черновики и показывать подтверждённые результаты

This commit is contained in:
Denozordec committed 2026-10-03 12:48:56 +07:00
1 parent c9b0f9266e
commit fcd8de2a96
9 files changed
+222 -156

No files matched your search

+4 -2
View File
@@ -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 ? "Сброс…" : "Сбросить"}
+117 -153
View File
@@ -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) }}
/>
</>
)
+2
View File
@@ -258,3 +258,5 @@ REST запрос → ожидание → подтверждённый output /
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
Завершён 11Б.3.4.4.4: 6 таблиц/counters purge в общей транзакции, отдельные partition/listener/event warnings и nullable размер файлов. DI/HTTP/API/typecheck/build/lint прошли; реальный PG purge не запускался. Следующий шаг 11Б.3.4.4.5 — форма NetFlow и подтверждённые/частичные/неизвестные операции.
Завершён 11Б.3.4.4.5: форма NetFlow known/dirty/rights/lock, selective PUT, partial outcomes и review после unknown POST; 12 E2E/form unit/typecheck/lint/production build прошли. Далее 11Б.3.4.4.6 — source/adaptive приёмка, затем общий контракт IP/CIDR/DNS.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.4; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.5; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.4.4.5 — достоверная форма NetFlow (2026-10-03)
- Форма использует известные DTO, пустые unknown значения, время получения, последние данные и раздельные read/operation outcomes. Dirty хранится по полям, GET обновляет untouched и не стирает правки, PUT отправляет только изменённое без parseInt/default/clamp. Нулевой порог и false сохранены; включение порога требует явного допустимого значения.
- Backend/enabled key remount, active/revision/abort и единый синхронный lock защищают GET/PUT/keys/export/purge. Traffic reader видит данные без изменения и private export; нет ключей — экспорт отключён, файлы отражают сохранённые настройки. GET после экспорта убран, черновик остаётся.
- PUT listenerError и purge partition/listener/event warnings показаны вместе с подтверждением. Сумма purge включает все 6 таблиц; ошибка refresh не отменяет receipt. Неизвестные keys/purge блокируют новые POST до явной проверки пользователем; guard текущего раздела не durable между reload/navigation. Dialog не закрывается до результата, соседние действия блокируются.
- 12 HTTP-route E2E, form unit, полный typecheck, ESLint и production frontend/contracts build прошли. При подготовке скрипт сначала остановился на отсутствующей shared/lib без изменений, путь исправлен на shared/api; оставшиеся purgeBusy ссылки обнаружены typecheck и исправлены перед успешными проверками.
- Реальные PG/UDP/RouterOS не приняты. Валидация IP/CIDR/DNS ещё требует общего backend/frontend контракта; текущий блок строго проверяет числа, непустые строки и отсутствие newline. Далее 11Б.3.4.4.6: source/adaptive приёмка (тесты подготовлены, ещё не запускались), затем общий сетевой ввод.
## Блок 11Б.3.4.4.4 — атомарность и результаты purge NetFlow (2026-10-03)
- Все 6 таблиц и сброс счётчиков выполняются одним PG client в BEGIN/COMMIT, с LOCK TABLE перед чтением счётчиков. Небезопасный/отсутствующий count отклоняется до DELETE; ошибка вызывает попытку ROLLBACK и release. Потерянный ответ COMMIT остаётся неизвестным, автоматического повторного удаления нет.
+2
View File
@@ -186,3 +186,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
Завершён 11Б.3.4.4.4: 6 таблиц/counters purge в общей транзакции, отдельные partition/listener/event warnings и nullable размер файлов. DI/HTTP/API/typecheck/build/lint прошли; реальный PG purge не запускался. Следующий шаг 11Б.3.4.4.5 — форма NetFlow и подтверждённые/частичные/неизвестные операции.
Завершён 11Б.3.4.4.5: форма NetFlow known/dirty/rights/lock, selective PUT, partial outcomes и review после unknown POST; 12 E2E/form unit/typecheck/lint/production build прошли. Далее 11Б.3.4.4.6 — source/adaptive приёмка, затем общий контракт IP/CIDR/DNS.
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {emptyNetflowDraft,netflowDraftPatch} from "./netflow-settings-form"
test("NetFlow form sends selected values without defaulting invalid neighbours",()=>{
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,flowListenPort:"4740"},new Set(["flowListenPort"])),{flowListenPort:4740})
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,enabled:false,shareOn:false},new Set(["enabled","shareOn"])),{enabled:false,mapServiceMinSharePct:0})
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,shareOn:true,mapServiceMinSharePct:"0.25"},new Set(["shareOn","mapServiceMinSharePct"])),{mapServiceMinSharePct:0.25})
for(const raw of ["0","65536","4739x","4.5",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,flowListenPort:raw},new Set(["flowListenPort"])))
for(const raw of ["0","100.01","NaN",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,shareOn:true,mapServiceMinSharePct:raw},new Set(["mapServiceMinSharePct"])))
})
+26
View File
@@ -0,0 +1,26 @@
import type { TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
export type NetflowDraft = { enabled: boolean; collectorIp: string; flowListenPort: string; wgListenPort: string; prefix: string; publicEndpoint: string; retentionHours: string; topN: string; shareOn: boolean; mapServiceMinSharePct: string }
export const emptyNetflowDraft: NetflowDraft = { enabled: false, collectorIp: "", flowListenPort: "", wgListenPort: "", prefix: "", publicEndpoint: "", retentionHours: "", topN: "", shareOn: false, mapServiceMinSharePct: "" }
export function netflowDraftFromSettings(data: TrafficFlowSettingsDto): NetflowDraft {
return { enabled: data.enabled, collectorIp: data.collectorIp, flowListenPort: String(data.flowListenPort), wgListenPort: String(data.wgListenPort), prefix: data.prefix, publicEndpoint: data.publicEndpoint, retentionHours: String(data.retentionHours), topN: String(data.topN), shareOn: data.mapServiceMinSharePct > 0, mapServiceMinSharePct: String(data.mapServiceMinSharePct) }
}
export function netflowDraftPatch(draft: NetflowDraft, dirty: ReadonlySet<keyof NetflowDraft>): TrafficFlowSettingsPatch {
const patch: TrafficFlowSettingsPatch = {}
for (const field of ["collectorIp", "prefix", "publicEndpoint"] as const) if (dirty.has(field)) {
const value = draft[field].trim()
if ((field !== "publicEndpoint" && !value) || /[\r\n]/.test(value)) throw new Error("Укажите корректное значение: " + field)
patch[field] = value
}
for (const [field, label, max] of [["flowListenPort","UDP IPFIX",65535],["wgListenPort","WG listen",65535],["retentionHours","Хранение (часов)",Number.MAX_SAFE_INTEGER],["topN","Top-N разговоров",1000]] as const) if (dirty.has(field)) {
const raw = draft[field], value = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(label + ": укажите целое число от 1 до " + max)
patch[field] = value
}
if (dirty.has("enabled")) patch.enabled = draft.enabled
if (dirty.has("shareOn") || dirty.has("mapServiceMinSharePct")) {
const value = Number(draft.mapServiceMinSharePct)
if (draft.shareOn && (!/^\d+(?:\.\d+)?$/.test(draft.mapServiceMinSharePct) || !Number.isFinite(value) || value <= 0 || value > 100)) throw new Error("Порог карты: укажите долю больше 0 и не более 100%")
patch.mapServiceMinSharePct = draft.shareOn ? value : 0
}
return patch
}
+2
View File
@@ -0,0 +1,2 @@
export const netflowSettings = { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 0, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] }
export const keyedNetflowSettings = {...netflowSettings,hostPublicKey:"fixture-public-only",hasHostPrivateKey:true}
+50
View File
@@ -0,0 +1,50 @@
import {test,expect,type Page} from "@playwright/test"
import {netflowSettings as settings,keyedNetflowSettings as keyed} from "./fixtures/netflow-settings"
async function setup(page:Page){
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p==="/api/auth/config"?{json:{required:false}}:p==="/api/traffic/flow/settings"?{json:settings}:{status:503,json:{error:"outside NetFlow fixture"}})})
}
const field=(page:Page)=>page.getByRole("textbox",{name:"UDP IPFIX",exact:true})
const save=(page:Page)=>page.getByRole("button",{name:"Сохранить NetFlow",exact:true})
const refresh=(page:Page)=>page.getByRole("button",{name:"Обновить статус NetFlow",exact:true})
async function open(page:Page){await page.goto("/data-collection");await expect(field(page)).toHaveValue("4739")}
test("NetFlow: неизвестный DTO не заменяется defaults и блокирует операции",async({page})=>{
await setup(page);await page.route("**/api/traffic/flow/settings",r=>r.fulfill({json:{}}));await page.goto("/data-collection");await expect(page.getByText("NetFlow не получен",{exact:true})).toBeVisible();await expect(field(page)).toHaveValue("");await expect(field(page)).toBeDisabled();await expect(page.getByText("Приём UDP: —",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Ключи хоста",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeDisabled()
})
test("NetFlow: false/zero известны, Save отправляет только dirty port и показывает listener warning",async({page})=>{
await setup(page);const writes:object[]=[];await page.route("**/api/traffic/flow/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();const patch=r.request().postDataJSON();writes.push(patch);return r.fulfill({json:{ok:true,settings:{...settings,...patch},listenerError:"listener unavailable"}})})
await open(page);await expect(page.getByRole("switch",{name:"Принимать IPFIX",exact:true})).not.toBeChecked();await expect(page.getByRole("textbox",{name:"Минимум % окна",exact:true})).toHaveValue("0");await expect(save(page)).toBeDisabled();await field(page).fill("4740");await save(page).click();await expect(page.getByText("Сохранение настроек NetFlow подтверждено",{exact:true})).toBeVisible();await expect(page.getByText(/Настройки сохранены; запуск приёма требует внимания: listener unavailable/)).toBeVisible();expect(writes).toEqual([{flowListenPort:4740}]);await expect(save(page)).toBeDisabled()
})
test("NetFlow: invalid port не округляется и не заменяется default",async({page})=>{
await setup(page);let writes=0;page.on("request",r=>{if(r.method()==="PUT")writes++});await open(page)
for(const value of ["0","65536","4739x","4.5",""]){await field(page).fill(value);await save(page).click();await expect(page.getByText("UDP IPFIX: укажите целое число от 1 до 65535",{exact:true})).toBeVisible();await expect(field(page)).toHaveValue(value)}expect(writes).toBe(0)
})
test("NetFlow: ошибка PUT сохраняет draft без повторов",async({page})=>{
await setup(page);let writes=0;await page.route("**/api/traffic/flow/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await field(page).fill("4740");await save(page).click();await expect(page.getByText(/Подтверждение сохранения не получено; черновик сохранён/)).toBeVisible();await expect(field(page)).toHaveValue("4740");await expect(save(page)).toBeEnabled();expect(writes).toBe(1)
})
test("NetFlow: refresh сохраняет dirty, обновляет untouched и время при ошибке",async({page})=>{
await setup(page);let failed=false,value=settings;await page.route("**/api/traffic/flow/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:value}));await open(page);await field(page).fill("4740");value={...settings,topN:201};await refresh(page).click();await expect(page.getByRole("textbox",{name:"Top-N разговоров",exact:true})).toHaveValue("201");await expect(field(page)).toHaveValue("4740");const stamp=page.getByRole("status").filter({hasText:/^Настройки NetFlow получены:/});const before=await stamp.textContent();failed=true;await refresh(page).click();await expect(page.getByText("NetFlow не обновлён",{exact:true})).toBeVisible();await expect(stamp).toHaveText(before!);await expect(field(page)).toHaveValue("4740");await expect(save(page)).toBeDisabled()
})
test("NetFlow: keys receipt сохраняет dirty port",async({page})=>{
await setup(page);let posts=0;await page.route("**/api/traffic/flow/settings/generate-keys",r=>{posts++;return r.fulfill({json:{ok:true,created:false,publicKey:keyed.hostPublicKey,settings:keyed}})});await open(page);await field(page).fill("4740");await page.getByRole("button",{name:"Ключи хоста",exact:true}).click();await expect(page.getByText("Ключи NetFlow уже созданы",{exact:true})).toBeVisible();await expect(field(page)).toHaveValue("4740");await expect(page.getByRole("textbox",{name:"Public key хоста",exact:true})).toHaveValue(keyed.hostPublicKey);expect(posts).toBe(1)
})
test("NetFlow: неизвестные keys требуют явной проверки перед повтором",async({page})=>{
await setup(page);let posts=0;await page.route("**/api/traffic/flow/settings/generate-keys",r=>{posts++;return r.fulfill({json:{}})});await open(page);const keys=page.getByRole("button",{name:"Ключи хоста",exact:true});await keys.click();await expect(page.getByText(/Результат операции неизвестен; проверьте статус и журнал перед повтором/)).toBeVisible();await expect(keys).toBeDisabled();await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeDisabled();expect(posts).toBe(1);await page.getByRole("button",{name:"Разрешить новую операцию NetFlow",exact:true}).click();await expect(keys).toBeEnabled();expect(posts).toBe(1)
})
test("NetFlow: экспорт существующей пары использует GET и сохраняет draft",async({page})=>{
await setup(page);await page.route("**/api/traffic/flow/settings",r=>r.fulfill({json:keyed}));let exports=0;await page.route("**/api/traffic/flow/host-files",r=>{expect(r.request().method()).toBe("GET");exports++;return r.fulfill({json:{files:[{id:"wg",label:"WG",filename:"wg-flow.conf",code:"fixture-only"}]}})});await open(page);await field(page).fill("4740");await page.getByRole("button",{name:"Скачать файлы хоста NetFlow",exact:true}).click();await expect(page.getByRole("dialog",{name:"Файлы для хоста Docker MM",exact:true})).toBeVisible();expect(exports).toBe(1);await page.keyboard.press("Escape");await expect(field(page)).toHaveValue("4740")
})
test("NetFlow: purge подтверждает все 6 счётчиков, partial и refresh error отдельно",async({page})=>{
await setup(page);let posts=0,failed=false;await page.route("**/api/traffic/flow/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await page.route("**/api/traffic/flow/purge",r=>{posts++;failed=true;return r.fulfill({json:{ok:true,deleted:{buckets:1,minuteStats:2,minuteDims:3,dailyDims:4,hourFacts:5,dailyFacts:6},fileBytesBefore:null,fileBytesAfter:null,vacuumed:false,eventError:"journal unavailable",listenerError:"listener unavailable",partitionError:"partition unavailable"}})})
await open(page);await field(page).fill("4740");await page.getByRole("button",{name:"Сбросить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Сбросить",exact:true}).click();await expect(page.getByText("Сброс данных NetFlow подтверждён. Удалено строк: 21.",{exact:true})).toBeVisible();await expect(page.getByText(/Запись журнала: journal unavailable/)).toBeVisible();await expect(page.getByText("NetFlow не обновлён",{exact:true})).toBeVisible();await expect(field(page)).toHaveValue("4740");expect(posts).toBe(1)
})
test("NetFlow: неизвестный purge закрывает confirmation и блокирует повтор",async({page})=>{
await setup(page);let posts=0;await page.route("**/api/traffic/flow/purge",r=>{posts++;return r.fulfill({status:503,json:{error:"purge unavailable"}})});await open(page);await page.getByRole("button",{name:"Сбросить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Сбросить",exact:true}).click();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.getByText(/Результат операции неизвестен; проверьте статус и журнал перед повтором/)).toBeVisible();await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeDisabled();expect(posts).toBe(1)
})
test("NetFlow: lock удерживает purge dialog и блокирует соседние операции",async({page})=>{
await setup(page);let posts=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve);await page.route("**/api/traffic/flow/purge",async r=>{posts++;await gate;await r.fulfill({json:{ok:true,deleted:{buckets:0,minuteStats:0,minuteDims:0,dailyDims:0},fileBytesBefore:null,fileBytesAfter:null,vacuumed:false}})})
try{await open(page);await page.getByRole("button",{name:"Сбросить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Сбросить",exact:true}).click();await expect.poll(()=>posts).toBe(1);await expect(page.getByRole("alertdialog")).toBeVisible();await expect(page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();release();await expect(page.getByRole("alertdialog")).toHaveCount(0);expect(posts).toBe(1)}finally{release()}
})
test("NetFlow: reader claims видят статус без edit/export/mutations",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard:read","mm:traffic:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));await page.route("**/api/traffic/flow/settings",r=>r.fulfill({json:keyed}));await open(page);await expect(field(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Скачать файлы хоста NetFlow",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Ключи хоста",exact:true})).toBeDisabled();await expect(refresh(page)).toBeEnabled()
})