From 94ad73212b0261c3cf4a03b226a42b3ecbf182d0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 23 Sep 2026 20:26:23 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/statistics/page.tsx | 30 +++++++++-------------- components/statistics/period-selector.tsx | 7 +++++- docs/ux-ui-progress.md | 7 ++++++ lib/statistics-range.test.ts | 28 +++++++++++++++++++++ lib/statistics-range.ts | 30 +++++++++++++++++++++++ tests/e2e/statistics-data.spec.ts | 26 ++++++++++++++++++++ 6 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 lib/statistics-range.test.ts create mode 100644 lib/statistics-range.ts diff --git a/app/(main)/statistics/page.tsx b/app/(main)/statistics/page.tsx index 84737f3f..a827ad51 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 { readStatisticsRange } from "@/lib/statistics-range" import { useDataSource } from "@/lib/data-source" import { fmtBps, formatBytes } from "@/lib/fmt-rate" import { @@ -95,13 +96,6 @@ interface CubeSlices { 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 { const t = sp.get("dim") ?? sp.get("tab") return t && isStatisticsSliceKind(t) ? t : "users" @@ -236,7 +230,7 @@ function StatisticsPageInner() { const { mode, backendUrl, prefsHydrated } = useDataSource() 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 dim = useMemo(() => readDim(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams]) @@ -245,19 +239,19 @@ 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 }) + const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError }) 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 loading = isLive && !rangeError && current === null + const error = rangeError ?? current?.error ?? null const dataReady = isLive && current !== null && !error const pivot = dataReady ? current.pivot : EMPTY_PIVOT const replaceParams = useCallback( (patch: Record) => { - const sp = new URLSearchParams(searchParams.toString()) + const sp = new URLSearchParams(window.location.search) for (const [k, v] of Object.entries(patch)) { if (v) sp.set(k, v) else sp.delete(k) @@ -291,7 +285,7 @@ function StatisticsPageInner() { ) useEffect(() => { - if (!prefsHydrated || !isLive) return + if (!prefsHydrated || !isLive || rangeError) return let cancelled = false void (async () => { try { @@ -317,7 +311,7 @@ function StatisticsPageInner() { return () => { 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 sliced = hasAnySlice(slices) @@ -357,7 +351,7 @@ function StatisticsPageInner() {
} + actions={rangeError ? : } />
@@ -372,8 +366,8 @@ function StatisticsPageInner() { {error ? ( - Ошибка загрузки - {error} + {rangeError ? "Некорректный период" : "Ошибка загрузки"} + {error}{!rangeError && } ) : null} @@ -495,7 +489,7 @@ function StatisticsPageInner() { }} /> {isLive && !dataReady ? ( -
{loading ? "Загрузка статистики…" : "Статистика недоступна. Повторите загрузку."}
+
{loading ? "Загрузка статистики…" : rangeError ? "Выберите корректный период для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}
) : emptyCube ? ( 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) +}) diff --git a/lib/statistics-range.ts b/lib/statistics-range.ts new file mode 100644 index 00000000..57a25077 --- /dev/null +++ b/lib/statistics-range.ts @@ -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 } +} diff --git a/tests/e2e/statistics-data.spec.ts b/tests/e2e/statistics-data.spec.ts index ad486f5d..582a5e4a 100644 --- a/tests/e2e/statistics-data.spec.ts +++ b/tests/e2e/statistics-data.spec.ts @@ -69,3 +69,29 @@ test('Статистика: ошибка pivot не показывает мет await expect(page.getByText('Данные 2026-09-01', { exact: true })).toHaveCount(0) 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') + }) +}