diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index ffea5159..1d9a1780 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -5,9 +5,8 @@ import { useRouter, useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" import { DataPageToolbar } from "@/components/data-page-toolbar" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" -import { createFilterQuery } from "@/components/reui/filters/filters-query" +import { useBgpUrlState } from "@/hooks/use-bgp-url-state" 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" @@ -37,8 +36,6 @@ import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-ra type BgpSession = BgpSessionRow type BgpTab = "sessions" | "routers" | "analytics" -type StateFilter = "all" | BgpState -type TypeFilter = "all" | BgpType const AS_NAMES = BGP_AS_NAMES // ─── mock data ──────────────────────────────────────────────────────────────── @@ -261,13 +258,11 @@ const BGP_FILTER_ACCESSORS = { } function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) { - const [search, setSearch] = useState("") - const [stateFilter, setStateFilter] = useState("all") - const [typeFilter, setTypeFilter] = useState("all") - const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) - + const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState() + const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions]) const q = search.toLowerCase() const filtered = useMemo(() => { + if (urlError) return [] const base = sessions.filter((s) => { if (stateFilter !== "all" && s.state !== stateFilter) return false if (typeFilter !== "all" && s.type !== typeFilter) return false @@ -275,7 +270,7 @@ function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q) && - !s.serverLabel.includes(q) && + !s.serverLabel.toLowerCase().includes(q) && !String(s.remoteAs).includes(q) && !(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q) ) { @@ -284,10 +279,11 @@ function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession return true }) return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS) - }, [sessions, q, stateFilter, typeFilter, advancedFilters]) + }, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError]) return (
+ {urlError && {urlError}} s.state === "Unknown").length }, { value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length }, { value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length }, { value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length }, { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length }, ], }} - filterControl={} + filterControl={} search={search} onSearchChange={setSearch} searchPlaceholder="IP, AS, описание…" countLabel={`${filtered.length} из ${sessions.length}`} actions={
- {(["all", "eBGP", "iBGP"] as const).map((t) => ( + {(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => ( ))}
} /> - + {hasFilters && !urlError && } +
) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f8636f7f..50b15d80 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,18 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.6, BGP ASN/type; далее фильтры/layout | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.7, BGP фильтры URL; далее layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.7 — URL-фильтры BGP (2026-09-30) + +- Поиск, тип, состояние и завершённый FilterQuery хранятся в URL. Draft не сериализуется как готовое условие; поиск использует replace, изменения фильтра — push. Back/reload восстанавливают срез и сохраняют устройство/сессию/сторонние параметры. +- Валидатор проверяет поля, operators/arity/значения, IDs, глубину/размер дерева и повторные URL-ключи. Некорректная ссылка явно блокирует расширение списка и предлагает сброс. AFI options получаются из текущих строк API/mock; сравнение точное, без подмены фактических семейств статическим IPv4. +- Доступны фильтры Unknown state/type, type-кнопки имеют aria-pressed. Поиск имён устройств учитывает регистр. +- 21 E2E прошёл, четыре unit URL проверили roundtrip/nesting/negation/draft/invalid/limits, TypeScript/lint чистые. Квота: 74% окна использовано, 27% недельного. Далее — desktop/mobile BGP, production build; затем каталог OSPF. Локальный коммит без push. + ## Блок 10.6 — неизвестные AS и тип BGP (2026-09-30) - Новый catalog parser возвращает null для отсутствующей/некорректной AS, проверяет диапазон 1–4294967295; тип Unknown при недостаточном контексте. Legacy parser и его consumers сохранены. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 3bfe2a57..3e4f8063 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -64,3 +64,7 @@ server/session/tab и возврат/reload сохраняют контекст; ## Блок 10.6 — BGP ASN/type Отсутствующие/невалидные ASN и тип неизвестны; legacy parser сохранён. Unit/15 E2E/TypeScript/lint прошли. Далее — URL фильтров и layout. + +## Блок 10.7 — фильтры BGP URL + +Поиск/тип/состояние/FilterQuery сохраняются и валидируются; AFI варианты соответствуют снимку. 21 E2E, четыре unit, TypeScript/lint прошли. Далее — desktop/mobile и сборка BGP, затем OSPF. diff --git a/hooks/use-bgp-url-state.ts b/hooks/use-bgp-url-state.ts new file mode 100644 index 00000000..c1fb472d --- /dev/null +++ b/hooks/use-bgp-url-state.ts @@ -0,0 +1,49 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useSearchParams } from "next/navigation" +import type { FilterQuery } from "@/components/reui/filters/filters-types" +import { encodeBgpQuery, patchBgpUrl, readBgpUrl, validateBgpQuery, type BgpTypeFilter, type BgpStateFilter } from "@/lib/data-filters/bgp-url-state" + +export function useBgpUrlState() { + const params = useSearchParams() + const raw = params.toString() + const state = useMemo(() => readBgpUrl(new URLSearchParams(raw)), [raw]) + const filterKey = params.get("filters") + const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null) + useEffect(() => { + const reset = () => setDraft(null) + window.addEventListener("popstate", reset) + return () => window.removeEventListener("popstate", reset) + }, []) + function write(changes: Record, replace = false) { + const href = patchBgpUrl(new URLSearchParams(window.location.search), changes) + if (href === window.location.pathname + window.location.search) return + if (replace) window.history.replaceState(null, "", href) + else window.history.pushState(null, "", href) + } + function setFilters(query: FilterQuery) { + if (validateBgpQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return } + try { + const encoded = encodeBgpQuery(query) + setDraft(null) + write({ filters: encoded }) + } catch (error) { + setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." }) + } + } + const currentDraft = draft?.key === filterKey ? draft : null + return { + search: state.search, + typeFilter: state.type, + stateFilter: state.state, + filters: currentDraft?.query ?? state.query, + error: state.error ?? currentDraft?.error ?? null, + hasFilters: params.has("q") || params.has("type") || params.has("state") || params.has("filters") || currentDraft !== null, + setSearch: (value: string) => write({ q: value }, true), + setTypeFilter: (value: BgpTypeFilter) => write({ type: value === "all" ? null : value }), + setStateFilter: (value: BgpStateFilter) => write({ state: value === "all" ? null : value }), + setFilters, + reset: () => { setDraft(null); write({ q: null, type: null, state: null, filters: null }) }, + } +} diff --git a/lib/data-filters/bgp-filter-fields.ts b/lib/data-filters/bgp-filter-fields.ts index 8fa2edf0..11b7472e 100644 --- a/lib/data-filters/bgp-filter-fields.ts +++ b/lib/data-filters/bgp-filter-fields.ts @@ -6,6 +6,7 @@ export const BGP_FILTER_FIELDS: FilterField[] = [ label: "Состояние", type: "select", options: [ + { value: "Unknown", label: "Неизвестно" }, { value: "Established", label: "Established" }, { value: "Active", label: "Active" }, { value: "Idle", label: "Idle" }, @@ -19,6 +20,7 @@ export const BGP_FILTER_FIELDS: FilterField[] = [ label: "Тип", type: "select", options: [ + { value: "Unknown", label: "Тип неизвестен" }, { value: "eBGP", label: "eBGP" }, { value: "iBGP", label: "iBGP" }, ], diff --git a/lib/data-filters/bgp-url-state.test.ts b/lib/data-filters/bgp-url-state.test.ts new file mode 100644 index 00000000..deedcba8 --- /dev/null +++ b/lib/data-filters/bgp-url-state.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { emptyBgpQuery, encodeBgpQuery, patchBgpUrl, readBgpUrl, validateBgpQuery } from "./bgp-url-state" +import type { FilterQuery } from "../../components/reui/filters/filters-types" +const query = (): FilterQuery => ({ ...emptyBgpQuery(), combinator: "or", rules: [ + { id: "a", type: "rule", path: ["state"], operator: "is_any_of", value: ["Active", "Idle"], negated: true }, + { id: "nested", type: "group", combinator: "and", rules: [{ id: "b", type: "rule", path: ["type"], operator: "is", value: "Unknown" }] }, +] }) +test("URL roundtrip preserves groups, negation, context and unrelated keys", () => { + const href = patchBgpUrl(new URLSearchParams("bgp=2&keep=1"), { q: "Тест & /", type: "eBGP", filters: encodeBgpQuery(query()) }) + const params = new URL(href, "http://test").searchParams + assert.deepEqual(readBgpUrl(params), { search: "Тест & /", type: "eBGP", state: "all", query: query(), error: null }) + assert.equal(patchBgpUrl(params, { q: null, type: null, filters: null }), "/bgp?bgp=2&keep=1") +}) +test("draft conditions never serialize as a completed filter", () => { + const draft = { ...emptyBgpQuery(), rules: [{ id: "draft", type: "rule" as const, path: ["type"], operator: "is", value: undefined }] } + assert.equal(validateBgpQuery(draft), "draft") + assert.throws(() => encodeBgpQuery(draft)) + assert.ok(readBgpUrl(new URLSearchParams({ filters: JSON.stringify({ v: 1, query: draft }) })).error) +}) +test("invalid URL variants are rejected instead of widening a selection", () => { + for (const filters of ["{", "null", JSON.stringify({ v: 2, query: query() }), " ".repeat(8193), JSON.stringify({ v: 1, query: { ...query(), rules: [{ id: "x", type: "rule", path: ["password"], operator: "is", value: "Active" }] } })]) { + assert.ok(readBgpUrl(new URLSearchParams({ filters })).error) + } + assert.ok(readBgpUrl(new URLSearchParams("type=unexpected")).error) + assert.ok(readBgpUrl(new URLSearchParams("type=eBGP&type=iBGP")).error) +}) +test("limits, IDs, operator arity and values are validated", () => { + const rule = { id: "r", type: "rule", path: ["state"], operator: "is", value: "Active" } + for (const rules of [[rule, rule], [{ ...rule, operator: "unknown" }], [{ ...rule, value: "Unexpected" }], [{ ...rule, operator: "is_any_of" }], [{ ...rule, operator: "empty" }], Array.from({ length: 51 }, (_, i) => ({ ...rule, id: String(i) }))]) { + assert.equal(validateBgpQuery({ ...emptyBgpQuery(), rules }), "invalid") + } + let nested: FilterQuery = emptyBgpQuery() + for (let i = 0; i < 6; i++) nested = { id: "group" + i, type: "group", combinator: "and", rules: [nested] } + assert.equal(validateBgpQuery(nested), "invalid") + assert.equal(encodeBgpQuery(emptyBgpQuery()), null) +}) diff --git a/lib/data-filters/bgp-url-state.ts b/lib/data-filters/bgp-url-state.ts new file mode 100644 index 00000000..24005da0 --- /dev/null +++ b/lib/data-filters/bgp-url-state.ts @@ -0,0 +1,75 @@ +import type { FilterQuery } from "@/components/reui/filters/filters-types" +import { BGP_FILTER_FIELDS } from "./bgp-filter-fields" + +export type BgpTypeFilter = "all" | "eBGP" | "iBGP" | "Unknown" +export const emptyBgpQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] }) +const types = ["eBGP", "iBGP", "Unknown"] +const states = ["Established", "Active", "Idle", "Connect", "OpenSent", "OpenConfirm", "Unknown"] +export type BgpStateFilter = "all" | "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" | "Unknown" +const MAX_BYTES = 8192 +const options = new Map(BGP_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))])) +const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) + +export function validateBgpQuery(value: unknown): "valid" | "draft" | "invalid" { + const ids = new Set() + let count = 0 + let draft = false + function visit(node: unknown, depth: number): boolean { + if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false + ids.add(node.id) + if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1)) + if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false + const fieldId = node.path[0] + const allowed = options.get(fieldId) + const accepts = (value: string) => fieldId === "afi" ? value.trim().length > 0 && value.length <= 256 : Boolean(allowed?.has(value)) + if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false + if (node.operator === "" || node.operator === undefined) { draft = true; return true } + if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined + if (!["is", "is_not", "is_any_of", "is_none_of"].includes(String(node.operator))) return false + if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true } + if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && accepts(node.value) + return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && accepts(item)) + } + if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid" + return draft ? "draft" : "valid" +} + +export function encodeBgpQuery(query: FilterQuery): string | null { + if (validateBgpQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.") + if (query.rules.length === 0) return null + const encoded = JSON.stringify({ v: 1, query }) + if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.") + return encoded +} + +export function readBgpUrl(params: URLSearchParams) { + const search = params.get("q") ?? "" + const rawState = params.get("state") + const state: BgpStateFilter = states.includes(rawState ?? "") ? rawState as BgpStateFilter : "all" + const rawType = params.get("type") + const type: BgpTypeFilter = types.includes(rawType ?? "") ? rawType as BgpTypeFilter : "all" + let error: string | null = rawType !== null && !types.includes(rawType) ? "В ссылке указан неизвестный тип BGP." : null + if (rawState !== null && !states.includes(rawState)) error = "В ссылке указано неизвестное состояние BGP." + let query = emptyBgpQuery() + const raw = params.get("filters") + if (["q", "type", "state", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка." + if (raw !== null) { + try { + if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error() + const parsed: unknown = JSON.parse(raw) + if (!record(parsed) || parsed.v !== 1 || validateBgpQuery(parsed.query) !== "valid") throw new Error() + query = parsed.query as FilterQuery + } catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." } + } + return { search, type, state, query, error } +} + +/** Update only owned keys; retain object context and parameters from other features. */ +export function patchBgpUrl(params: URLSearchParams, changes: Record) { + const next = new URLSearchParams(params) + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === "") next.delete(key) + else next.set(key, value) + } + return "/bgp" + (next.size ? "?" + next.toString() : "") +} diff --git a/tests/e2e/routing-tables.spec.ts b/tests/e2e/routing-tables.spec.ts index 0744f09a..a0a56e9c 100644 --- a/tests/e2e/routing-tables.spec.ts +++ b/tests/e2e/routing-tables.spec.ts @@ -91,3 +91,34 @@ test("BGP FilterQuery: выбор, AND и сброс", async ({ page }) => { await page.getByRole("button", { name: "Очистить", exact: true }).click() await expect(rows).toHaveCount(12) }) + +test("BGP: фильтры и поиск из URL переживают reload и возврат", async ({ page }) => { + await setup(page, "/bgp?keep=x&state=Active&type=eBGP&q=Пир%207") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + await expect(rows.first()).toContainText("Пир 7") + await page.reload() + await expect(rows).toHaveCount(1) + await page.getByRole("button", { name: "Сбросить фильтры BGP", exact: true }).click() + await expect(rows).toHaveCount(12) + await page.goBack() + await expect(rows).toHaveCount(1) + 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.poll(() => new URL(page.url()).searchParams.has("filters")).toBe(true) + await page.reload() + await expect(rows).toHaveCount(1) + expect(new URL(page.url()).searchParams.get("keep")).toBe("x") +}) +for (const invalid of ["state=broken", "type=broken", "q=a&q=b", "filters=%7B", "filters=" + encodeURIComponent(JSON.stringify({ v: 1, query: { id: "root", type: "group", combinator: "and", rules: [{ id: "x", type: "rule", path: ["password"], operator: "is", value: "x" }] } }))]) { + test("BGP: некорректный URL-фильтр не расширяет срез " + invalid.slice(0, 30), async ({ page }) => { + await setup(page, "/bgp?keep=x&" + invalid) + await expect(page.getByRole("alert").filter({ hasText: "ссылк" })).toBeVisible() + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить фильтры BGP", exact: true }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(12) + expect(new URL(page.url()).searchParams.get("keep")).toBe("x") + }) +}