fix(ui): подготовить проверку срезов статистики для FilterQuery
This commit is contained in:
1 parent
fca42aaa81
commit
52af82af42
3 files changed
+122
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user