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"
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>
) : (
+3 -1
View File
@@ -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>
</>}
</>
)
}
+8
View File
@@ -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
View File
@@ -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 }
}
+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)
})