From 65241c94e964b0c6701443477ff7f5b724734cfe Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 28 Sep 2026 11:22:20 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 13 ++++++---- components/traffic/traffic-data-time.tsx | 15 +++++++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 9 ++++++- hooks/use-flow-live.ts | 10 +++---- tests/e2e/traffic-flow.spec.ts | 33 ++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 components/traffic/traffic-data-time.tsx diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index e98c1844..74f3bbd0 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, type ReactNode } from "react" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" +import { TrafficDataTime } from "@/components/traffic/traffic-data-time" import { PageHeader } from "@/components/page-header" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" @@ -796,7 +797,7 @@ export default function TrafficPage() { const [flowScope, setFlowScope] = useState("servers") 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 [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; 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 @@ -804,7 +805,7 @@ export default function TrafficPage() { 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; monthly?: FlowMonthlyDto; error?: string } | null>(null) + const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null) const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId]) const [flowIfaceSelection, setFlowIfaceSelection] = useState<{ owner: string; iface: string } | null>(null) const flowIface = flowIfaceSelection?.owner === flowIfaceOwner ? flowIfaceSelection.iface : "__all__" @@ -825,7 +826,7 @@ export default function TrafficPage() { iface: selectedIface, }) const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m" - const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({ + const { sample: flowLiveSample, error: flowLiveError, receivedAt: flowLiveReceivedAt } = useFlowLive({ enabled: flowLiveEnabled, backendUrl, range, @@ -882,7 +883,7 @@ export default function TrafficPage() { getFlowClients(backendUrl, range === "30d" ? "24h" : range), ]).then(([stats, exporters, clients]) => { if (cancelled) return - setFlowResult({ key: flowKey, stats, exporters: exporters.exporters, clients: clients.clients }) + setFlowResult({ key: flowKey, receivedAt: Date.now(), 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 ?? "") @@ -907,7 +908,7 @@ export default function TrafficPage() { iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay, }).then(data => ({ data })) void request.then(result => { - if (!cancelled) setAnalyticsResult({ key: analyticsKey, ...result }) + if (!cancelled) setAnalyticsResult({ key: analyticsKey, receivedAt: Date.now(), ...result }) }).catch(e => { if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" }) }) @@ -1193,6 +1194,7 @@ export default function TrafficPage() { Подключить JH + {isLive && } {liveBusy &&

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

} {liveError && Потоки недоступны{liveError}} {flowError && !liveError && ( @@ -1277,6 +1279,7 @@ export default function TrafficPage() { {analyticsLoading &&

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

} {currentAnalytics?.error && Аналитика недоступна{currentAnalytics.error}} + {isLive && (displayedFlow || currentAnalytics?.monthly) && } + Получено ({source}): + {" · время измерения не предоставлено"} +

+ ) +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index b5c05e76..3af06006 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -84,7 +84,7 @@ flowchart LR Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8](ux-ui-stage-8.md). -Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4Н. +Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4О. В flow добавлены отметки получения API/SSE; свежесть остальных видов трафика ещё требует реализации. Ближайший оставшийся объём: diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 45805ca5..ffcf0f34 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -24,13 +24,20 @@ | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | -| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4Н; далее свежесть данных и приёмка | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4О; далее свежесть остальных видов трафика и приёмка | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.4О — время получения flow-данных (2026-09-28) + +- Список потоков и выбранная аналитика/месячный отчёт показывают отдельное время успешного получения. Источник обозначен как API или live-поток. Подпись явно сообщает, что время измерения не предоставлено: локальные часы браузера не выдаются за метку сетевой телеметрии. +- useFlowLive сохраняет receivedAt вместе с валидным sample и ключом среза. Ошибка/закрытие потока сохраняют эту отметку; невалидный sample не обновляет её. При смене среза, загрузке или ошибке без отображаемых данных прежняя отметка скрыта. Mock-режим не получает фиктивную метку API. +- 21 flow-E2E прошёл, включая управляемое время, задержку нового среза и ошибку после успешного sample. После уточнения скрытия отметок при ошибке списка повторно прошли 2 профильных E2E. ESLint, итоговый TypeScript и diff-check чистые. Production build прошёл (34 страницы); финальное условие скрытия дополнительно проверено TypeScript/E2E. Мобильный экран 390px осмотрен, проверки 320/390/1440 прошли. +- Следующий шаг: время получения REST-списков серверов/клиентов/интерфейсов и деталей сервера; отдельно использовать at серверного SSE как время измерения. Затем сводная приёмка этапа 8. Таймаут молчащего SSE этим блоком не реализован; наличие метки получения не доказывает свежесть измерения. Реальный IPFIX/RouterOS не проверен. Использовано 34% пятичасового и 5% недельного лимита на контрольной точке. Локальный коммит без push. + ## Блок 8.4Н — поздний фрагмент открытого SSE (2026-09-28) - Добавлен браузерный сценарий с управляемым ReadableStream: первый sample отображается, второй reader.read остаётся ожидающим, пользователь выбирает второй сервер. Проверены abort старой подписки, получение нового sample и завершение старого read после abort. Поздний фрагмент не заменяет данные второго сервера. diff --git a/hooks/use-flow-live.ts b/hooks/use-flow-live.ts index 249d562d..39fd700f 100644 --- a/hooks/use-flow-live.ts +++ b/hooks/use-flow-live.ts @@ -30,15 +30,15 @@ export function useFlowLive(opts: { dedup?: boolean excludeMesh?: boolean excludeOverlay?: boolean -}): { sample: FlowAnalyticsDto | null; error: string | null } { +}): { sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | 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) + const [result, setResult] = useState<{ key: string; sample: FlowAnalyticsDto | null; error: string | null; receivedAt: number | null } | null>(null) useEffect(() => { if (!opts.enabled) return const ac = new AbortController() const setError = (error: string) => { - if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, error })) + if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error })) } const path = `/api/traffic/flow/live${flowQuery({ range: opts.range, @@ -82,7 +82,7 @@ export function useFlowLive(opts: { if (!isValidFlowLiveSample(parsed)) { throw new Error("Некорректные данные live-потока") } - setResult({ key, sample: parsed, error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null }) + setResult({ key, sample: parsed, receivedAt: Date.now(), error: parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null }) } else if (ev.event === "error" && ev.data) { const parsed: unknown = JSON.parse(ev.data) throw new Error(parsed && typeof parsed === "object" && "error" in parsed && typeof parsed.error === "string" ? parsed.error : "Ошибка live-потока") @@ -99,5 +99,5 @@ 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, key]) - return result?.key === key ? { sample: result.sample, error: result.error } : { sample: null, error: null } + return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } } diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 73340f2b..e552b1de 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -64,6 +64,8 @@ test('Flow: ошибка списка не отображается как от 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.getByLabel('Получение аналитики потоков')).toHaveCount(0) + 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() @@ -285,3 +287,34 @@ test('Flow: позднее чтение открытого SSE не заменя await expect(page.getByText('Открытый поток второго', { exact: true }).filter({ visible: true })).toBeVisible() await expect(page.getByLabel('Скорость потоков')).toContainText('7.00 Мбит/с') }) + +test('Flow: время получения принадлежит текущему срезу и сохраняется после ошибки SSE', async ({ page }) => { + const control = await setup(page) + const receipt = page.getByLabel('Получение аналитики потоков') + const first = new Date('2026-09-28T06:00:00Z') + await page.clock.setFixedTime(first) + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(receipt.locator('time')).toHaveAttribute('datetime', first.toISOString()) + await expect(receipt).toContainText('Получено (API)') + await expect(receipt).toContainText('время измерения не предоставлено') + control.hold = '24h' + const started = page.waitForRequest(r => r.url().includes('/flow/analytics?') && r.url().includes('range=24h')) + await page.getByRole('button', { name: '24ч', exact: true }).click() + await started + await expect(receipt).toHaveCount(0) + const second = new Date('2026-09-28T06:05:00Z') + await page.clock.setFixedTime(second) + control.release() + await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString()) + control.invalidSample = null + await page.getByRole('button', { name: '5м', exact: true }).click() + await expect(page.getByText('Некорректные данные live-потока', { exact: true })).toBeVisible() + await expect(receipt).toContainText('Получено (live-поток)') + await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString()) + await page.clock.setFixedTime(new Date('2026-09-28T06:10:00Z')) + await expect(receipt.locator('time')).toHaveAttribute('datetime', second.toISOString()) + control.fail = true + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(page.getByRole('alert').filter({ hasText: 'Аналитика недоступна' })).toBeVisible() + await expect(receipt).toHaveCount(0) +})