fix(ui): восстанавливать поиск и фильтры BGP из URL
This commit is contained in:
1 parent
5c307a5b6c
commit
aa678b9d5d
8 files changed
+220
-16
No files matched your search
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
export function validateBgpQuery(value: unknown): "valid" | "draft" | "invalid" {
|
||||
const ids = new Set<string>()
|
||||
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<string, string | null>) {
|
||||
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() : "")
|
||||
}
|
||||
Reference in new issue
Block a user