fix(ui): перенести фильтры BGP и бэкапов на FilterQuery
This commit is contained in:
1 parent
a475b5c6df
commit
fca42aaa81
11 files changed
+103
-34
No files matched your search
@@ -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<Backup[]>([])
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [opBusy, setOpBusy] = useState(false)
|
||||
|
||||
@@ -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<StateFilter>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => 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={<QueryFilters fields={BGP_FILTER_FIELDS} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="IP, AS, описание…"
|
||||
|
||||
@@ -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={<QueryFilters fields={BACKUP_FILTER_FIELDS} query={filters} onQueryChange={onFiltersChange} />}
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Поиск по файлу, серверу, заметке…"
|
||||
|
||||
@@ -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 <Filters fields={fields} query={query} onQueryChange={onQueryChange}
|
||||
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -21,6 +21,8 @@ function matchRule<T>(row: T, rule: FilterRule, accessors: FilterAccessors<T>):
|
||||
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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user