diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 445a55c3..742394e0 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -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([]) - const [liveBusy, setLiveBusy] = useState(false) - const [liveError, setLiveError] = useState(null) const [serverIfaces, setServerIfaces] = useState([]) 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([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState([]) - const [flowStats, setFlowStats] = useState(null) const [flowScope, setFlowScope] = useState("servers") - const [flowExporters, setFlowExporters] = useState([]) - const [flowClients, setFlowClients] = useState([]) - const [flowAnalytics, setFlowAnalytics] = useState(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: , 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() {
- {(!isLive || effectiveMode === "flows" || !trafficError) && } @@ -1255,7 +1248,9 @@ export default function TrafficPage() { Подключить JH
- {flowError && ( + {liveBusy &&

Загрузка потоков…

} + {liveError && Потоки недоступны{liveError}} + {flowError && !liveError && (
{flowError}
@@ -1325,7 +1320,7 @@ export default function TrafficPage() { onClick={() => setSelectedId(card.id)} /> ))} - {sortedFlowCards.length === 0 ? ( + {!liveBusy && !liveError && sortedFlowCards.length === 0 ? (

{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}

@@ -1334,6 +1329,8 @@ export default function TrafficPage() {
+ {analyticsLoading &&

Загрузка аналитики…

} + {currentAnalytics?.error && Аналитика недоступна{currentAnalytics.error}}
@@ -1358,7 +1355,7 @@ export default function TrafficPage() { onOpenChange={setOverlayOpen} servers={catalogServers} backendUrl={backendUrl} - onDone={() => { void loadFlows() }} + onDone={() => { setFlowRetry(value => value + 1) }} /> ) : ( diff --git a/components/traffic/flow-analytics-panel.tsx b/components/traffic/flow-analytics-panel.tsx index 5f181a27..e2d68b1e 100644 --- a/components/traffic/flow-analytics-panel.tsx +++ b/components/traffic/flow-analytics-panel.tsx @@ -466,6 +466,7 @@ export function FlowAnalyticsDetail({ ) : null} + {analytics && <>

Аналитика потребления

- {analytics?.live ? live : null} + {liveHint === "live" ? live : null}
setSlice(String(v))} className="gap-3"> @@ -641,6 +642,7 @@ export function FlowAnalyticsDetail({ + } ) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 0cec5e33..b1c02ef9 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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-запросы больше не делят состояние загрузки/ошибки с этими списками. diff --git a/hooks/use-flow-live.ts b/hooks/use-flow-live.ts index 1ea47154..900f4384 100644 --- a/hooks/use-flow-live.ts +++ b/hooks/use-flow-live.ts @@ -32,19 +32,15 @@ export function useFlowLive(opts: { excludeMesh?: boolean excludeOverlay?: boolean }): { sample: FlowAnalyticsDto | null; error: string | null } { - const [sample, setSample] = useState(null) - const [error, setError] = useState(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 } } diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts new file mode 100644 index 00000000..125bd297 --- /dev/null +++ b/tests/e2e/traffic-flow.spec.ts @@ -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(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) +})