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 остаётся открытым.
|
После блока использовано 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)
|
## Карта миграции (исходная инвентаризация этапа 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.
|
Текущая связка: @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