fix(ui): подключить фильтры статистики с проверкой срезов

This commit is contained in:
Denozordec committed 2026-09-14 22:27:12 +07:00
1 parent 52af82af42
commit 4fd844f977
5 files changed
+72 -37

No files matched your search

+2 -29
View File
@@ -26,8 +26,7 @@ import {
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { Filter } from "@/components/reui/filters"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
import {
isStatisticsPivotDim,
isStatisticsSliceKind,
@@ -129,29 +128,6 @@ function readSlices(sp: URLSearchParams): CubeSlices {
return next
}
function slicesToFilters(slices: CubeSlices): Filter[] {
return SLICE_KEYS.flatMap((key) => {
const val = slices[key]
if (!val) return []
return [{ id: key, field: key, operator: "is", values: [val] }]
})
}
function filtersToSlices(filters: Filter[]): CubeSlices {
const next: CubeSlices = {}
for (const f of filters) {
const raw = String(f.values[0] ?? "").trim()
if (!raw) continue
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
else if (f.field === "service") next.service = raw
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
else if (f.field === "serverId") next.serverId = raw
else if (f.field === "userId") next.userId = raw
else if (f.field === "iface") next.iface = raw
}
return next
}
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
@@ -261,7 +237,6 @@ function StatisticsPageInner() {
const range = useMemo(() => readRange(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams])
const filters = useMemo(() => slicesToFilters(slices), [slices])
const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams])
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
@@ -506,9 +481,7 @@ function StatisticsPageInner() {
) : null}
</div>
}
filters={filters}
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
filterFields={STATISTICS_FILTER_FIELDS}
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
countLabel={countLabel}
/>
<SliceChips
@@ -0,0 +1,27 @@
"use client"
import { useState } from "react"
import { QueryFilters } from "@/components/data-grids/query-filters"
import { Button } from "@/components/ui/button"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import { filterQueryToSlices, slicesToFilterQuery, type StatisticsSlices } from "@/lib/data-filters/statistics-filter-query"
/** Parent keys this editor by URL slices so external navigation resets drafts. */
export function StatisticsFilters({ slices, onApply }: {
slices: StatisticsSlices
onApply: (slices: StatisticsSlices) => void
}) {
const [query, setQuery] = useState(() => slicesToFilterQuery(slices))
const result = filterQueryToSlices(query)
return <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<QueryFilters fields={STATISTICS_FILTER_FIELDS} query={query} onQueryChange={setQuery} />
<Button size="sm" variant="outline" disabled={result.status !== "valid"}
onClick={() => { if (result.status === "valid") onApply(result.slices) }}>
Применить срез
</Button>
</div>
{result.status === "invalid" && <p role="alert" className="text-xs text-destructive">{result.error}</p>}
{result.status === "draft" && <p role="status" className="text-xs text-muted-foreground">Завершите условие. Текущий срез сохранён.</p>}
</div>
}
+8
View File
@@ -338,6 +338,14 @@ BGP, backups и BackupsHistory используют FilterQuery и соврем
Квота после подготовки: 49% пятичасовой и 97% недельной использовано. Сохранить подготовку локальным коммитом без push. Чужие изменения skill/AGENTS/.codex не включать.
## Этап 4 — блок 13: подключение Filters статистики (2026-09-14)
StatisticsFilters подключён вместо старого массива Filter. Поля предлагают только is, адаптер дополнительно отклоняет неподдерживаемые выражения. Локальный черновик не изменяет URL до явного «Применить срез»; invalid/draft блокируют применение и сохраняют действующие срезы. Ключ редактора зависит от URL-срезов, поэтому внешний переход сбрасывает черновик. Старые функции slicesToFilters/filtersToSlices страницы удалены.
TypeScript до и после первого браузерного прогона и scoped ESLint прошли. Расширенный браузерный сценарий статистики прошёл: сортировка/переход в срез, восстановление фильтра из URL, редактирование без изменения URL, применение, сброс, отклонение RUS без обрезания до RU и внешняя смена страны на DE. В первом расширенном прогоне исправлен неоднозначный локатор alert; повтор — 1 passed. Проверки через HTTP-моки.
На последнем чтении использовано 62% пятичасовой и 99% недельной квоты. Старые Filters/fallback/helper ещё физически остаются, поэтому этап 4 НЕ завершён. Следующий шаг — удалить неиспользуемый fallback DataPageToolbar и старые Filters/evaluator после проверки импортов, обновить тест совместимости, затем выполнить полную регрессию и production build. Сохранить этот блок локальным коммитом без push.
## Карта миграции (исходная инвентаризация этапа 0)
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
+14 -8
View File
@@ -1,46 +1,52 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
import type { FilterField } from "@/components/reui/filters/filters-types"
export const STATISTICS_FILTER_FIELDS: FilterFieldConfig[] = [
export const STATISTICS_FILTER_FIELDS: FilterField[] = [
{
key: "country",
id: "country",
label: "Страна",
type: "text",
placeholder: "ISO, напр. US",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
key: "service",
id: "service",
label: "Сервис",
type: "text",
placeholder: "https, dns…",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
key: "asn",
id: "asn",
label: "ASN",
type: "text",
placeholder: "номер ASN",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
key: "serverId",
id: "serverId",
label: "Сервер",
type: "text",
placeholder: "id сервера",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
key: "userId",
id: "userId",
label: "Пользователь",
type: "text",
placeholder: "id пользователя",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
key: "iface",
id: "iface",
label: "Интерфейс",
type: "text",
placeholder: "ether1",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
]
+21
View File
@@ -107,4 +107,25 @@ test("Статистика: CompactDataGrid сохраняет 12 строк, с
await expect(rows.first()).toContainText("Пользователь 12")
await rows.first().click()
await expect(page).toHaveURL(/userId=user-12/)
await expect(page.getByRole("button", { name: "user-12", exact: true })).toBeVisible()
await page.getByRole("button", { name: "user-12", exact: true }).click()
await page.getByPlaceholder("id пользователя").fill("user-2")
await page.getByPlaceholder("id пользователя").press("Enter")
await expect(page).toHaveURL(/userId=user-12/)
await page.getByRole("button", { name: "Применить срез", exact: true }).click()
await expect(page).toHaveURL(/userId=user-2/)
await page.getByRole("button", { name: "Очистить", exact: true }).click()
await expect(page).toHaveURL(/userId=user-2/)
await page.getByRole("button", { name: "Применить срез", exact: true }).click()
await expect(page).not.toHaveURL(/userId=/)
await page.goto("/statistics?from=2026-09-01&to=2026-09-13&country=RU")
await page.getByRole("button", { name: "RU", exact: true }).click()
await page.getByPlaceholder("ISO, напр. US").fill("RUS")
await page.getByPlaceholder("ISO, напр. US").press("Enter")
await expect(page.getByRole("alert").filter({ hasText: "Укажите двухбуквенный код страны." })).toBeVisible()
await expect(page.getByRole("button", { name: "Применить срез", exact: true })).toBeDisabled()
await expect(page).toHaveURL(/country=RU(?:&|$)/)
await page.evaluate(() => window.history.pushState(null, "", "/statistics?country=DE"))
await expect(page.getByRole("button", { name: "DE", exact: true })).toBeVisible()
await expect(page.getByText("Укажите двухбуквенный код страны.", { exact: true })).toHaveCount(0)
})