fix(ui): сохранять и проверять контекст отчёта статистики
This commit is contained in:
1 parent
94ad73212b
commit
e05b3a38c8
5 files changed
+117
-20
No files matched your search
@@ -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<string, string | undefined>) => {
|
||||
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 ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>{rangeError ? "Некорректный период" : "Ошибка загрузки"}</AlertTitle>
|
||||
<AlertDescription>{error}{!rangeError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}</AlertDescription>
|
||||
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
@@ -438,7 +440,7 @@ function StatisticsPageInner() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={(next) => 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() {
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={planes}
|
||||
onChange={(next) => 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() {
|
||||
<DimensionSelect
|
||||
label="Критерий"
|
||||
value={dim}
|
||||
onChange={(next) => 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 })}
|
||||
/>
|
||||
<PivotDimSelect
|
||||
label="Колонки"
|
||||
value={pivotCol}
|
||||
exclude={pivotRow}
|
||||
onChange={(next) => replaceParams({ pivotCol: next })}
|
||||
onChange={(next) => updateParams({ pivotCol: next })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -489,7 +491,7 @@ function StatisticsPageInner() {
|
||||
}}
|
||||
/>
|
||||
{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 ? (
|
||||
<EmptyState
|
||||
title="Нет данных куба"
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 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)
|
||||
|
||||
- lib/statistics-range.ts проверяет существование календарной даты, ISO-время с зоной, порядок границ, дубли/неполную пару from/to. Сравнение по времени, date-only конец включён как UTC-день; конец ограничен текущим временем, как в backend parseStatisticsPeriod. Отсутствие обеих границ означает пресет 7 дней, повреждённая пара не заменяется им молча.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
Reference in new issue
Block a user