fix(ui): сохранять и проверять контекст отчёта статистики

This commit is contained in:
Denozordec committed 2026-09-23 20:30:11 +07:00
1 parent 94ad73212b
commit e05b3a38c8
5 files changed
+117 -20

No files matched your search

+22 -20
View File
@@ -33,6 +33,7 @@ import {
isStatisticsSliceKind, isStatisticsSliceKind,
STATISTICS_DIMS, STATISTICS_DIMS,
} from "@/lib/statistics-dims" } from "@/lib/statistics-dims"
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
import { readStatisticsRange } from "@/lib/statistics-range" import { readStatisticsRange } from "@/lib/statistics-range"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate" import { fmtBps, formatBytes } from "@/lib/fmt-rate"
@@ -231,6 +232,7 @@ function StatisticsPageInner() {
const isLive = mode === "live" const isLive = mode === "live"
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams]) const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams]) const slices = useMemo(() => readSlices(searchParams), [searchParams])
const dim = useMemo(() => readDim(searchParams), [searchParams]) const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams])
@@ -239,17 +241,17 @@ function StatisticsPageInner() {
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams]) const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [retry, setRetry] = useState(0) 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<{ const [result, setResult] = useState<{
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
} | null>(null) } | null>(null)
const current = result?.key === requestKey ? result : null const current = result?.key === requestKey ? result : null
const loading = isLive && !rangeError && current === null const loading = isLive && !rangeError && !paramsError && current === null
const error = rangeError ?? current?.error ?? null const error = rangeError ?? paramsError ?? current?.error ?? null
const dataReady = isLive && current !== null && !error const dataReady = isLive && current !== null && !error
const pivot = dataReady ? current.pivot : EMPTY_PIVOT const pivot = dataReady ? current.pivot : EMPTY_PIVOT
const replaceParams = useCallback( const updateParams = useCallback(
(patch: Record<string, string | undefined>) => { (patch: Record<string, string | undefined>) => {
const sp = new URLSearchParams(window.location.search) const sp = new URLSearchParams(window.location.search)
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
@@ -258,21 +260,21 @@ function StatisticsPageInner() {
} }
const qs = sp.toString() const qs = sp.toString()
if (qs === searchParams.toString()) return 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], [searchParams],
) )
const setRange = useCallback( const setRange = useCallback(
(next: DateRangeYmd) => { (next: DateRangeYmd) => {
replaceParams({ from: next.from, to: next.to }) updateParams({ from: next.from, to: next.to })
}, },
[replaceParams], [updateParams],
) )
const setSlices = useCallback( const setSlices = useCallback(
(next: CubeSlices) => { (next: CubeSlices) => {
replaceParams({ updateParams({
country: next.country, country: next.country,
service: next.service, service: next.service,
asn: next.asn, asn: next.asn,
@@ -281,11 +283,11 @@ function StatisticsPageInner() {
iface: next.iface, iface: next.iface,
}) })
}, },
[replaceParams], [updateParams],
) )
useEffect(() => { useEffect(() => {
if (!prefsHydrated || !isLive || rangeError) return if (!prefsHydrated || !isLive || rangeError || paramsError) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
try { try {
@@ -311,7 +313,7 @@ function StatisticsPageInner() {
return () => { return () => {
cancelled = true 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 viewData = dataReady ? current.data : EMPTY
const sliced = hasAnySlice(slices) const sliced = hasAnySlice(slices)
@@ -327,7 +329,7 @@ function StatisticsPageInner() {
if (rowId === "__other__" || colId === "__other__") return if (rowId === "__other__" || colId === "__other__") return
let next = applyPivotDim(slices, pivotRow, rowId) let next = applyPivotDim(slices, pivotRow, rowId)
next = applyPivotDim(next, pivotCol, colId) next = applyPivotDim(next, pivotCol, colId)
replaceParams({ updateParams({
country: next.country, country: next.country,
service: next.service, service: next.service,
asn: next.asn, asn: next.asn,
@@ -366,8 +368,8 @@ function StatisticsPageInner() {
{error ? ( {error ? (
<Alert variant="destructive" role="alert"> <Alert variant="destructive" role="alert">
<AlertTitle>{rangeError ? "Некорректный период" : "Ошибка загрузки"}</AlertTitle> <AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
<AlertDescription>{error}{!rangeError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}</AlertDescription> <AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@@ -438,7 +440,7 @@ function StatisticsPageInner() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<SegmentedControl <SegmentedControl
value={view} value={view}
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })} onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
options={[ options={[
{ value: "explore", label: "Разрез" }, { value: "explore", label: "Разрез" },
{ value: "pivot", label: "Сводка" }, { value: "pivot", label: "Сводка" },
@@ -446,7 +448,7 @@ function StatisticsPageInner() {
/> />
<SegmentedControl <SegmentedControl
value={planes} value={planes}
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })} onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
options={[ options={[
{ value: "unique", label: "Уникальный" }, { value: "unique", label: "Уникальный" },
{ value: "all", label: "Все плоскости" }, { value: "all", label: "Все плоскости" },
@@ -456,7 +458,7 @@ function StatisticsPageInner() {
<DimensionSelect <DimensionSelect
label="Критерий" label="Критерий"
value={dim} value={dim}
onChange={(next) => replaceParams({ dim: next })} onChange={(next) => updateParams({ dim: next, tab: undefined })}
/> />
) : null} ) : null}
{view === "pivot" ? ( {view === "pivot" ? (
@@ -465,13 +467,13 @@ function StatisticsPageInner() {
label="Строки" label="Строки"
value={pivotRow} value={pivotRow}
exclude={pivotCol} exclude={pivotCol}
onChange={(next) => replaceParams({ pivotRow: next })} onChange={(next) => updateParams({ pivotRow: next })}
/> />
<PivotDimSelect <PivotDimSelect
label="Колонки" label="Колонки"
value={pivotCol} value={pivotCol}
exclude={pivotRow} exclude={pivotRow}
onChange={(next) => replaceParams({ pivotCol: next })} onChange={(next) => updateParams({ pivotCol: next })}
/> />
</> </>
) : null} ) : null}
@@ -489,7 +491,7 @@ function StatisticsPageInner() {
}} }}
/> />
{isLive && !dataReady ? ( {isLive && !dataReady ? (
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError ? "Выберите корректный период для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div> <div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
) : emptyCube ? ( ) : emptyCube ? (
<EmptyState <EmptyState
title="Нет данных куба" title="Нет данных куба"
+7
View File
@@ -29,6 +29,13 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.2Б — URL-контекст статистики (2026-09-23)
- Проверяются дубли/пустые значения, dim/tab, view, planes, измерения pivot, ID сервера, ASN и длина кода страны. Неверный срез блокирует запрос, а не удаляется из API-query с расширением отчёта. Совпадающие dim/tab поддержаны; смена dim удаляет устаревший tab. Сброс удаляет параметры контекста, сохраняя from/to, чужие ключи и hash.
- Дискретный выбор периода, среза и режима использует native pushState, читая свежие параметры URL. Back/Forward/reload восстанавливают отчёт; черновик Filters по-прежнему применяется явно.
- 5 unit-тестов периода/контекста, 13 E2E статистики, ESLint и production build/TypeScript прошли (34 страницы). Проверены ошибочные ссылки без API, сброс, история периода/среза и отсутствие запросов записи при навигации. Совместимость значений следует текущему API: ASN — целое, country — два символа; справочники не используются как обязательный валидатор.
- Далее 8.3: live/история трафика, завершение SSE и гонки запросов; затем адаптивная приёмка и единицы. На контрольной точке использовано 73% пятичасовой / 42% недельной квоты. Локальный коммит без push.
## Блок 8.2А — строгий период статистики (2026-09-23) ## Блок 8.2А — строгий период статистики (2026-09-23)
- lib/statistics-range.ts проверяет существование календарной даты, ISO-время с зоной, порядок границ, дубли/неполную пару from/to. Сравнение по времени, date-only конец включён как UTC-день; конец ограничен текущим временем, как в backend parseStatisticsPeriod. Отсутствие обеих границ означает пресет 7 дней, повреждённая пара не заменяется им молча. - lib/statistics-range.ts проверяет существование календарной даты, ISO-время с зоной, порядок границ, дубли/неполную пару from/to. Сравнение по времени, date-only конец включён как UTC-день; конец ограничен текущим временем, как в backend parseStatisticsPeriod. Отсутствие обеих границ означает пресет 7 дней, повреждённая пара не заменяется им молча.
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { statisticsContextError } from './statistics-context'
test('неверные параметры не превращаются в более широкий отчёт', () => {
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)
})
+29
View File
@@ -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
}
+49
View File
@@ -95,3 +95,52 @@ for (const query of [
expect(params.get('keep')).toBe('1') 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([])
})