From e05b3a38c8e7f52c8a033c30d1c22c34a3525fd3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 23 Sep 2026 20:30:11 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/statistics/page.tsx | 42 +++++++++++++------------- docs/ux-ui-progress.md | 7 +++++ lib/statistics-context.test.ts | 10 +++++++ lib/statistics-context.ts | 29 ++++++++++++++++++ tests/e2e/statistics-data.spec.ts | 49 +++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 lib/statistics-context.test.ts create mode 100644 lib/statistics-context.ts diff --git a/app/(main)/statistics/page.tsx b/app/(main)/statistics/page.tsx index a827ad51..8b4f1519 100644 --- a/app/(main)/statistics/page.tsx +++ b/app/(main)/statistics/page.tsx @@ -33,6 +33,7 @@ import { isStatisticsSliceKind, STATISTICS_DIMS, } from "@/lib/statistics-dims" +import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context" import { readStatisticsRange } from "@/lib/statistics-range" import { useDataSource } from "@/lib/data-source" import { fmtBps, formatBytes } from "@/lib/fmt-rate" @@ -231,6 +232,7 @@ function StatisticsPageInner() { const isLive = mode === "live" const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams]) + const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams]) const slices = useMemo(() => readSlices(searchParams), [searchParams]) const dim = useMemo(() => readDim(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams]) @@ -239,17 +241,17 @@ function StatisticsPageInner() { const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams]) const [retry, setRetry] = useState(0) - const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError }) + const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError }) 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 && !rangeError && current === null - const error = rangeError ?? current?.error ?? null + const loading = isLive && !rangeError && !paramsError && current === null + const error = rangeError ?? paramsError ?? current?.error ?? null const dataReady = isLive && current !== null && !error const pivot = dataReady ? current.pivot : EMPTY_PIVOT - const replaceParams = useCallback( + const updateParams = useCallback( (patch: Record) => { const sp = new URLSearchParams(window.location.search) for (const [k, v] of Object.entries(patch)) { @@ -258,21 +260,21 @@ function StatisticsPageInner() { } const qs = sp.toString() if (qs === searchParams.toString()) return - window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics") + window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash) }, [searchParams], ) const setRange = useCallback( (next: DateRangeYmd) => { - replaceParams({ from: next.from, to: next.to }) + updateParams({ from: next.from, to: next.to }) }, - [replaceParams], + [updateParams], ) const setSlices = useCallback( (next: CubeSlices) => { - replaceParams({ + updateParams({ country: next.country, service: next.service, asn: next.asn, @@ -281,11 +283,11 @@ function StatisticsPageInner() { iface: next.iface, }) }, - [replaceParams], + [updateParams], ) useEffect(() => { - if (!prefsHydrated || !isLive || rangeError) return + if (!prefsHydrated || !isLive || rangeError || paramsError) return let cancelled = false void (async () => { try { @@ -311,7 +313,7 @@ function StatisticsPageInner() { return () => { cancelled = true } - }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError]) + }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError]) const viewData = dataReady ? current.data : EMPTY const sliced = hasAnySlice(slices) @@ -327,7 +329,7 @@ function StatisticsPageInner() { if (rowId === "__other__" || colId === "__other__") return let next = applyPivotDim(slices, pivotRow, rowId) next = applyPivotDim(next, pivotCol, colId) - replaceParams({ + updateParams({ country: next.country, service: next.service, asn: next.asn, @@ -366,8 +368,8 @@ function StatisticsPageInner() { {error ? ( - {rangeError ? "Некорректный период" : "Ошибка загрузки"} - {error}{!rangeError && } + {rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"} + {error}{!rangeError && !paramsError && }{paramsError && } ) : null} @@ -438,7 +440,7 @@ function StatisticsPageInner() {
replaceParams({ view: next === "pivot" ? "pivot" : "explore" })} + onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })} options={[ { value: "explore", label: "Разрез" }, { value: "pivot", label: "Сводка" }, @@ -446,7 +448,7 @@ function StatisticsPageInner() { /> replaceParams({ planes: next === "all" ? "all" : undefined })} + onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })} options={[ { value: "unique", label: "Уникальный" }, { value: "all", label: "Все плоскости" }, @@ -456,7 +458,7 @@ function StatisticsPageInner() { replaceParams({ dim: next })} + onChange={(next) => updateParams({ dim: next, tab: undefined })} /> ) : null} {view === "pivot" ? ( @@ -465,13 +467,13 @@ function StatisticsPageInner() { label="Строки" value={pivotRow} exclude={pivotCol} - onChange={(next) => replaceParams({ pivotRow: next })} + onChange={(next) => updateParams({ pivotRow: next })} /> replaceParams({ pivotCol: next })} + onChange={(next) => updateParams({ pivotCol: next })} /> ) : null} @@ -489,7 +491,7 @@ function StatisticsPageInner() { }} /> {isLive && !dataReady ? ( -
{loading ? "Загрузка статистики…" : rangeError ? "Выберите корректный период для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}
+
{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}
) : emptyCube ? ( { + for (const query of ['view=bad', 'planes=bad', 'dim=bad', 'tab=bad', 'pivotRow=bad', 'view=pivot&pivotRow=country&pivotCol=country', 'dim=users&tab=servers', 'serverId=NaN', 'serverId=1.5', 'serverId=0', 'asn=bad', 'country=Russia', 'country=RU&country=DE', 'userId=', 'service=']) assert.ok(statisticsContextError(new URLSearchParams(query)), query) +}) +test('поддерживаемые значения и чужие параметры сохраняют совместимость', () => { + for (const query of ['', 'serverId=42&asn=64512&country=RU&iface=ether1', 'dim=users&tab=users', 'view=pivot&pivotRow=server&pivotCol=country&planes=all', 'keep=1']) assert.equal(statisticsContextError(new URLSearchParams(query)), null, query) +}) diff --git a/lib/statistics-context.ts b/lib/statistics-context.ts new file mode 100644 index 00000000..9543af78 --- /dev/null +++ b/lib/statistics-context.ts @@ -0,0 +1,29 @@ +import { isStatisticsPivotDim, isStatisticsSliceKind } from './statistics-dims' + +export const STATISTICS_CONTEXT_KEYS = ['dim', 'tab', 'view', 'planes', 'pivotRow', 'pivotCol', 'country', 'service', 'asn', 'serverId', 'userId', 'iface'] as const + +export function statisticsContextError(params: URLSearchParams): string | null { + for (const key of STATISTICS_CONTEXT_KEYS) { + if (params.getAll(key).length > 1) return `Параметр ${key} указан несколько раз.` + const value = params.get(key) + if (value !== null && !value.trim()) return `Параметр ${key} пуст.` + } + for (const key of ['dim', 'tab']) { + const value = params.get(key) + if (value !== null && !isStatisticsSliceKind(value)) return 'Неизвестный разрез статистики.' + } + if (params.has('dim') && params.has('tab') && params.get('dim') !== params.get('tab')) return 'Параметры dim и tab задают разные разрезы.' + if (params.has('view') && !['explore', 'pivot'].includes(params.get('view')!)) return 'Неизвестный вид отчёта.' + if (params.has('planes') && !['unique', 'all'].includes(params.get('planes')!)) return 'Неизвестный режим учёта трафика.' + for (const key of ['pivotRow', 'pivotCol']) { + if (params.has(key) && !isStatisticsPivotDim(params.get(key)!)) return 'Неизвестное измерение сводки.' + } + if (params.get('view') === 'pivot' && (params.get('pivotRow') ?? 'country') === (params.get('pivotCol') ?? 'service')) return 'Выберите разные измерения строк и колонок.' + const serverId = params.get('serverId')?.trim() + if (serverId && (!/^\d+$/.test(serverId) || !Number.isSafeInteger(Number(serverId)) || Number(serverId) <= 0)) return 'ID сервера должен быть положительным целым числом.' + const asn = params.get('asn')?.trim() + if (asn && (!/^-?\d+$/.test(asn) || !Number.isSafeInteger(Number(asn)))) return 'ASN должен быть целым числом.' + const country = params.get('country')?.trim() + if (country && country.length !== 2) return 'Код страны должен состоять из двух символов.' + return null +} diff --git a/tests/e2e/statistics-data.spec.ts b/tests/e2e/statistics-data.spec.ts index 582a5e4a..bd1c29e5 100644 --- a/tests/e2e/statistics-data.spec.ts +++ b/tests/e2e/statistics-data.spec.ts @@ -95,3 +95,52 @@ for (const query of [ expect(params.get('keep')).toBe('1') }) } + +for (const query of ['serverId=bad', 'planes=unknown', 'country=RU&country=DE']) { + test(`Статистика: неверный контекст ${query} блокирует запрос до сброса`, async ({ page }) => { + const requests: string[] = [] + page.on('request', r => { if (new URL(r.url()).pathname === '/api/statistics') requests.push(r.url()) }) + await setup(page) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + const before = requests.length + await page.goto('/statistics?from=2026-09-01&to=2026-09-03&keep=1&' + query + '#report') + await expect(page.getByRole('alert').filter({ hasText: 'Параметры отчёта требуют внимания' })).toBeVisible() + await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) + expect(requests).toHaveLength(before) + await page.getByRole('button', { name: 'Сбросить параметры отчёта', exact: true }).click() + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + const url = new URL(page.url()) + expect(url.searchParams.get('from')).toBe('2026-09-01') + expect(url.searchParams.get('keep')).toBe('1') + expect(url.hash).toBe('#report') + await page.goBack() + await expect(page.getByRole('alert').filter({ hasText: 'Параметры отчёта требуют внимания' })).toBeVisible() + }) +} + +test('Статистика: история периода и выбранного среза восстанавливается без записи', async ({ page }) => { + const writes: string[] = [] + page.on('request', r => { if (!['GET', 'HEAD', 'OPTIONS'].includes(r.method())) writes.push(r.url()) }) + await setup(page) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + const original = page.url() + await page.getByRole('button', { name: '30 д', exact: true }).click() + await expect(page).not.toHaveURL(original) + await expect(page.locator('tr[data-row-id]')).toHaveCount(1) + const period = page.url() + await page.locator('tr[data-row-id]').click() + await expect(page).toHaveURL(/userId=/) + const slice = page.url() + await page.goBack() + await expect(page).toHaveURL(period) + await page.goBack() + await expect(page).toHaveURL(original) + await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible() + await page.goForward() + await page.goForward() + await expect(page).toHaveURL(slice) + await page.reload() + await expect(page).toHaveURL(slice) + await expect(page.getByRole('button', { name: 'Применить срез', exact: true })).toBeVisible() + expect(writes).toEqual([]) +})