fix(ui): подготовить проверку срезов статистики для FilterQuery

This commit is contained in:
Denozordec committed 2026-09-14 22:12:15 +07:00
1 parent fca42aaa81
commit 52af82af42
3 files changed
+122

No files matched your search

+10
View File
@@ -328,6 +328,16 @@ BGP, backups и BackupsHistory используют FilterQuery и соврем
После блока использовано 42% пятичасовой и 96% недельной квоты. Остались statistics, старый fallback DataPageToolbar и старые Filters/helper; после их удаления повторить общие проверки. Сохранить блок локальным коммитом без push. Этап 4 остаётся открытым.
## Этап 4 — подготовка блока 13: адаптер статистики (2026-09-14)
Добавлен независимый `statistics-filter-query.ts`: преобразование URL-срезов в FilterQuery и строгий обратный адаптер с результатами valid/draft/invalid. Поддерживается только AND равенств скалярным строкам; OR, отрицание, массивы, неизвестные поля и конфликтующие значения не преобразуются молча. Незавершённый черновик не возвращает частичный набор срезов. Страна нормализуется в двухбуквенный код, ASN/serverId проверяются как положительные безопасные целые; недопустимые значения не обрезаются и не очищаются до другого числа.
Проверки: 5 unit-тестов адаптера, ESLint и TypeScript — успешно. Адаптер пока не подключён к экрану: это подготовительный checkpoint, а не завершение миграции statistics.
Точный следующий шаг: создать StatisticsFilters с отдельным локальным черновиком FilterQuery и отображением ошибки адаптера; обновлять URL/запрос только при valid, сохранять прежние срезы при draft/invalid; синхронизировать внешний переход/сброс URL с черновиком. Полям дать только оператор is, остальные неподдерживаемые выражения всё равно проверять адаптером. Затем удалить старые slicesToFilters/filtersToSlices страницы, fallback DataPageToolbar, `components/reui/filters.tsx` и неиспользуемый старый evaluator после проверки всех импортов. Unit-тест совместимости со старым evaluator заменить самостоятельными ожидаемыми результатами. Завершить браузерными тестами URL/черновика/ошибки и общей регрессией, только затем закрывать этап 4.
Квота после подготовки: 49% пятичасовой и 97% недельной использовано. Сохранить подготовку локальным коммитом без push. Чужие изменения skill/AGENTS/.codex не включать.
## Карта миграции (исходная инвентаризация этапа 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.
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
import { filterQueryToSlices, slicesToFilterQuery } from "./statistics-filter-query"
const rule = (field: string, value?: unknown, operator = "is") => createFilterRule({ id: field, path: [field], operator, value })
test("URL slices round-trip without dropping dimensions", () => {
const slices = { country: "RU", service: "https", asn: "64512", serverId: "1", userId: "user-1", iface: "ether1" }
assert.deepEqual(filterQueryToSlices(slicesToFilterQuery(slices)), { status: "valid", slices })
assert.deepEqual(filterQueryToSlices(createFilterQuery()), { status: "valid", slices: {} })
})
test("nested AND preserves all dimensions and normalizes values", () => {
const query = createFilterQuery([rule("country", " ru "), createFilterQuery([rule("asn", "064512"), rule("service", " dns ")])])
assert.deepEqual(filterQueryToSlices(query), { status: "valid", slices: { country: "RU", asn: "64512", service: "dns" } })
})
test("unfinished edits never publish partial slices", () => {
for (const value of [undefined, "", " "]) {
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "RU"), rule("service", value)])), { status: "draft" })
}
})
test("OR, negation, unsupported operators and conflicting equalities are rejected", () => {
for (const query of [
createFilterQuery([rule("country", "RU"), rule("country", "DE")], "or"),
createFilterQuery([{ ...rule("country", "RU"), negated: true }]),
createFilterQuery([rule("service", "http", "contains")]),
createFilterQuery([rule("country", "RU"), rule("country", "DE")]),
createFilterQuery([rule("country", ["RU", "DE"])]),
createFilterQuery([rule("unknown", "value")]),
]) assert.equal(filterQueryToSlices(query).status, "invalid")
})
test("invalid values are rejected instead of silently rewritten", () => {
for (const [key, value] of [["country", "RUS"], ["asn", "AS64512"], ["serverId", "-1"], ["serverId", "1.2"], ["asn", "9007199254740992"]]) {
assert.equal(filterQueryToSlices(createFilterQuery([rule(key, value)])).status, "invalid")
}
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "ru"), rule("country", "RU")])), { status: "valid", slices: { country: "RU" } })
})
@@ -0,0 +1,71 @@
import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query"
import type { FilterNode, FilterQuery } from "@/components/reui/filters/filters-types"
export const STATISTICS_SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
export type StatisticsSlices = Partial<Record<typeof STATISTICS_SLICE_KEYS[number], string>>
export type StatisticsFilterResult =
| { status: "valid"; slices: StatisticsSlices }
| { status: "draft" }
| { status: "invalid"; error: string }
export function slicesToFilterQuery(slices: StatisticsSlices): FilterQuery {
return createFilterQuery(STATISTICS_SLICE_KEYS.flatMap(key => slices[key]
? [createFilterRule({ id: key, path: [key], operator: "is", value: slices[key] })] : []))
}
/** The statistics API accepts a conjunction of scalar equalities only.
* Never publish a partial draft or flatten OR into an unrelated API request. */
export function filterQueryToSlices(query: FilterQuery): StatisticsFilterResult {
const slices: StatisticsSlices = {}
let draft = false
let error: string | undefined
function visit(node: FilterNode) {
if (node.type === "group") {
if (node.combinator !== "and") {
error = "Статистика поддерживает только условия, объединённые «И»."
return
}
node.rules.forEach(visit)
return
}
const key = node.path[0] as keyof StatisticsSlices
if (node.path.length !== 1 || !STATISTICS_SLICE_KEYS.includes(key)) {
error = "Неизвестное поле среза статистики."
return
}
if (node.negated || (node.operator && node.operator !== "is")) {
error = "Для срезов статистики доступно только условие «равно»."
return
}
if (!node.operator || node.value === undefined || node.value === "") {
draft = true
return
}
if (typeof node.value !== "string") {
error = "Укажите одно текстовое значение среза."
return
}
let value = node.value.trim()
if (!value) { draft = true; return }
if (key === "country") {
if (!/^[a-z]{2}$/i.test(value)) { error = "Укажите двухбуквенный код страны."; return }
value = value.toUpperCase()
}
if (key === "asn" || key === "serverId") {
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) {
error = "ASN и идентификатор сервера должны быть положительными целыми числами."
return
}
value = String(Number(value))
}
if (slices[key] !== undefined && slices[key] !== value) {
error = "Для одного поля нельзя задать два разных значения среза."
return
}
slices[key] = value
}
visit(query)
if (error) return { status: "invalid", error }
if (draft) return { status: "draft" }
return { status: "valid", slices }
}