diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 08da9d19..167034fb 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useState } from "react" import { PageHeader } from "@/components/page-header" import type { Backup, Server } from "@/lib/data" -import type { Filter } from "@/components/reui/filters" +import { createFilterQuery } from "@/components/reui/filters/filters-query" +import type { FilterQuery } from "@/components/reui/filters/filters-types" import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert" @@ -56,7 +57,7 @@ export default function BackupsPage() { const [backupList, setBackupList] = useState([]) const [kindFilter, setKindFilter] = useState("all") const [search, setSearch] = useState("") - const [advancedFilters, setAdvancedFilters] = useState([]) + const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(false) const [opBusy, setOpBusy] = useState(false) diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index 9659da9e..d75e119d 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -4,8 +4,10 @@ import { useState, useMemo, useEffect } from "react" import { PageHeader } from "@/components/page-header" import { DataPageToolbar } from "@/components/data-page-toolbar" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" -import type { Filter } from "@/components/reui/filters" -import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" +import { createFilterQuery } from "@/components/reui/filters/filters-query" +import { QueryFilters } from "@/components/data-grids/query-filters" +import type { FilterQuery } from "@/components/reui/filters/filters-types" +import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" import { BGP_AS_NAMES } from "@/lib/bgp/types" @@ -306,7 +308,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) { const [search, setSearch] = useState("") const [stateFilter, setStateFilter] = useState("all") const [typeFilter, setTypeFilter] = useState("all") - const [advancedFilters, setAdvancedFilters] = useState([]) + const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) const q = search.toLowerCase() const filtered = useMemo(() => { @@ -325,7 +327,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) { } return true }) - return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS) + return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS) }, [sessions, q, stateFilter, typeFilter, advancedFilters]) return ( @@ -343,9 +345,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) { { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length }, ], }} - filters={advancedFilters} - onFiltersChange={setAdvancedFilters} - filterFields={BGP_FILTER_FIELDS} + filterControl={} search={search} onSearchChange={setSearch} searchPlaceholder="IP, AS, описание…" diff --git a/components/backups/backups-history.tsx b/components/backups/backups-history.tsx index fce98be8..79b18dfc 100644 --- a/components/backups/backups-history.tsx +++ b/components/backups/backups-history.tsx @@ -1,8 +1,9 @@ "use client" -import type { Filter } from "@/components/reui/filters" +import { QueryFilters } from "@/components/data-grids/query-filters" +import type { FilterQuery } from "@/components/reui/filters/filters-types" import type { Backup } from "@/lib/data" -import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" +import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { BACKUP_FILTER_ACCESSORS, BACKUP_FILTER_FIELDS, @@ -33,8 +34,8 @@ export function BackupsHistory({ onKindFilterChange: (value: KindFilter) => void search: string onSearchChange: (value: string) => void - filters: Filter[] - onFiltersChange: (filters: Filter[]) => void + filters: FilterQuery + onFiltersChange: (filters: FilterQuery) => void onDownload: (id: string, filename: string) => void onRestore: (backup: Backup) => void onDelete: (backup: Backup) => void @@ -49,7 +50,7 @@ export function BackupsHistory({ [b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)), ) : byKind - const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS) + const filtered = applyFilterQuery(searched, filters, BACKUP_FILTER_ACCESSORS) const isEmptyAll = backups.length === 0 return ( @@ -64,9 +65,7 @@ export function BackupsHistory({ { value: "manual", label: "Вручную", count: manualCount }, ], }} - filters={filters} - onFiltersChange={onFiltersChange} - filterFields={BACKUP_FILTER_FIELDS} + filterControl={} search={search} onSearchChange={onSearchChange} searchPlaceholder="Поиск по файлу, серверу, заметке…" diff --git a/components/data-grids/query-filters.tsx b/components/data-grids/query-filters.tsx new file mode 100644 index 00000000..87296be7 --- /dev/null +++ b/components/data-grids/query-filters.tsx @@ -0,0 +1,14 @@ +"use client" + +import { Filters } from "@/components/reui/filters/filters" +import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types" +import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru" + +export function QueryFilters({ fields, query, onQueryChange }: { + fields: FilterField[] + query: FilterQuery + onQueryChange: (query: FilterQuery) => void +}) { + return +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 39f788f0..1fa9ec9c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -320,6 +320,14 @@ Production build блока 7: `npm run build` прошёл, 34 статичес Следующий блок — BGP и backups/backups-history на FilterQuery, затем statistics с явным ограничением на поддерживаемые API-срезы (AND равенств) и удаление старого Filters/fallback. Сохранить текущий блок отдельным локальным коммитом без push. +## Этап 4 — блок 12: Filters BGP и резервных копий (2026-09-14) + +BGP, backups и BackupsHistory используют FilterQuery и современный `QueryFilters` через `filterControl`. Поля переведены на FilterField; scalar-поля с несколькими вариантами используют select и операторы выбора. Фильтрация выполняется общим рекурсивным evaluator с сохранением AND/OR и черновиков. Для текстового поля «Сервер» добавлены поддерживаемые ReUI операции starts_with/ends_with, включая отрицание и сравнение без учёта регистра. + +Проверки: TypeScript — успешно; 8 профильных браузерных тестов BGP/OSPF/администрирования — passed, включая фильтрацию, изменение значения, AND и сброс; 7 unit-тестов evaluator — passed. Первый запуск tsx в sandbox завершился системной ошибкой uv_os_get_passwd ENOMEM; повтор вне sandbox прошёл. ESLint без ошибок в проверенных файлах, в BGP остаются 7 исходных предупреждений неиспользуемых импортов/StateBadge; известная исходная ошибка effect в backups/page не исправлялась этим блоком, lint всей этой страницы не заявляется чистым. + +После блока использовано 42% пятичасовой и 96% недельной квоты. Остались statistics, старый fallback DataPageToolbar и старые Filters/helper; после их удаления повторить общие проверки. Сохранить блок локальным коммитом без push. Этап 4 остаётся открытым. + ## Карта миграции (исходная инвентаризация этапа 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/apply-filter-query.test.ts b/lib/data-filters/apply-filter-query.test.ts index 465c3728..5c4a0b75 100644 --- a/lib/data-filters/apply-filter-query.test.ts +++ b/lib/data-filters/apply-filter-query.test.ts @@ -30,6 +30,12 @@ test("negative selection and empty values stay distinct", () => { assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "empty")]), accessors), []) }) +test("text prefix and suffix use case-insensitive matching and support negation", () => { + assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "starts_with", "r")]), accessors), [rows[0]]) + assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "ends_with", "e")]), accessors), [rows[1]]) + assert.deepEqual(applyFilterQuery(rows, createFilterQuery([{ ...rule("country", "starts_with", "r"), negated: true }]), accessors), rows.slice(1)) +}) + test("migration preserves the existing operators and implicit AND", () => { for (const operator of ["is", "isNot", "isAnyOf", "isNotAnyOf", "contains", "doesNotContain"]) { const filters = [{ id: "country", field: "country", operator, values: ["RU", "DE"] }, diff --git a/lib/data-filters/apply-filter-query.ts b/lib/data-filters/apply-filter-query.ts index 124c2580..6649eecd 100644 --- a/lib/data-filters/apply-filter-query.ts +++ b/lib/data-filters/apply-filter-query.ts @@ -21,6 +21,8 @@ function matchRule(row: T, rule: FilterRule, accessors: FilterAccessors): case "is_none_of": result = !values.some(v => String(actual) === String(v)); break case "contains": result = values.some(v => text(actual).includes(text(v))); break case "not_contains": result = !values.some(v => text(actual).includes(text(v))); break + case "starts_with": result = text(actual).startsWith(text(values[0])); break + case "ends_with": result = text(actual).endsWith(text(values[0])); break case "empty": result = empty; break case "not_empty": result = !empty; break // Unsupported conditions must never silently widen the result set. diff --git a/lib/data-filters/backup-filter-fields.ts b/lib/data-filters/backup-filter-fields.ts index ad1b182e..77110558 100644 --- a/lib/data-filters/backup-filter-fields.ts +++ b/lib/data-filters/backup-filter-fields.ts @@ -1,26 +1,26 @@ -import type { FilterFieldConfig } from "@/components/reui/filters" +import type { FilterField } from "@/components/reui/filters/filters-types" import type { Backup } from "@/lib/data" -export const BACKUP_FILTER_FIELDS: FilterFieldConfig[] = [ +export const BACKUP_FILTER_FIELDS: FilterField[] = [ { - key: "server", + id: "server", label: "Сервер", type: "text", placeholder: "Имя сервера", }, { - key: "kind", + id: "kind", label: "Тип", - type: "multiselect", + type: "select", options: [ { value: "auto", label: "авто" }, { value: "manual", label: "вручную" }, ], }, { - key: "storage", + id: "storage", label: "Хранилище", - type: "multiselect", + type: "select", options: [ { value: "local", label: "Локально" }, { value: "s3", label: "S3" }, diff --git a/lib/data-filters/bgp-filter-fields.ts b/lib/data-filters/bgp-filter-fields.ts index 3ec2785b..8fa2edf0 100644 --- a/lib/data-filters/bgp-filter-fields.ts +++ b/lib/data-filters/bgp-filter-fields.ts @@ -1,10 +1,10 @@ -import type { FilterFieldConfig } from "@/components/reui/filters" +import type { FilterField } from "@/components/reui/filters/filters-types" -export const BGP_FILTER_FIELDS: FilterFieldConfig[] = [ +export const BGP_FILTER_FIELDS: FilterField[] = [ { - key: "state", + id: "state", label: "Состояние", - type: "multiselect", + type: "select", options: [ { value: "Established", label: "Established" }, { value: "Active", label: "Active" }, @@ -15,18 +15,18 @@ export const BGP_FILTER_FIELDS: FilterFieldConfig[] = [ ], }, { - key: "type", + id: "type", label: "Тип", - type: "multiselect", + type: "select", options: [ { value: "eBGP", label: "eBGP" }, { value: "iBGP", label: "iBGP" }, ], }, { - key: "afi", + id: "afi", label: "AFI", - type: "multiselect", + type: "select", options: [ { value: "IPv4 Unicast", label: "IPv4 Unicast" }, { value: "IPv6 Unicast", label: "IPv6 Unicast" }, diff --git a/tests/e2e/admin-tables.spec.ts b/tests/e2e/admin-tables.spec.ts index 77759dc5..869b82a6 100644 --- a/tests/e2e/admin-tables.spec.ts +++ b/tests/e2e/admin-tables.spec.ts @@ -15,7 +15,7 @@ const certificates = certificatesListResponseSchema.parse({ failures: [], certif daysLeft: 300 + i, keySize: 2048, usage: ["server"], trusted: true, status: "valid", })) }) const backups: BackupItem[] = ids.map(i => ({ id: String(i), serverId: 1, serverName: "Тестовый роутер", - filename: `backup-${i}.backup`, sizeBytes: i === 12 ? 1048576 : i * 1024, createdAt: "2026-09-14T00:00:00Z", kind: "manual" })) + filename: `backup-${i}.backup`, sizeBytes: i === 12 ? 1048576 : i * 1024, createdAt: "2026-09-14T00:00:00Z", kind: i > 6 ? "auto" : "manual" })) async function setup(page: Page, url: string) { await page.route("**/health", route => route.fulfill({ json: { ok: true } })) @@ -70,3 +70,23 @@ test("Бэкапы: 12 строк, числовая сортировка и вы await page.getByRole("button", { name: "Отмена", exact: true }).click() await expect(rows).toHaveCount(12) }) + +test("Бэкапы FilterQuery: тип, изменение значения и сброс", async ({ page }) => { + await setup(page, "/backups") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(12) + await page.getByRole("button", { name: "Фильтры", exact: true }).click() + await page.getByRole("option", { name: "Тип", exact: true }).click() + await page.getByRole("option", { name: "равно", exact: true }).click() + await page.getByRole("option", { name: "авто", exact: true }).click() + await expect(rows).toHaveCount(6) + await expect(page.locator('tr[data-row-id="12"]')).toBeVisible() + await expect(page.locator('tr[data-row-id="1"]')).toHaveCount(0) + await page.getByRole("button", { name: "авто", exact: true }).click() + await page.getByRole("option", { name: "вручную", exact: true }).click() + await expect(rows).toHaveCount(6) + await expect(page.locator('tr[data-row-id="1"]')).toBeVisible() + await expect(page.locator('tr[data-row-id="12"]')).toHaveCount(0) + await page.getByRole("button", { name: "Очистить", exact: true }).click() + await expect(rows).toHaveCount(12) +}) diff --git a/tests/e2e/routing-tables.spec.ts b/tests/e2e/routing-tables.spec.ts index 36f6e930..8b4f58d9 100644 --- a/tests/e2e/routing-tables.spec.ts +++ b/tests/e2e/routing-tables.spec.ts @@ -15,7 +15,7 @@ const ospf = { } const bgp = ids.map(i => ({ ...host, id: String(i), name: `Пир ${i}`, peerIp: `192.0.2.${i}`, remoteAs: 65000 + i, localAs: 64512, localId: "192.0.2.100", remoteId: `192.0.2.${i}`, - state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, + state: i > 6 ? "Active" : "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: i, prefixesTx: i, inputMessages: i, outputMessages: i, capabilities: ["route-refresh"], lastError: null })) async function setup(page: Page, path: string, empty = false) { @@ -72,3 +72,22 @@ test("Пустые ответы API показывают пустые состо await expect(page.getByText(message, { exact: true })).toBeVisible() } }) + +test("BGP FilterQuery: выбор, AND и сброс", async ({ page }) => { + await setup(page, "/bgp") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(12) + await page.getByRole("button", { name: "Фильтры", exact: true }).click() + await page.getByRole("option", { name: "Состояние", exact: true }).click() + await page.getByRole("option", { name: "равно", exact: true }).click() + await page.getByRole("option", { name: "Active", exact: true }).click() + await expect(rows).toHaveCount(6) + await expect(rows.first()).toContainText("Пир 7") + await page.getByRole("button", { name: "Фильтры", exact: true }).click() + await page.getByRole("option", { name: "Тип", exact: true }).click() + await page.getByRole("option", { name: "равно", exact: true }).click() + await page.getByRole("option", { name: "iBGP", exact: true }).click() + await expect(rows).toHaveCount(0) + await page.getByRole("button", { name: "Очистить", exact: true }).click() + await expect(rows).toHaveCount(12) +})