From 52af82af4218edb62c6f4e6d5884ef5ca2e552ca Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 14 Sep 2026 22:12:15 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D1=83=20=D1=81=D1=80=D0=B5=D0=B7=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20FilterQuery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-progress.md | 10 +++ .../statistics-filter-query.test.ts | 41 +++++++++++ lib/data-filters/statistics-filter-query.ts | 71 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 lib/data-filters/statistics-filter-query.test.ts create mode 100644 lib/data-filters/statistics-filter-query.ts diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1fa9ec9c..f27eabe9 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. diff --git a/lib/data-filters/statistics-filter-query.test.ts b/lib/data-filters/statistics-filter-query.test.ts new file mode 100644 index 00000000..828a7533 --- /dev/null +++ b/lib/data-filters/statistics-filter-query.test.ts @@ -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" } }) +}) diff --git a/lib/data-filters/statistics-filter-query.ts b/lib/data-filters/statistics-filter-query.ts new file mode 100644 index 00000000..150e0560 --- /dev/null +++ b/lib/data-filters/statistics-filter-query.ts @@ -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> +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 } +}