fix(ui): привязать flow-аналитику к контексту запроса

This commit is contained in:
Denozordec committed 2026-09-24 01:31:43 +07:00
1 parent 94dd453b33
commit 24313340d5
5 files changed
+163 -82

No files matched your search

+63 -66
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react" import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
@@ -840,8 +840,6 @@ export default function TrafficPage() {
const trafficLoading = trafficState?.key !== trafficKey const trafficLoading = trafficState?.key !== trafficKey
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([]) const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
const [liveBusy, setLiveBusy] = useState(false)
const [liveError, setLiveError] = useState<string | null>(null)
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([]) const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
const [selectedIface, setSelectedIface] = useState("__all__") const [selectedIface, setSelectedIface] = useState("__all__")
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
@@ -849,11 +847,18 @@ export default function TrafficPage() {
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null) const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([]) const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
const [flowScope, setFlowScope] = useState<FlowScope>("servers") const [flowScope, setFlowScope] = useState<FlowScope>("servers")
const [flowExporters, setFlowExporters] = useState<FlowEntityCard[]>([]) const [flowRetry, setFlowRetry] = useState(0)
const [flowClients, setFlowClients] = useState<FlowEntityCard[]>([]) const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null) const [flowResult, setFlowResult] = useState<{ key: string; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
const currentFlows = flowResult?.key === flowKey ? flowResult : null
const liveBusy = isLive && groupMode === "flows" && !currentFlows
const liveError = currentFlows?.error
const flowStats = currentFlows?.stats ?? null
const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows])
const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows])
const [analyticsRetry, setAnalyticsRetry] = useState(0)
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; data?: FlowAnalyticsDto; error?: string } | null>(null)
const [flowIface, setFlowIface] = useState("__all__") const [flowIface, setFlowIface] = useState("__all__")
const [flowDedup, setFlowDedup] = useState(true) const [flowDedup, setFlowDedup] = useState(true)
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true) const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
@@ -919,59 +924,46 @@ export default function TrafficPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range]) }, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range])
const loadFlows = useCallback(async () => {
if (!isLive) return
setLiveBusy(true)
setLiveError(null)
try {
const [stats, exporters, clients] = await Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
])
setFlowStats(stats)
setFlowExporters(exporters.exporters)
setFlowClients(clients.clients)
setSelectedId((prev) => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
if (list.some((x) => x.id === prev)) return prev
return list[0]?.id ?? ""
})
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
} finally {
setLiveBusy(false)
}
}, [isLive, backendUrl, range, flowScope])
useEffect(() => { useEffect(() => {
if (!isLive || effectiveMode !== "flows") return if (!isLive || effectiveMode !== "flows") return
void loadFlows() let cancelled = false
}, [isLive, effectiveMode, loadFlows]) void Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
]).then(([stats, exporters, clients]) => {
if (cancelled) return
setFlowResult({ key: flowKey, stats, exporters: exporters.exporters, clients: clients.clients })
setSelectedId(prev => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
})
}).catch(e => {
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
})
return () => { cancelled = true }
}, [isLive, effectiveMode, backendUrl, range, flowScope, flowKey])
const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry])
const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null
const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && !currentAnalytics
useEffect(() => { useEffect(() => {
if (!isLive || effectiveMode !== "flows" || !selectedId) { if (!isLive || effectiveMode !== "flows" || !selectedId) return
setFlowAnalytics(null) let cancelled = false
return const request = range === "30d"
} ? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthlyToAnalytics)
if (range === "30d") { : getFlowAnalytics(backendUrl, {
const month = new Date().toISOString().slice(0, 7) range, serverId: flowScope === "servers" ? selectedId : undefined,
void getFlowMonthly(backendUrl, { userId: flowScope === "users" ? selectedId : undefined,
month, iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay,
serverId: flowScope === "servers" ? selectedId : undefined, })
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null)) void request.then(data => {
return if (!cancelled) setAnalyticsResult({ key: analyticsKey, data })
} }).catch(e => {
void getFlowAnalytics(backendUrl, { if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
range, })
serverId: flowScope === "servers" ? selectedId : undefined, return () => { cancelled = true }
userId: flowScope === "users" ? selectedId : undefined, }, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey])
iface: flowIface,
dedup: flowDedup,
excludeMesh: flowExcludeMesh,
excludeOverlay: flowExcludeOverlay,
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
useEffect(() => { useEffect(() => {
setFlowIface("__all__") setFlowIface("__all__")
@@ -1110,7 +1102,7 @@ export default function TrafficPage() {
}) })
}, [flowCards, q, sortField, sortDir]) }, [flowCards, q, sortField, sortDir])
const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null
const displayedFlow = flowLiveSample ?? flowAnalytics const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = isLive ? currentDetail?.server : selServer const detailServer = isLive ? currentDetail?.server : selServer
@@ -1134,6 +1126,7 @@ export default function TrafficPage() {
|| flowLiveError || flowLiveError
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null) || (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
const flowLiveHint = flowLiveError ? (flowLiveSample ? "Последние данные · live недоступен" : "История · live недоступен") : flowLiveSample ? "live" : undefined
const flowKpiItems = [ const flowKpiItems = [
{ {
id: "exporters", id: "exporters",
@@ -1146,7 +1139,7 @@ export default function TrafficPage() {
id: "bps", id: "bps",
label: "Скорость", label: "Скорость",
value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"), value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"),
hint: displayedFlow?.live ? "live" : undefined, hint: flowLiveHint,
icon: <ActivityIcon className="size-4" />, icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
@@ -1207,7 +1200,7 @@ export default function TrafficPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
if (effectiveMode === "flows") void loadFlows() if (effectiveMode === "flows") setFlowRetry(value => value + 1)
else setTrafficRetry(value => value + 1) else setTrafficRetry(value => value + 1)
}} }}
disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)} disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
@@ -1238,8 +1231,8 @@ export default function TrafficPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{(!isLive || effectiveMode === "flows" || !trafficError) && <KpiStatGrid {(!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) && <KpiStatGrid
isLoading={isLive && effectiveMode !== "flows" && trafficLoading} isLoading={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
aria-label="Сводка трафика" aria-label="Сводка трафика"
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems} items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>} />}
@@ -1255,7 +1248,9 @@ export default function TrafficPage() {
Подключить JH Подключить JH
</Button> </Button>
</div> </div>
{flowError && ( {liveBusy && <p role="status">Загрузка потоков…</p>}
{liveError && <Alert variant="destructive" role="alert"><AlertTitle>Потоки недоступны</AlertTitle><AlertDescription>{liveError}<Button size="sm" variant="outline" onClick={() => setFlowRetry(value => value + 1)}>Повторить загрузку потоков</Button></AlertDescription></Alert>}
{flowError && !liveError && (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2"> <div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{flowError} {flowError}
</div> </div>
@@ -1325,7 +1320,7 @@ export default function TrafficPage() {
onClick={() => setSelectedId(card.id)} onClick={() => setSelectedId(card.id)}
/> />
))} ))}
{sortedFlowCards.length === 0 ? ( {!liveBusy && !liveError && sortedFlowCards.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."} {flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p> </p>
@@ -1334,6 +1329,8 @@ export default function TrafficPage() {
</div> </div>
<Frame dense className="w-full flex flex-col"> <Frame dense className="w-full flex flex-col">
<FramePanel className="flex-1 px-5 pb-5 pt-5"> <FramePanel className="flex-1 px-5 pb-5 pt-5">
{analyticsLoading && <p role="status">Загрузка аналитики…</p>}
{currentAnalytics?.error && <Alert variant="destructive" role="alert"><AlertTitle>Аналитика недоступна</AlertTitle><AlertDescription>{currentAnalytics.error}<Button size="sm" variant="outline" onClick={() => setAnalyticsRetry(value => value + 1)}>Повторить аналитику</Button></AlertDescription></Alert>}
<FlowAnalyticsDetail <FlowAnalyticsDetail
card={selFlowCard} card={selFlowCard}
analytics={displayedFlow} analytics={displayedFlow}
@@ -1347,8 +1344,8 @@ export default function TrafficPage() {
onExcludeMesh={setFlowExcludeMesh} onExcludeMesh={setFlowExcludeMesh}
excludeOverlay={flowExcludeOverlay} excludeOverlay={flowExcludeOverlay}
onExcludeOverlay={setFlowExcludeOverlay} onExcludeOverlay={setFlowExcludeOverlay}
liveHint={displayedFlow?.live ? "live" : undefined} liveHint={flowLiveHint}
emptyHint={flowEmptyHint(flowStats, collectorAlive)} emptyHint={analyticsLoading ? "Загрузка аналитики…" : currentAnalytics?.error ? "Не удалось получить данные выбранного среза" : flowEmptyHint(flowStats, collectorAlive)}
/> />
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -1358,7 +1355,7 @@ export default function TrafficPage() {
onOpenChange={setOverlayOpen} onOpenChange={setOverlayOpen}
servers={catalogServers} servers={catalogServers}
backendUrl={backendUrl} backendUrl={backendUrl}
onDone={() => { void loadFlows() }} onDone={() => { setFlowRetry(value => value + 1) }}
/> />
</div> </div>
) : ( ) : (
+3 -1
View File
@@ -466,6 +466,7 @@ export function FlowAnalyticsDetail({
</div> </div>
) : null} ) : null}
{analytics && <>
<TrafficRxTxChart <TrafficRxTxChart
rx={analytics?.rxSeries ?? card.rxSeries} rx={analytics?.rxSeries ?? card.rxSeries}
tx={analytics?.txSeries ?? card.txSeries} tx={analytics?.txSeries ?? card.txSeries}
@@ -549,7 +550,7 @@ export function FlowAnalyticsDetail({
<div className="mt-4 pt-4 border-t flex flex-col gap-3"> <div className="mt-4 pt-4 border-t flex flex-col gap-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Аналитика потребления</p> <p className="text-xs text-muted-foreground">Аналитика потребления</p>
{analytics?.live ? <Badge variant="success-light" size="sm">live</Badge> : null} {liveHint === "live" ? <Badge variant="success-light" size="sm">live</Badge> : null}
</div> </div>
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3"> <Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
<TabsList variant="line" className="flex flex-wrap h-auto"> <TabsList variant="line" className="flex flex-wrap h-auto">
@@ -641,6 +642,7 @@ export function FlowAnalyticsDetail({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</>}
</> </>
) )
} }
+8
View File
@@ -29,6 +29,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.3Б3 — контекст flow-аналитики (2026-09-24)
- Списки flow и аналитика привязаны к backend, режиму, периоду, объекту и фильтрам. Поздние ответы отменённого эффекта игнорируются. Ошибки списка/аналитики получили отдельные Alert и повтор без сброса контекста; ошибка списка не выдаётся за отсутствие экспортёров.
- При отсутствии подтверждённой аналитики график, метрики и таблицы деталей скрыты; прежняя подстановка общих показателей карточки удалена. Общая сводка может показывать подтверждённые агрегаты коллектора — дальнейшее уточнение подписей/области данных остаётся в 8.4.
- useFlowLive хранит данные с ключом контекста, проверяет abort после fetch/read, сообщает о завершении SSE. Последний sample сохраняется в своём контексте с отметкой недоступности live; одинаковая отметка используется в сводке и деталях.
- 7 прежних E2E трафика прошли; 4 новых flow-E2E прошли после исправления найденной тестом live-метки сводки: поздний период, ошибка аналитики/повтор, 403 списка/повтор и завершённый SSE. Production build/TypeScript — 34 страницы. Lint hook/панели/тестов чистый; на странице осталась 1 прежняя ошибка синхронного сброса flowIface (было 3).
- Далее: отдельные проверки смены сервера/клиента/backend и позднего SSE, строгая валидация sample, семантика месячного отчёта (30d сейчас запрашивает текущий календарный месяц; API monthly принимает только month/serverId и не применяет остальные фильтры). Затем 8.4: неизвестные значения, единицы и мобильная приёмка. Реальный IPFIX/RouterOS не проверен. Использовано 66% пятичасового и 57% недельного лимита.
## Блок 8.3Б2 — общие списки трафика (2026-09-24) ## Блок 8.3Б2 — общие списки трафика (2026-09-24)
- Списки серверов, пользователей и интерфейсов и их сводка привязаны к backend, режиму, периоду и повтору загрузки. Ответ отменённого эффекта не меняет данные и выбранный объект. Flow-запросы больше не делят состояние загрузки/ошибки с этими списками. - Списки серверов, пользователей и интерфейсов и их сводка привязаны к backend, режиму, периоду и повтору загрузки. Ответ отменённого эффекта не меняет данные и выбранный объект. Flow-запросы больше не делят состояние загрузки/ошибки с этими списками.
+12 -15
View File
@@ -32,19 +32,15 @@ export function useFlowLive(opts: {
excludeMesh?: boolean excludeMesh?: boolean
excludeOverlay?: boolean excludeOverlay?: boolean
}): { sample: FlowAnalyticsDto | null; error: string | null } { }): { sample: FlowAnalyticsDto | null; error: string | null } {
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null) const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay])
const [error, setError] = useState<string | null>(null) const [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null } | null>(null)
useEffect(() => { useEffect(() => {
if (!opts.enabled) { if (!opts.enabled) return
setSample(null)
setError(null)
return
}
const ac = new AbortController() const ac = new AbortController()
setSample(null) const setError = (error: string) => {
setError(null) if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, error }))
}
const path = `/api/traffic/flow/live${flowQuery({ const path = `/api/traffic/flow/live${flowQuery({
range: opts.range, range: opts.range,
serverId: opts.serverId, serverId: opts.serverId,
@@ -65,6 +61,7 @@ export function useFlowLive(opts: {
signal: ac.signal, signal: ac.signal,
credentials: "include", credentials: "include",
}) })
if (ac.signal.aborted) return
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
setError(`live HTTP ${res.status}`) setError(`live HTTP ${res.status}`)
return return
@@ -73,7 +70,8 @@ export function useFlowLive(opts: {
const decoder = new TextDecoder() const decoder = new TextDecoder()
while (!ac.signal.aborted) { while (!ac.signal.aborted) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (ac.signal.aborted) return
if (done) { setError("Поток live завершён"); break }
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true })
const parts = buf.split("\n\n") const parts = buf.split("\n\n")
buf = parts.pop() ?? "" buf = parts.pop() ?? ""
@@ -86,8 +84,7 @@ export function useFlowLive(opts: {
setError("live sample пустой") setError("live sample пустой")
continue continue
} }
setSample(parsed) setResult({ key, sample: parsed, error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null })
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
} else if (ev.event === "error" && ev.data) { } else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string } const parsed = JSON.parse(ev.data) as { error?: string }
setError(parsed.error ?? "live error") setError(parsed.error ?? "live error")
@@ -101,7 +98,7 @@ export function useFlowLive(opts: {
})() })()
return () => ac.abort() return () => ac.abort()
}, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay]) }, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay, key])
return { sample, error } return result?.key === key ? { sample: result.sample, error: result.error } : { sample: null, error: null }
} }
+77
View File
@@ -0,0 +1,77 @@
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { hold: '', fail: false, failList: false, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const card = { id: '1', name: 'Экспортёр тест', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3], bytes: 200 }
const data = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false }
await page.route('**/health', r => r.fulfill({ json: { ok: true } }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()), path = url.pathname, range = url.searchParams.get('range')
if (path === '/api/traffic/flow/analytics') {
if (control.hold === range) await gate
if (control.fail) return route.fulfill({ status: 500, json: { error: 'Срез временно недоступен' } })
return route.fulfill({ json: { ...data, topCategory: 'Категория ' + range } })
}
if (path === '/api/traffic/flow/live') return route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify({ ...data, live: true }) + '\n\n' })
if (path === '/api/traffic/flow/exporters' && control.failList) return route.fulfill({ status: 403, json: { error: 'Нет доступа к экспортёрам' } })
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/flow/exporters' ? { exporters: [card] }
: path === '/api/traffic/flow/clients' ? { clients: [] }
: path === '/api/traffic/flows' ? { exportersOnline: 1, bytesPerMin: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', talkers: [], listenerBound: true }
: path === '/api/traffic/servers' ? { servers: [] }
: path.endsWith('/interfaces') || path.endsWith('/bound-interfaces') ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
})
await page.goto('/traffic')
await page.getByRole('button', { name: 'Потоки', exact: true }).click()
await expect(page.getByLabel('Скорость потоков')).toBeVisible()
return control
}
test('Flow: поздний ответ периода не заменяет текущую аналитику', async ({ page }) => {
const control = await setup(page)
control.hold = '24h'
await page.getByRole('button', { name: '24ч', exact: true }).click()
await expect(page.getByText('Загрузка аналитики…', { exact: true })).toBeVisible()
await expect(page.getByLabel('Скорость потоков')).toHaveCount(0)
await page.getByRole('button', { name: '15м', exact: true }).click()
await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible()
const response = page.waitForResponse(r => r.url().includes('/flow/analytics?') && r.url().includes('range=24h'))
control.release()
await response
await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible()
await expect(page.getByText('Категория 24h', { exact: true })).toHaveCount(0)
})
test('Flow: ошибка аналитики и повтор сохраняют период', async ({ page }) => {
const control = await setup(page)
control.fail = true
await page.getByRole('button', { name: '15м', exact: true }).click()
await expect(page.getByRole('alert').filter({ hasText: 'Аналитика недоступна' })).toContainText('Срез временно недоступен')
await expect(page.getByLabel('Скорость потоков')).toHaveCount(0)
control.fail = false
await page.getByRole('button', { name: 'Повторить аналитику', exact: true }).click()
await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible()
})
test('Flow: ошибка списка не отображается как отсутствие экспортёров', async ({ page }) => {
const control = await setup(page)
control.failList = true
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await expect(page.getByRole('alert').filter({ hasText: 'Потоки недоступны' })).toContainText('Нет доступа')
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
await expect(page.getByText('Нет экспортёров IPFIX.', { exact: false })).toHaveCount(0)
control.failList = false
await page.getByRole('button', { name: 'Повторить загрузку потоков', exact: true }).click()
await expect(page.getByLabel('Скорость потоков')).toBeVisible()
})
test('Flow: завершённый SSE не помечает последние данные как live', async ({ page }) => {
await setup(page)
await page.getByRole('button', { name: '5м', exact: true }).click()
await expect(page.getByText('Последние данные · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2)
await expect(page.getByText('live', { exact: true })).toHaveCount(0)
})