fix(ui): привязать flow-аналитику к контексту запроса
This commit is contained in:
1 parent
94dd453b33
commit
24313340d5
5 files changed
+163
-82
No files matched your search
+63
-66
@@ -1,6 +1,6 @@
|
||||
"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 { PageHeader } from "@/components/page-header"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
@@ -840,8 +840,6 @@ export default function TrafficPage() {
|
||||
const trafficLoading = trafficState?.key !== trafficKey
|
||||
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
|
||||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||||
const [liveBusy, setLiveBusy] = useState(false)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
|
||||
const [selectedIface, setSelectedIface] = useState("__all__")
|
||||
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 [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
||||
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
||||
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
|
||||
const [flowExporters, setFlowExporters] = useState<FlowEntityCard[]>([])
|
||||
const [flowClients, setFlowClients] = useState<FlowEntityCard[]>([])
|
||||
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
|
||||
const [flowRetry, setFlowRetry] = useState(0)
|
||||
const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
|
||||
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 [flowDedup, setFlowDedup] = useState(true)
|
||||
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
|
||||
@@ -919,59 +924,46 @@ export default function TrafficPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [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(() => {
|
||||
if (!isLive || effectiveMode !== "flows") return
|
||||
void loadFlows()
|
||||
}, [isLive, effectiveMode, loadFlows])
|
||||
let cancelled = false
|
||||
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(() => {
|
||||
if (!isLive || effectiveMode !== "flows" || !selectedId) {
|
||||
setFlowAnalytics(null)
|
||||
return
|
||||
}
|
||||
if (range === "30d") {
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
void getFlowMonthly(backendUrl, {
|
||||
month,
|
||||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
|
||||
return
|
||||
}
|
||||
void getFlowAnalytics(backendUrl, {
|
||||
range,
|
||||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
userId: flowScope === "users" ? selectedId : undefined,
|
||||
iface: flowIface,
|
||||
dedup: flowDedup,
|
||||
excludeMesh: flowExcludeMesh,
|
||||
excludeOverlay: flowExcludeOverlay,
|
||||
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
|
||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
|
||||
if (!isLive || effectiveMode !== "flows" || !selectedId) return
|
||||
let cancelled = false
|
||||
const request = range === "30d"
|
||||
? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthlyToAnalytics)
|
||||
: getFlowAnalytics(backendUrl, {
|
||||
range, serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
userId: flowScope === "users" ? selectedId : undefined,
|
||||
iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay,
|
||||
})
|
||||
void request.then(data => {
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, data })
|
||||
}).catch(e => {
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey])
|
||||
|
||||
useEffect(() => {
|
||||
setFlowIface("__all__")
|
||||
@@ -1110,7 +1102,7 @@ export default function TrafficPage() {
|
||||
})
|
||||
}, [flowCards, q, sortField, sortDir])
|
||||
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 detailServer = isLive ? currentDetail?.server : selServer
|
||||
@@ -1134,6 +1126,7 @@ export default function TrafficPage() {
|
||||
|| flowLiveError
|
||||
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||
|
||||
const flowLiveHint = flowLiveError ? (flowLiveSample ? "Последние данные · live недоступен" : "История · live недоступен") : flowLiveSample ? "live" : undefined
|
||||
const flowKpiItems = [
|
||||
{
|
||||
id: "exporters",
|
||||
@@ -1146,7 +1139,7 @@ export default function TrafficPage() {
|
||||
id: "bps",
|
||||
label: "Скорость",
|
||||
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" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
@@ -1207,7 +1200,7 @@ export default function TrafficPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveMode === "flows") void loadFlows()
|
||||
if (effectiveMode === "flows") setFlowRetry(value => value + 1)
|
||||
else setTrafficRetry(value => value + 1)
|
||||
}}
|
||||
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 flex-col gap-5">
|
||||
{(!isLive || effectiveMode === "flows" || !trafficError) && <KpiStatGrid
|
||||
isLoading={isLive && effectiveMode !== "flows" && trafficLoading}
|
||||
{(!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) && <KpiStatGrid
|
||||
isLoading={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
|
||||
aria-label="Сводка трафика"
|
||||
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||||
/>}
|
||||
@@ -1255,7 +1248,9 @@ export default function TrafficPage() {
|
||||
Подключить JH
|
||||
</Button>
|
||||
</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">
|
||||
{flowError}
|
||||
</div>
|
||||
@@ -1325,7 +1320,7 @@ export default function TrafficPage() {
|
||||
onClick={() => setSelectedId(card.id)}
|
||||
/>
|
||||
))}
|
||||
{sortedFlowCards.length === 0 ? (
|
||||
{!liveBusy && !liveError && sortedFlowCards.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
|
||||
</p>
|
||||
@@ -1334,6 +1329,8 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
<Frame dense className="w-full flex flex-col">
|
||||
<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
|
||||
card={selFlowCard}
|
||||
analytics={displayedFlow}
|
||||
@@ -1347,8 +1344,8 @@ export default function TrafficPage() {
|
||||
onExcludeMesh={setFlowExcludeMesh}
|
||||
excludeOverlay={flowExcludeOverlay}
|
||||
onExcludeOverlay={setFlowExcludeOverlay}
|
||||
liveHint={displayedFlow?.live ? "live" : undefined}
|
||||
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
|
||||
liveHint={flowLiveHint}
|
||||
emptyHint={analyticsLoading ? "Загрузка аналитики…" : currentAnalytics?.error ? "Не удалось получить данные выбранного среза" : flowEmptyHint(flowStats, collectorAlive)}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
@@ -1358,7 +1355,7 @@ export default function TrafficPage() {
|
||||
onOpenChange={setOverlayOpen}
|
||||
servers={catalogServers}
|
||||
backendUrl={backendUrl}
|
||||
onDone={() => { void loadFlows() }}
|
||||
onDone={() => { setFlowRetry(value => value + 1) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -466,6 +466,7 @@ export function FlowAnalyticsDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{analytics && <>
|
||||
<TrafficRxTxChart
|
||||
rx={analytics?.rxSeries ?? card.rxSeries}
|
||||
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="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
|
||||
<TabsList variant="line" className="flex flex-wrap h-auto">
|
||||
@@ -641,6 +642,7 @@ export function FlowAnalyticsDetail({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,14 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 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)
|
||||
|
||||
- Списки серверов, пользователей и интерфейсов и их сводка привязаны к backend, режиму, периоду и повтору загрузки. Ответ отменённого эффекта не меняет данные и выбранный объект. Flow-запросы больше не делят состояние загрузки/ошибки с этими списками.
|
||||
|
||||
+12
-15
@@ -32,19 +32,15 @@ export function useFlowLive(opts: {
|
||||
excludeMesh?: boolean
|
||||
excludeOverlay?: boolean
|
||||
}): { sample: FlowAnalyticsDto | null; error: string | null } {
|
||||
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null)
|
||||
const [error, setError] = useState<string | 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 [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled) {
|
||||
setSample(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!opts.enabled) return
|
||||
const ac = new AbortController()
|
||||
setSample(null)
|
||||
setError(null)
|
||||
const setError = (error: string) => {
|
||||
if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, error }))
|
||||
}
|
||||
const path = `/api/traffic/flow/live${flowQuery({
|
||||
range: opts.range,
|
||||
serverId: opts.serverId,
|
||||
@@ -65,6 +61,7 @@ export function useFlowLive(opts: {
|
||||
signal: ac.signal,
|
||||
credentials: "include",
|
||||
})
|
||||
if (ac.signal.aborted) return
|
||||
if (!res.ok || !res.body) {
|
||||
setError(`live HTTP ${res.status}`)
|
||||
return
|
||||
@@ -73,7 +70,8 @@ export function useFlowLive(opts: {
|
||||
const decoder = new TextDecoder()
|
||||
while (!ac.signal.aborted) {
|
||||
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 })
|
||||
const parts = buf.split("\n\n")
|
||||
buf = parts.pop() ?? ""
|
||||
@@ -86,8 +84,7 @@ export function useFlowLive(opts: {
|
||||
setError("live sample пустой")
|
||||
continue
|
||||
}
|
||||
setSample(parsed)
|
||||
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||
setResult({ key, sample: parsed, error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null })
|
||||
} else if (ev.event === "error" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as { error?: string }
|
||||
setError(parsed.error ?? "live error")
|
||||
@@ -101,7 +98,7 @@ export function useFlowLive(opts: {
|
||||
})()
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user