fix(ui): проверять период статистики до запроса
This commit is contained in:
1 parent
167e19cd91
commit
94ad73212b
6 files changed
+109
-19
No files matched your search
@@ -33,6 +33,7 @@ import {
|
|||||||
isStatisticsSliceKind,
|
isStatisticsSliceKind,
|
||||||
STATISTICS_DIMS,
|
STATISTICS_DIMS,
|
||||||
} from "@/lib/statistics-dims"
|
} from "@/lib/statistics-dims"
|
||||||
|
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"
|
||||||
import {
|
import {
|
||||||
@@ -95,13 +96,6 @@ interface CubeSlices {
|
|||||||
|
|
||||||
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||||
|
|
||||||
function readRange(sp: URLSearchParams): DateRangeYmd {
|
|
||||||
const from = sp.get("from")
|
|
||||||
const to = sp.get("to")
|
|
||||||
if (from && to && from <= to) return { from, to }
|
|
||||||
return rangeForPreset("7d")
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||||
const t = sp.get("dim") ?? sp.get("tab")
|
const t = sp.get("dim") ?? sp.get("tab")
|
||||||
return t && isStatisticsSliceKind(t) ? t : "users"
|
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||||
@@ -236,7 +230,7 @@ function StatisticsPageInner() {
|
|||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const range = useMemo(() => readRange(searchParams), [searchParams])
|
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [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])
|
||||||
@@ -245,19 +239,19 @@ 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 })
|
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError })
|
||||||
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 && current === null
|
const loading = isLive && !rangeError && current === null
|
||||||
const error = current?.error ?? null
|
const error = rangeError ?? 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 replaceParams = useCallback(
|
||||||
(patch: Record<string, string | undefined>) => {
|
(patch: Record<string, string | undefined>) => {
|
||||||
const sp = new URLSearchParams(searchParams.toString())
|
const sp = new URLSearchParams(window.location.search)
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (v) sp.set(k, v)
|
if (v) sp.set(k, v)
|
||||||
else sp.delete(k)
|
else sp.delete(k)
|
||||||
@@ -291,7 +285,7 @@ function StatisticsPageInner() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prefsHydrated || !isLive) return
|
if (!prefsHydrated || !isLive || rangeError) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -317,7 +311,7 @@ function StatisticsPageInner() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey])
|
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError])
|
||||||
|
|
||||||
const viewData = dataReady ? current.data : EMPTY
|
const viewData = dataReady ? current.data : EMPTY
|
||||||
const sliced = hasAnySlice(slices)
|
const sliced = hasAnySlice(slices)
|
||||||
@@ -357,7 +351,7 @@ function StatisticsPageInner() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||||
actions={<PeriodSelector range={range} onChange={setRange} />}
|
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
@@ -372,8 +366,8 @@ function StatisticsPageInner() {
|
|||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive" role="alert">
|
<Alert variant="destructive" role="alert">
|
||||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
<AlertTitle>{rangeError ? "Некорректный период" : "Ошибка загрузки"}</AlertTitle>
|
||||||
<AlertDescription>{error}<Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button></AlertDescription>
|
<AlertDescription>{error}{!rangeError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -495,7 +489,7 @@ function StatisticsPageInner() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isLive && !dataReady ? (
|
{isLive && !dataReady ? (
|
||||||
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : "Статистика недоступна. Повторите загрузку."}</div>
|
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError ? "Выберите корректный период для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
||||||
) : emptyCube ? (
|
) : emptyCube ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет данных куба"
|
title="Нет данных куба"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DateSelector,
|
DateSelector,
|
||||||
type DateSelectorValue,
|
type DateSelectorValue,
|
||||||
} from "@/components/reui/date-selector"
|
} from "@/components/reui/date-selector"
|
||||||
|
import { isCalendarDate, isStatisticsDate } from "@/lib/statistics-range"
|
||||||
import { DATE_SELECTOR_RU } from "@/components/statistics/date-selector-i18n"
|
import { DATE_SELECTOR_RU } from "@/components/statistics/date-selector-i18n"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
@@ -42,14 +43,18 @@ export function formatYmd(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseYmd(s: string): Date | null {
|
export function parseYmd(s: string): Date | null {
|
||||||
|
if (!isCalendarDate(s)) return null
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||||||
if (!m) return null
|
if (!m) return null
|
||||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
const d = new Date(0)
|
||||||
|
d.setFullYear(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
|
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
|
||||||
export function parseRangeDate(s: string): Date | null {
|
export function parseRangeDate(s: string): Date | null {
|
||||||
|
if (!isStatisticsDate(s)) return null
|
||||||
if (s.includes("T")) {
|
if (s.includes("T")) {
|
||||||
const d = new Date(s)
|
const d = new Date(s)
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 8.2А — строгий период статистики (2026-09-23)
|
||||||
|
|
||||||
|
- lib/statistics-range.ts проверяет существование календарной даты, ISO-время с зоной, порядок границ, дубли/неполную пару from/to. Сравнение по времени, date-only конец включён как UTC-день; конец ограничен текущим временем, как в backend parseStatisticsPeriod. Отсутствие обеих границ означает пресет 7 дней, повреждённая пара не заменяется им молча.
|
||||||
|
- Некорректный период блокирует запрос статистики, скрывает метрики и предлагает выбрать последние 7 дней. Восстановление сохраняет срез и чужие параметры. DateSelector использует строгую проверку вместо нормализации 30 февраля через Date. При изменении параметров читается текущий window.location.search.
|
||||||
|
- 3 unit-теста, 9 E2E статистики и lint прошли. Production build с TypeScript прошёл: 34 страницы. Проверены 403/500/повтор, гонка периодов, pivot, неверные прямые ссылки, сохранение country/keep при восстановлении.
|
||||||
|
- Это клиентская валидация; backend API напрямую всё ещё использует прежний парсер. Следующий блок 8.2Б: история URL и валидация остальных параметров, чтобы неверный срез не расширял отчёт молча. На проверке использовано 60% пятичасовой / 41% недельной квоты. Коммит локальный без push.
|
||||||
|
|
||||||
## Блок 8.1 — достоверность состояния статистики (2026-09-23)
|
## Блок 8.1 — достоверность состояния статистики (2026-09-23)
|
||||||
|
|
||||||
- Новый план docs/ux-ui-stage-8.md с референсами и схемой. Результат статистики привязан к ключу backend/режим/период/срез/view/pivot/planes/повтор. Старый отчёт не выводится под новым контекстом; отменённый эффект не применяет поздний ответ.
|
- Новый план docs/ux-ui-stage-8.md с референсами и схемой. Результат статистики привязан к ключу backend/режим/период/срез/view/pivot/planes/повтор. Старый отчёт не выводится под новым контекстом; отменённый эффект не применяет поздний ответ.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { isCalendarDate, isStatisticsDate, readStatisticsRange } from './statistics-range'
|
||||||
|
|
||||||
|
const fallback = { from: '2026-09-01', to: '2026-09-07' }
|
||||||
|
const now = Date.parse('2026-09-23T12:00:00Z')
|
||||||
|
const read = (raw: string) => readStatisticsRange(new URLSearchParams(raw), fallback, now)
|
||||||
|
|
||||||
|
test('календарные даты проверяются без нормализации несуществующего дня', () => {
|
||||||
|
for (const date of ['2024-02-29', '2000-02-29', '2026-12-31']) assert.equal(isCalendarDate(date), true)
|
||||||
|
for (const date of ['2026-02-29', '1900-02-29', '2026-04-31', '2026-13-01', '0000-01-01']) assert.equal(isCalendarDate(date), false)
|
||||||
|
assert.equal(isStatisticsDate('2026-02-30T12:00:00Z'), false)
|
||||||
|
assert.equal(isStatisticsDate('2026-09-01T24:00:00Z'), false)
|
||||||
|
assert.equal(isStatisticsDate('2026-09-01T12:00:00'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('пустая ссылка использует пресет, повреждённая не принимается молча', () => {
|
||||||
|
assert.deepEqual(read(''), { range: fallback, error: null })
|
||||||
|
for (const raw of ['from=', 'to=2026-09-01', 'from=bad&to=bad', 'from=2026-09-01&from=2026-09-02&to=2026-09-03', 'from=2026-09-03&to=2026-09-01', 'from=2027-01-01&to=2027-01-02']) assert.ok(read(raw).error, raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('границы сравниваются по времени, date-only конец включён как в API', () => {
|
||||||
|
assert.equal(read('from=2026-09-01&to=2026-09-01').error, null)
|
||||||
|
const params = new URLSearchParams({ from: '2026-09-01T12:00:00+07:00', to: '2026-09-01T06:00:00Z' })
|
||||||
|
assert.equal(readStatisticsRange(params, fallback, now).error, null)
|
||||||
|
params.set('to', '2026-09-01T04:00:00Z')
|
||||||
|
assert.ok(readStatisticsRange(params, fallback, now).error)
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export function isCalendarDate(value: string): boolean {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||||
|
if (!match) return false
|
||||||
|
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3])
|
||||||
|
if (year < 1 || month < 1 || month > 12 || day < 1) return false
|
||||||
|
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
||||||
|
return day <= [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStatisticsDate(value: string): boolean {
|
||||||
|
if (isCalendarDate(value)) return true
|
||||||
|
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/.exec(value)
|
||||||
|
return Boolean(match && isCalendarDate(match[1]) && Number(match[2]) < 24 && Number(match[3]) < 60 && Number(match[4]) < 60 && Number.isFinite(Date.parse(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStatisticsRange(
|
||||||
|
params: URLSearchParams,
|
||||||
|
fallback: { from: string; to: string },
|
||||||
|
now = Date.now(),
|
||||||
|
): { range: { from: string; to: string }; error: string | null } {
|
||||||
|
const from = params.get('from'), to = params.get('to')
|
||||||
|
if (!params.has('from') && !params.has('to')) return { range: fallback, error: null }
|
||||||
|
const invalid = { range: fallback, error: 'Период в ссылке некорректен. Укажите две существующие даты или ISO-время с часовым поясом; начало должно быть раньше конца периода и текущего времени.' }
|
||||||
|
if (params.getAll('from').length !== 1 || params.getAll('to').length !== 1 || !from || !to || !isStatisticsDate(from) || !isStatisticsDate(to)) return invalid
|
||||||
|
const start = Date.parse(from.includes('T') ? from : from + 'T00:00:00Z')
|
||||||
|
// Backend treats date-only `to` as an inclusive UTC day and caps it at now.
|
||||||
|
const end = Date.parse(to.includes('T') ? to : to + 'T00:00:00Z') + (to.includes('T') ? 0 : 86400000)
|
||||||
|
if (start >= Math.min(end, now)) return invalid
|
||||||
|
return { range: { from, to }, error: null }
|
||||||
|
}
|
||||||
@@ -69,3 +69,29 @@ test('Статистика: ошибка pivot не показывает мет
|
|||||||
await expect(page.getByText('Данные 2026-09-01', { 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' })
|
await page.screenshot({ path: 'test-results/statistics-error.png', animations: 'disabled' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const query of [
|
||||||
|
'from=2026-02-30&to=2026-03-02',
|
||||||
|
'from=2026-09-01',
|
||||||
|
'from=2026-09-01&from=2026-09-02&to=2026-09-03',
|
||||||
|
'from=2026-09-03&to=2026-09-01',
|
||||||
|
]) {
|
||||||
|
test(`Статистика: некорректный период ${query} не запрашивает API`, 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?' + query + '&country=RU&keep=1')
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: 'Некорректный период' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
|
||||||
|
expect(requests).toHaveLength(before)
|
||||||
|
await page.getByRole('button', { name: 'Выбрать последние 7 дней', exact: true }).click()
|
||||||
|
await expect(page.getByLabel('Сводка трафика')).toBeVisible()
|
||||||
|
await expect(page.getByText('Некорректный период', { exact: true })).toHaveCount(0)
|
||||||
|
const params = new URL(page.url()).searchParams
|
||||||
|
expect(params.getAll('from')).toHaveLength(1)
|
||||||
|
expect(params.get('country')).toBe('RU')
|
||||||
|
expect(params.get('keep')).toBe('1')
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user