fix(ui): отделить ошибки статистики от пустых данных

This commit is contained in:
Denozordec committed 2026-09-23 20:22:18 +07:00
1 parent 3898881d8a
commit 167e19cd91
4 files changed
+135 -25

No files matched your search

+28 -24
View File
@@ -9,6 +9,7 @@ import {
ServerIcon,
UsersIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
@@ -243,10 +244,16 @@ function StatisticsPageInner() {
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [data, setData] = useState<StatisticsDto>(EMPTY)
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [retry, setRetry] = useState(0)
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry })
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 dataReady = isLive && current !== null && !error
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
const replaceParams = useCallback(
(patch: Record<string, string | undefined>) => {
@@ -287,37 +294,32 @@ function StatisticsPageInner() {
if (!prefsHydrated || !isLive) return
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const query = toQuery(range, slices, planes)
const dto = await getStatistics(backendUrl, query)
if (!cancelled) setData(dto)
if (cancelled) return
let matrix = EMPTY_PIVOT
if (view === "pivot" && pivotRow !== pivotCol) {
const matrix = await getStatisticsPivot(backendUrl, {
matrix = await getStatisticsPivot(backendUrl, {
...query,
row: pivotRow,
col: pivotCol,
metric: "bytes",
})
if (!cancelled) setPivot(matrix)
}
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
} catch (e: unknown) {
if (!cancelled) {
setData(EMPTY)
setPivot(EMPTY_PIVOT)
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes])
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey])
const viewData = isLive ? data : EMPTY
const viewData = dataReady ? current.data : EMPTY
const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
@@ -369,13 +371,13 @@ function StatisticsPageInner() {
) : null}
{error ? (
<Alert variant="destructive">
<Alert variant="destructive" role="alert">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription>{error}<Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button></AlertDescription>
</Alert>
) : null}
{!slices.serverId && isLive && !emptyCube ? (
{!slices.serverId && dataReady && !emptyCube ? (
<Alert>
<AlertTitle>Уникальный объём</AlertTitle>
<AlertDescription>
@@ -384,7 +386,7 @@ function StatisticsPageInner() {
</Alert>
) : null}
<KpiStatGrid
{(dataReady || loading) && <KpiStatGrid
aria-label="Сводка трафика"
isLoading={loading}
skeletonCount={5}
@@ -432,9 +434,9 @@ function StatisticsPageInner() {
iconClassName: "text-muted-foreground",
},
]}
/>
/>}
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
<DataPageCard>
<DataPageToolbar
@@ -482,7 +484,7 @@ function StatisticsPageInner() {
</div>
}
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
countLabel={countLabel}
countLabel={dataReady ? countLabel : undefined}
/>
<SliceChips
chips={chips}
@@ -492,7 +494,9 @@ function StatisticsPageInner() {
setSlices(next)
}}
/>
{emptyCube ? (
{isLive && !dataReady ? (
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : "Статистика недоступна. Повторите загрузку."}</div>
) : emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
+8 -1
View File
@@ -22,13 +22,20 @@
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Не начат |
| 8 | Трафик и статистика | В работе: 8.1 — состояния статистики; далее периоды/URL и live-трафик |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.1 — достоверность состояния статистики (2026-09-23)
- Новый план docs/ux-ui-stage-8.md с референсами и схемой. Результат статистики привязан к ключу backend/режим/период/срез/view/pivot/planes/повтор. Старый отчёт не выводится под новым контекстом; отменённый эффект не применяет поздний ответ.
- Ошибка API не показывает нулевые KPI, график и «Нет данных куба». Кнопка «Повторить загрузку» повторяет текущий запрос; подтверждённый пустой ответ по-прежнему показывает нули и пустое состояние. Данные и pivot публикуются после завершения всей цепочки запроса.
- Проверки: 4 новых E2E (403/500 + повтор с пустым ответом, задержка/гонка периодов, ошибка pivot) и существующий сценарий статистики из migrated-tables прошли. ESLint страницы/тестов чистый; production build с TypeScript прошёл, 34 страницы. Скриншот ошибки просмотрен. Использованы прежние Alert/Button/KpiStatGrid/DataGrid, без изменения темы/установленных примитивов.
- Ограничение: при сбое pivot сейчас скрыт весь отчёт, частично загруженный основной ответ отдельно не показывается. Последний успешный отчёт не сохраняется как отдельный доступный пользователю снимок. Следующий блок 8.2 — строгие периоды и URL, затем live/история трафика. На проверке использовано 44% пятичасовой / 38% недельной квоты. Коммит локальный без push.
## Завершение этапа 7 — локальная приёмка (2026-09-23)
- 94 профильных E2E dashboard-data/servers/uptime-links прошли. Включены сквозные сценарии сервера и Ping-проверки из очереди дашборда, права, ошибки/повторы операций, URL-контекст, формы и адаптивность. Сценарий Ping с действием и возвратом уже существует в uptime-links.spec.ts («очередь проверок…»); ранее он ошибочно оставался в списке следующих шагов журнала.
+28
View File
@@ -0,0 +1,28 @@
# Этап 8: трафик и статистика
Основа: установленные ReUI PRO/base-nova, Frame, KpiStatGrid, DataGrid, Filters и DateSelector. Каждый законченный блок — профильные проверки, журнал и локальный коммит без push.
| Блок | Результат | Приёмка |
| --- | --- | --- |
| 8.1 | Данные статистики привязаны к периоду/срезу; ошибка не равна нулю; повтор запроса | Задержка нового периода, 403/500, успешный пустой ответ, поздний ответ |
| 8.2 | Валидные периоды, URL-контекст, единицы | Ошибочные даты, история, срезы и pivot |
| 8.3 | Разделение live и истории трафика; состояния потока и polling | Разрыв SSE, смена интерфейса/периода, неизвестные значения и гонки |
| 8.4 | Связь графиков/таблиц и мобильная приёмка | Общий период/объект, пустые состояния, 320/390/1440 px |
| Область | Референс | Схема |
| --- | --- | --- |
| Фильтры и таблицы | [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Период → срез → график → строки того же запроса |
| Сводка | [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) | Контекст → подтверждённые метрики → детали |
```text
[Период] [Срезы] [История / live]
↓ один контекст запроса
[Загрузка / Ошибка + повтор / Подтверждённые данные]
[Метрики] → [График] → [Таблица] → [Уточнить срез]
```
Исходные наблюдения 2026-09-23:
- Statistics оставляет прежний data/pivot до ответа нового запроса; catch подставляет EMPTY, совмещая ошибку с нулями и «Нет данных куба».
- readRange сравнивает строки без строгой проверки даты; parseYmd нормализует несуществующие дни через JavaScript Date. Проверить в 8.2.
- useTrafficLive требует отдельной проверки завершения SSE и позднего ответа после abort в 8.3; это пока риск, а не доказанный дефект UI.
- Реальный IPFIX/RouterOS недоступен локально. HTTP/SSE-моки не доказывают сбор и точность сетевой телеметрии.
+71
View File
@@ -0,0 +1,71 @@
import { test, expect, type Page } from '@playwright/test'
import { statisticsDtoSchema } from '../../packages/contracts/src/statistics'
async function setup(page: Page, failure = 0) {
const control = { failure, empty: false, hold: false, release: () => {} }
const pending = new Promise<void>(resolve => { control.release = resolve })
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname !== '/api/statistics') return route.fulfill({ json: url.pathname === '/api/auth/config' ? { required: false } : url.pathname === '/api/servers' ? [] : {} })
const from = url.searchParams.get('from')!
const fail = control.failure
if (control.hold && from === '2026-09-02') await pending
if (fail) return route.fulfill({ status: fail, json: { error: 'Статистика временно недоступна' } })
const n = control.empty ? 0 : 1
const dto = statisticsDtoSchema.parse({
from, to: url.searchParams.get('to'), grain: 'day',
kpis: { bytes: n * 1000, packets: n * 10, avgBps: n * 100, users: n, servers: n, ifaces: n, topCountry: '', topService: '' },
series: [], servers: [], interfaces: [], countries: [], services: [], asns: [],
users: n ? [{ id: from, label: `Данные ${from}`, bytes: 1000, packets: 10, bps: 100, percent: 100 }] : [],
})
await route.fulfill({ json: dto })
})
await page.goto('/statistics?from=2026-09-01&to=2026-09-03&dim=users')
return control
}
for (const status of [403, 500]) {
test(`Статистика: ${status} не подменяется нулём; повтор получает подтверждённый пустой ответ`, async ({ page }) => {
const control = await setup(page, status)
await expect(page.getByRole('alert').filter({ hasText: 'Ошибка загрузки' })).toContainText('Статистика временно недоступна')
await expect(page.getByText('Нет данных куба', { exact: true })).toHaveCount(0)
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
control.failure = 0
control.empty = true
await page.getByRole('button', { name: 'Повторить загрузку', exact: true }).click()
await expect(page.getByText('Нет данных куба', { exact: true })).toBeVisible()
await expect(page.getByLabel('Сводка трафика')).toBeVisible()
await expect(page.getByText('Ошибка загрузки', { exact: true })).toHaveCount(0)
})
}
test('Статистика: смена периода скрывает старые строки и игнорирует поздний ответ', async ({ page }) => {
const control = await setup(page)
await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible()
control.hold = true
const waiting = page.waitForRequest(r => r.url().includes('/api/statistics?') && r.url().includes('from=2026-09-02'))
await page.evaluate(() => window.history.pushState(null, '', '/statistics?from=2026-09-02&to=2026-09-03'))
await waiting
await expect(page.getByText('Загрузка статистики…', { exact: true })).toBeVisible()
await expect(page.getByText('Данные 2026-09-01', { exact: true })).toHaveCount(0)
await page.evaluate(() => window.history.pushState(null, '', '/statistics?from=2026-09-03&to=2026-09-03'))
await expect(page.getByText('Данные 2026-09-03', { exact: true })).toBeVisible()
const old = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('from=2026-09-02'))
control.release()
await old
await expect(page.getByText('Данные 2026-09-03', { exact: true })).toBeVisible()
await expect(page.getByText('Данные 2026-09-02', { exact: true })).toHaveCount(0)
})
test('Статистика: ошибка pivot не показывает метрики и строки прежнего отчёта', async ({ page }) => {
await setup(page)
await expect(page.getByText('Данные 2026-09-01', { exact: true })).toBeVisible()
await page.route('**/api/statistics/pivot?**', route => route.fulfill({ status: 500, json: { error: 'Сводка недоступна' } }))
await page.getByRole('button', { name: 'Сводка', exact: true }).click()
await expect(page.getByRole('alert').filter({ hasText: 'Ошибка загрузки' })).toContainText('Сводка недоступна')
await expect(page.getByLabel('Сводка трафика')).toHaveCount(0)
await expect(page.getByText('Нет данных куба', { 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' })
})