diff --git a/app/(main)/statistics/page.tsx b/app/(main)/statistics/page.tsx index bcbf394e..84737f3f 100644 --- a/app/(main)/statistics/page.tsx +++ b/app/(main)/statistics/page.tsx @@ -9,6 +9,7 @@ import { ServerIcon, UsersIcon, } from "lucide-react" +import { Button } from "@/components/ui/button" import { PageHeader } from "@/components/page-header" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { DataPageCard } from "@/components/data-page-card" @@ -243,10 +244,16 @@ function StatisticsPageInner() { const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams]) const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams]) - const [data, setData] = useState(EMPTY) - const [pivot, setPivot] = useState(EMPTY_PIVOT) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const [retry, setRetry] = useState(0) + const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry }) + const [result, setResult] = useState<{ + key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null + } | null>(null) + const current = result?.key === requestKey ? result : null + const loading = isLive && current === null + const error = current?.error ?? null + const dataReady = isLive && current !== null && !error + const pivot = dataReady ? current.pivot : EMPTY_PIVOT const replaceParams = useCallback( (patch: Record) => { @@ -287,37 +294,32 @@ function StatisticsPageInner() { if (!prefsHydrated || !isLive) return let cancelled = false void (async () => { - setLoading(true) - setError(null) try { const query = toQuery(range, slices, planes) const dto = await getStatistics(backendUrl, query) - if (!cancelled) setData(dto) + if (cancelled) return + let matrix = EMPTY_PIVOT if (view === "pivot" && pivotRow !== pivotCol) { - const matrix = await getStatisticsPivot(backendUrl, { + matrix = await getStatisticsPivot(backendUrl, { ...query, row: pivotRow, col: pivotCol, metric: "bytes", }) - if (!cancelled) setPivot(matrix) } + if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null }) } catch (e: unknown) { if (!cancelled) { - setData(EMPTY) - setPivot(EMPTY_PIVOT) - setError(e instanceof Error ? e.message : "Не удалось загрузить статистику") + setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" }) } - } finally { - if (!cancelled) setLoading(false) } })() return () => { cancelled = true } - }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes]) + }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey]) - const viewData = isLive ? data : EMPTY + const viewData = dataReady ? current.data : EMPTY const sliced = hasAnySlice(slices) const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0) @@ -369,13 +371,13 @@ function StatisticsPageInner() { ) : null} {error ? ( - + Ошибка загрузки - {error} + {error} ) : null} - {!slices.serverId && isLive && !emptyCube ? ( + {!slices.serverId && dataReady && !emptyCube ? ( Уникальный объём @@ -384,7 +386,7 @@ function StatisticsPageInner() { ) : null} - + />} - + {dataReady && } } filterControl={} - countLabel={countLabel} + countLabel={dataReady ? countLabel : undefined} /> - {emptyCube ? ( + {isLive && !dataReady ? ( +
{loading ? "Загрузка статистики…" : "Статистика недоступна. Повторите загрузку."}
+ ) : emptyCube ? ( {} } + const pending = new Promise(resolve => { control.release = resolve }) + await page.route('**/health', route => route.fulfill({ json: { ok: true } })) + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()) + if (url.pathname !== '/api/statistics') return route.fulfill({ json: url.pathname === '/api/auth/config' ? { required: false } : url.pathname === '/api/servers' ? [] : {} }) + const from = url.searchParams.get('from')! + const fail = control.failure + if (control.hold && from === '2026-09-02') await pending + if (fail) return route.fulfill({ status: fail, json: { error: 'Статистика временно недоступна' } }) + const n = control.empty ? 0 : 1 + const dto = statisticsDtoSchema.parse({ + from, to: url.searchParams.get('to'), grain: 'day', + kpis: { bytes: n * 1000, packets: n * 10, avgBps: n * 100, users: n, servers: n, ifaces: n, topCountry: '', topService: '' }, + series: [], servers: [], interfaces: [], countries: [], services: [], asns: [], + users: n ? [{ id: from, label: `Данные ${from}`, bytes: 1000, packets: 10, bps: 100, percent: 100 }] : [], + }) + await route.fulfill({ json: dto }) + }) + await page.goto('/statistics?from=2026-09-01&to=2026-09-03&dim=users') + return control +} + +for (const status of [403, 500]) { + test(`Статистика: ${status} не подменяется нулём; повтор получает подтверждённый пустой ответ`, async ({ page }) => { + const control = await setup(page, status) + await expect(page.getByRole('alert').filter({ hasText: 'Ошибка загрузки' })).toContainText('Статистика временно недоступна') + await expect(page.getByText('Нет данных куба', { exact: true })).toHaveCount(0) + await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) + control.failure = 0 + control.empty = true + await page.getByRole('button', { name: 'Повторить загрузку', exact: true }).click() + await expect(page.getByText('Нет данных куба', { exact: true })).toBeVisible() + await expect(page.getByLabel('Сводка трафика')).toBeVisible() + await expect(page.getByText('Ошибка загрузки', { exact: true })).toHaveCount(0) + }) +} + +test('Статистика: смена периода скрывает старые строки и игнорирует поздний ответ', async ({ page }) => { + const control = await setup(page) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + control.hold = true + const waiting = page.waitForRequest(r => r.url().includes('/api/statistics?') && r.url().includes('from=2026-09-02')) + await page.evaluate(() => window.history.pushState(null, '', '/statistics?from=2026-09-02&to=2026-09-03')) + await waiting + await expect(page.getByText('Загрузка статистики…', { exact: true })).toBeVisible() + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toHaveCount(0) + await page.evaluate(() => window.history.pushState(null, '', '/statistics?from=2026-09-03&to=2026-09-03')) + await expect(page.getByText('Данные 2026-09-03', { exact: true })).toBeVisible() + const old = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('from=2026-09-02')) + control.release() + await old + await expect(page.getByText('Данные 2026-09-03', { exact: true })).toBeVisible() + await expect(page.getByText('Данные 2026-09-02', { exact: true })).toHaveCount(0) +}) + +test('Статистика: ошибка pivot не показывает метрики и строки прежнего отчёта', async ({ page }) => { + await setup(page) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + await page.route('**/api/statistics/pivot?**', route => route.fulfill({ status: 500, json: { error: 'Сводка недоступна' } })) + 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('Нет данных куба', { exact: true })).toHaveCount(0) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toHaveCount(0) + await page.screenshot({ path: 'test-results/statistics-error.png', animations: 'disabled' }) +})