fix(ui): отделить ошибки статистики от пустых данных
This commit is contained in:
1 parent
3898881d8a
commit
167e19cd91
4 files changed
+135
-25
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
@@ -243,10 +244,16 @@ function StatisticsPageInner() {
|
|||||||
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||||
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||||
|
|
||||||
const [data, setData] = useState<StatisticsDto>(EMPTY)
|
const [retry, setRetry] = useState(0)
|
||||||
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
|
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry })
|
||||||
const [loading, setLoading] = useState(false)
|
const [result, setResult] = useState<{
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(
|
const replaceParams = useCallback(
|
||||||
(patch: Record<string, string | undefined>) => {
|
(patch: Record<string, string | undefined>) => {
|
||||||
@@ -287,37 +294,32 @@ function StatisticsPageInner() {
|
|||||||
if (!prefsHydrated || !isLive) return
|
if (!prefsHydrated || !isLive) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const query = toQuery(range, slices, planes)
|
const query = toQuery(range, slices, planes)
|
||||||
const dto = await getStatistics(backendUrl, query)
|
const dto = await getStatistics(backendUrl, query)
|
||||||
if (!cancelled) setData(dto)
|
if (cancelled) return
|
||||||
|
let matrix = EMPTY_PIVOT
|
||||||
if (view === "pivot" && pivotRow !== pivotCol) {
|
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||||
const matrix = await getStatisticsPivot(backendUrl, {
|
matrix = await getStatisticsPivot(backendUrl, {
|
||||||
...query,
|
...query,
|
||||||
row: pivotRow,
|
row: pivotRow,
|
||||||
col: pivotCol,
|
col: pivotCol,
|
||||||
metric: "bytes",
|
metric: "bytes",
|
||||||
})
|
})
|
||||||
if (!cancelled) setPivot(matrix)
|
|
||||||
}
|
}
|
||||||
|
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setData(EMPTY)
|
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
||||||
setPivot(EMPTY_PIVOT)
|
|
||||||
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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 sliced = hasAnySlice(slices)
|
||||||
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||||
|
|
||||||
@@ -369,13 +371,13 @@ function StatisticsPageInner() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive" role="alert">
|
||||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}<Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button></AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!slices.serverId && isLive && !emptyCube ? (
|
{!slices.serverId && dataReady && !emptyCube ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertTitle>Уникальный объём</AlertTitle>
|
<AlertTitle>Уникальный объём</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -384,7 +386,7 @@ function StatisticsPageInner() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<KpiStatGrid
|
{(dataReady || loading) && <KpiStatGrid
|
||||||
aria-label="Сводка трафика"
|
aria-label="Сводка трафика"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
skeletonCount={5}
|
skeletonCount={5}
|
||||||
@@ -432,9 +434,9 @@ function StatisticsPageInner() {
|
|||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
|
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
@@ -482,7 +484,7 @@ function StatisticsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
||||||
countLabel={countLabel}
|
countLabel={dataReady ? countLabel : undefined}
|
||||||
/>
|
/>
|
||||||
<SliceChips
|
<SliceChips
|
||||||
chips={chips}
|
chips={chips}
|
||||||
@@ -492,7 +494,9 @@ function StatisticsPageInner() {
|
|||||||
setSlices(next)
|
setSlices(next)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{emptyCube ? (
|
{isLive && !dataReady ? (
|
||||||
|
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : "Статистика недоступна. Повторите загрузку."}</div>
|
||||||
|
) : emptyCube ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет данных куба"
|
title="Нет данных куба"
|
||||||
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||||
|
|||||||
@@ -22,13 +22,20 @@
|
|||||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Не начат |
|
| 8 | Трафик и статистика | В работе: 8.1 — состояния статистики; далее периоды/URL и live-трафик |
|
||||||
| 9 | VPN, Firewall и Filters | Не начат |
|
| 9 | VPN, Firewall и Filters | Не начат |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Завершение этапа 7 — локальная приёмка (2026-09-23)
|
||||||
|
|
||||||
- 94 профильных E2E dashboard-data/servers/uptime-links прошли. Включены сквозные сценарии сервера и Ping-проверки из очереди дашборда, права, ошибки/повторы операций, URL-контекст, формы и адаптивность. Сценарий Ping с действием и возвратом уже существует в uptime-links.spec.ts («очередь проверок…»); ранее он ошибочно оставался в списке следующих шагов журнала.
|
- 94 профильных E2E dashboard-data/servers/uptime-links прошли. Включены сквозные сценарии сервера и Ping-проверки из очереди дашборда, права, ошибки/повторы операций, URL-контекст, формы и адаптивность. Сценарий Ping с действием и возвратом уже существует в uptime-links.spec.ts («очередь проверок…»); ранее он ошибочно оставался в списке следующих шагов журнала.
|
||||||
|
|||||||
@@ -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-моки не доказывают сбор и точность сетевой телеметрии.
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user