feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { applyFilterQuery } from "./apply-filter-query"
|
||||
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
|
||||
|
||||
const rows = [{ country: "RU", status: "online" }, { country: "DE", status: "offline" }, { country: "NL", status: "offline" }]
|
||||
const accessors = { country: (r: typeof rows[number]) => r.country, status: (r: typeof rows[number]) => r.status }
|
||||
const rule = (field: string, operator: string, value?: unknown) => createFilterRule({ id: field, path: [field], operator, value })
|
||||
|
||||
test("scalar select supports multiple allowed values and AND with status", () => {
|
||||
const query = createFilterQuery([rule("country", "is_any_of", ["RU", "DE"]), rule("status", "is", "offline")])
|
||||
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
|
||||
})
|
||||
test("nested OR preserves grouping and negation", () => {
|
||||
const query = createFilterQuery([createFilterQuery([rule("country", "is", "RU"), rule("country", "is", "DE")], "or", "countries"), { ...rule("status", "is", "online"), negated: true }])
|
||||
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
|
||||
})
|
||||
test("drafts do not make OR true, an entirely empty query keeps the list", () => {
|
||||
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is", "RU"), rule("status", "is")], "or"), accessors), [rows[0]])
|
||||
assert.deepEqual(applyFilterQuery(rows, createFilterQuery(), accessors), rows)
|
||||
})
|
||||
test("unknown fields and operators never broaden the selection", () => {
|
||||
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "unsupported", "RU")]), accessors), [])
|
||||
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("unknown", "is", "RU")]), accessors), [])
|
||||
})
|
||||
test("negative selection and empty values stay distinct", () => {
|
||||
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is_none_of", ["RU", "NL"])]), accessors), [rows[1]])
|
||||
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("scalar operators keep their expected results when combined with AND", () => {
|
||||
const cases: [string, unknown, typeof rows][] = [
|
||||
["is", "RU", []],
|
||||
["is_not", "RU", [rows[1], rows[2]]],
|
||||
["is_any_of", ["RU", "DE"], [rows[1]]],
|
||||
["is_none_of", ["RU", "DE"], [rows[2]]],
|
||||
["contains", "e", [rows[1]]],
|
||||
["not_contains", "e", [rows[2]]],
|
||||
]
|
||||
for (const [operator, value, expected] of cases) {
|
||||
const query = createFilterQuery([rule("country", operator, value), rule("status", "is_not", "online")])
|
||||
assert.deepEqual(applyFilterQuery(rows, query, accessors), expected)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FilterNode, FilterQuery, FilterRule } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export type FilterAccessors<T> = Record<string, (row: T) => unknown>
|
||||
|
||||
/** Undefined is a draft, not a matching branch (especially inside OR). */
|
||||
function matchRule<T>(row: T, rule: FilterRule, accessors: FilterAccessors<T>): boolean | undefined {
|
||||
if (!rule.operator) return undefined
|
||||
const accessor = accessors[rule.path.join(".")]
|
||||
if (!accessor) return false
|
||||
const actual = accessor(row)
|
||||
const value = rule.value
|
||||
const values = Array.isArray(value) ? value : value === undefined ? [] : [value]
|
||||
const empty = actual == null || actual === "" || (Array.isArray(actual) && actual.length === 0)
|
||||
if (!["empty", "not_empty"].includes(rule.operator) && (value === undefined || values.length === 0)) return undefined
|
||||
const text = (v: unknown) => String(v ?? "").toLocaleLowerCase()
|
||||
let result: boolean
|
||||
switch (rule.operator) {
|
||||
case "is": result = String(actual) === String(values[0]); break
|
||||
case "is_not": result = String(actual) !== String(values[0]); break
|
||||
case "is_any_of": result = values.some(v => String(actual) === String(v)); break
|
||||
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.
|
||||
default: return false
|
||||
}
|
||||
return rule.negated ? !result : result
|
||||
}
|
||||
|
||||
function matchNode<T>(row: T, node: FilterNode, accessors: FilterAccessors<T>): boolean | undefined {
|
||||
if (node.type === "rule") return matchRule(row, node, accessors)
|
||||
const results = node.rules.map(child => matchNode(row, child, accessors)).filter(value => value !== undefined)
|
||||
if (results.length === 0) return undefined
|
||||
return node.combinator === "and" ? results.every(Boolean) : results.some(Boolean)
|
||||
}
|
||||
|
||||
/** Evaluate the tree recursively: flattening loses group combinators. */
|
||||
export function applyFilterQuery<T>(rows: T[], query: FilterQuery, accessors: FilterAccessors<T>): T[] {
|
||||
return rows.filter(row => matchNode(row, query, accessors) !== false)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
import type { Backup } from "@/lib/data"
|
||||
|
||||
export const BACKUP_FILTER_FIELDS: FilterField[] = [
|
||||
{
|
||||
id: "server",
|
||||
label: "Сервер",
|
||||
type: "text",
|
||||
placeholder: "Имя сервера",
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: "Тип",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "auto", label: "авто" },
|
||||
{ value: "manual", label: "вручную" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: "Хранилище",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "local", label: "Локально" },
|
||||
{ value: "s3", label: "S3" },
|
||||
{ value: "both", label: "Локально + S3" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const BACKUP_FILTER_ACCESSORS = {
|
||||
server: (b: Backup) => b.server,
|
||||
kind: (b: Backup) => b.kind,
|
||||
storage: (b: Backup) => b.storage,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { emptyBackupQuery, encodeBackupQuery, patchBackupsUrl, readBackupsUrl, validateBackupQuery } from "./backups-url-state"
|
||||
const query = () => ({ ...emptyBackupQuery(), rules: [{ id: "rule-1", type: "rule" as const, path: ["server"], operator: "contains", value: "Москва & /" }] })
|
||||
test("backup context roundtrips text/select filters and retains foreign keys", () => {
|
||||
const href = patchBackupsUrl(new URLSearchParams("keep=1"), { q: "Тест & /", kind: "manual", tab: "settings", filters: encodeBackupQuery(query()) })
|
||||
const params = new URL(href, "https://example.test").searchParams, result = readBackupsUrl(params)
|
||||
assert.equal(params.get("keep"), "1"); assert.equal(result.tab, "settings"); assert.equal(result.kind, "manual"); assert.equal(result.search, "Тест & /"); assert.deepEqual(result.query, query()); assert.equal(result.error, null)
|
||||
const select = { ...query(), rules: [{ id: "rule-1", type: "rule" as const, path: ["storage"], operator: "is_any_of", value: ["s3", "both"] }] }
|
||||
assert.equal(validateBackupQuery(select), "valid"); assert.equal(encodeBackupQuery(emptyBackupQuery()), null)
|
||||
})
|
||||
test("bad, duplicate and oversized URL parameters remain visible errors", () => {
|
||||
for (const raw of ["tab=bad", "kind=bad", "kind=auto&kind=manual", "q=a&q=b", "filters={}", "filters=bad", "q=" + "x".repeat(201)]) assert.ok(readBackupsUrl(new URLSearchParams(raw)).error)
|
||||
for (const patch of [{ path: ["unknown"] }, { operator: "gt" }, { value: 1 }, { path: ["storage"], operator: "is", value: "unknown" }]) assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], ...patch }] }), "invalid")
|
||||
assert.equal(validateBackupQuery({ ...query(), rules: [query().rules[0], query().rules[0]] }), "invalid")
|
||||
assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }), "draft"); assert.throws(() => encodeBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }))
|
||||
const tooMany = { ...query(), rules: Array.from({ length: 51 }, (_, i) => ({ ...query().rules[0], id: "r" + i })) }; assert.equal(validateBackupQuery(tooMany), "invalid")
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { BACKUP_FILTER_FIELDS } from "./backup-filter-fields"
|
||||
|
||||
export type BackupKindFilter = "all" | "auto" | "manual"
|
||||
export type BackupsTab = "history" | "settings"
|
||||
export const emptyBackupQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
|
||||
const MAX_BYTES = 8192
|
||||
const options = new Map(BACKUP_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 validateBackupQuery(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 allowed = options.get(node.path[0])
|
||||
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
|
||||
const text = node.path[0] === "server"
|
||||
const operators = text ? ["contains", "not_contains", "starts_with", "ends_with", "is", "is_not"] : ["is", "is_not", "is_any_of", "is_none_of"]
|
||||
if (!operators.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 (text) return typeof node.value === "string" && node.value.trim().length > 0 && node.value.length <= 256
|
||||
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && allowed.has(node.value)
|
||||
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
|
||||
}
|
||||
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
|
||||
return draft ? "draft" : "valid"
|
||||
}
|
||||
|
||||
export function encodeBackupQuery(query: FilterQuery): string | null {
|
||||
if (validateBackupQuery(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 readBackupsUrl(params: URLSearchParams) {
|
||||
const search = params.get("q") ?? "", rawKind = params.get("kind"), rawTab = params.get("tab")
|
||||
const kind: BackupKindFilter = rawKind === "auto" || rawKind === "manual" ? rawKind : "all"
|
||||
const tab: BackupsTab = rawTab === "settings" ? "settings" : "history"
|
||||
let error: string | null = null
|
||||
if ((rawKind !== null && !["auto", "manual"].includes(rawKind)) || (rawTab !== null && !["history", "settings"].includes(rawTab))) error = "В ссылке указан неизвестный тип или раздел резервных копий."
|
||||
if (search.length > 200) error = "Поиск в ссылке слишком длинный."
|
||||
let query = emptyBackupQuery()
|
||||
const raw = params.get("filters")
|
||||
if (["q", "kind", "tab", "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 || validateBackupQuery(parsed.query) !== "valid") throw new Error()
|
||||
query = parsed.query as FilterQuery
|
||||
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
|
||||
}
|
||||
return { search, kind, tab, query, error }
|
||||
}
|
||||
|
||||
/** Update only owned keys; retain object context and parameters from other features. */
|
||||
export function patchBackupsUrl(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 "/backups" + (next.size ? "?" + next.toString() : "")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export const BGP_FILTER_FIELDS: FilterField[] = [
|
||||
{
|
||||
id: "state",
|
||||
label: "Состояние",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "Unknown", label: "Неизвестно" },
|
||||
{ value: "Established", label: "Established" },
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Idle", label: "Idle" },
|
||||
{ value: "Connect", label: "Connect" },
|
||||
{ value: "OpenSent", label: "OpenSent" },
|
||||
{ value: "OpenConfirm", label: "OpenConfirm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
label: "Тип",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "Unknown", label: "Тип неизвестен" },
|
||||
{ value: "eBGP", label: "eBGP" },
|
||||
{ value: "iBGP", label: "iBGP" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "afi",
|
||||
label: "AFI",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "IPv4 Unicast", label: "IPv4 Unicast" },
|
||||
{ value: "IPv6 Unicast", label: "IPv6 Unicast" },
|
||||
{ value: "VPNv4 Unicast", label: "VPNv4 Unicast" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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() : "")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FilterLabels } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export const FILTER_LABELS_RU: FilterLabels = {
|
||||
addFilter: "Фильтры", advancedFilter: "Расширенные фильтры", showRecords: "Показать записи",
|
||||
builderEmpty: "Фильтров пока нет", builderEmptyHint: "Добавьте условие, чтобы уточнить список.",
|
||||
addCondition: "Добавить условие", addConditionGroup: "Добавить группу", addToGroup: "Добавить условие в группу",
|
||||
removeGroup: "Удалить группу", wrapInGroup: "Объединить в группу", ungroup: "Разгруппировать",
|
||||
moveToTopLevel: "На верхний уровень", moveToGroup: n => `В группу ${n}`,
|
||||
reorder: "Изменить порядок", reorderHint: "Alt + стрелка вверх или вниз — изменить порядок",
|
||||
groupAll: "Выполнены все условия", groupAny: "Выполнено любое условие", groupPlaceholder: "Перетащите условия сюда",
|
||||
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
|
||||
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
|
||||
groupAnnouncement: added => added ? "Группа добавлена" : "Группа удалена",
|
||||
reorderAnnouncement: (label, position, total) => `${label}: позиция ${position} из ${total}`,
|
||||
moveAnnouncement: (label, destination, position, total) => `${label}: ${destination}, позиция ${position} из ${total}`,
|
||||
clearAll: "Сбросить фильтры", groupMenu: "Действия с группой",
|
||||
searchFields: "Поиск поля…", searchOperators: "Поиск условия…", searchOptions: "Поиск значения…",
|
||||
back: "Назад", clear: "Очистить", apply: "Применить", discard: "Отменить изменения",
|
||||
empty: "Ничего не найдено", loading: "Загрузка…", loadingMore: "Загрузка…", loadMore: "Загрузить ещё",
|
||||
error: "Не удалось загрузить", retry: "Повторить", where: "Где", and: "И", or: "ИЛИ",
|
||||
combinator: "Изменить связь условий", combinatorLabel: word => `${word}, изменить связь условий`,
|
||||
duplicate: "Дублировать", negate: "Инвертировать", convertToAdvanced: "Расширенный редактор", remove: "Удалить",
|
||||
chipMenu: field => `Действия с фильтром «${field}»`, filtersLabel: "Фильтры", filterLabel: condition => condition,
|
||||
readOnly: "Фильтры доступны только для чтения.", pathSeparator: " > ", valuePlaceholder: "Введите значение…",
|
||||
selectPlaceholder: "Выберите…", noValue: "нет значения", selectCondition: "Выберите условие",
|
||||
incomplete: "незавершённый фильтр", branchAffordance: "открывает список",
|
||||
exclusiveHint: "нельзя сочетать с другими значениями",
|
||||
exclusiveAnnouncement: (label, cleared) => `Выбрано ${label}. Снято других значений: ${cleared}.`,
|
||||
itemCount: count => `Элементов: ${count}`, fieldsLabel: "Поля",
|
||||
resultsAnnouncement: count => `Найдено: ${count}`, actionsLabel: "Действия",
|
||||
stepAnnouncement: (step, label) => `${step === "field" ? "Выберите поле" : step === "operator" ? "Выберите условие" : "Введите значение"}. ${label}`,
|
||||
countAnnouncement: count => `Применено фильтров: ${count}`, valueCount: count => `Выбрано: ${count}`,
|
||||
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`, valueRange: (from, to) => `${from} — ${to}`,
|
||||
rangeFrom: field => `${field}: от`, rangeTo: field => `${field}: до`, rangeSeparator: "до",
|
||||
negated: operator => `не (${operator})`, issueOperator: "Выберите условие", issueValue: "Введите значение",
|
||||
issueRange: "Укажите обе границы", issueRangeOrder: "Конец диапазона меньше начала",
|
||||
issueEmptyGroup: "В группе пока нет условий", issueSummary: count => `Требуют внимания: ${count}`,
|
||||
}
|
||||
|
||||
export const FILTER_OPERATOR_LABELS_RU = {
|
||||
is: "равно", is_not: "не равно", is_any_of: "любой из", is_none_of: "ни один из",
|
||||
contains: "содержит", not_contains: "не содержит", empty: "не задано", not_empty: "задано",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
|
||||
export const SERVER_FILTER_FIELDS: FilterField[] = [
|
||||
{
|
||||
id: "country",
|
||||
label: "Страна",
|
||||
type: "select",
|
||||
searchable: true,
|
||||
options: [
|
||||
{ value: "RU", label: "RU" },
|
||||
{ value: "DE", label: "DE" },
|
||||
{ value: "NL", label: "NL" },
|
||||
{ value: "SG", label: "SG" },
|
||||
{ value: "FI", label: "FI" },
|
||||
{ value: "US", label: "US" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Статус",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "online", label: "Online" },
|
||||
{ value: "degraded", label: "Degraded" },
|
||||
{ value: "offline", label: "Offline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
label: "Тип",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "jump-host", label: "JumpHost" },
|
||||
{ value: "exit-node", label: "Exit Node" },
|
||||
{ value: "home-router", label: "Home Router" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const SERVER_FILTER_ACCESSORS = {
|
||||
country: (s: Server) => s.country ?? "",
|
||||
status: (s: Server) => s.status,
|
||||
type: (s: Server) => s.type as ServerType,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { emptyServerQuery, encodeServerQuery, patchServerUrl, readServerUrl, validateServerQuery } from "./server-url-state"
|
||||
import type { FilterQuery } from "../../components/reui/filters/filters-types"
|
||||
const query = (): FilterQuery => ({ ...emptyServerQuery(), combinator: "or", rules: [
|
||||
{ id: "a", type: "rule", path: ["country"], operator: "is_any_of", value: ["RU", "DE"], negated: true },
|
||||
{ id: "nested", type: "group", combinator: "and", rules: [{ id: "b", type: "rule", path: ["status"], operator: "is", value: "offline" }] },
|
||||
] })
|
||||
test("URL roundtrip preserves groups, negation, context and unrelated keys", () => {
|
||||
const href = patchServerUrl(new URLSearchParams("server=2&keep=1"), { q: "Тест & /", type: "exit-node", filters: encodeServerQuery(query()) })
|
||||
const params = new URL(href, "http://test").searchParams
|
||||
assert.deepEqual(readServerUrl(params), { search: "Тест & /", type: "exit-node", query: query(), error: null })
|
||||
assert.equal(patchServerUrl(params, { q: null, type: null, filters: null }), "/servers?server=2&keep=1")
|
||||
})
|
||||
test("draft conditions never serialize as a completed filter", () => {
|
||||
const draft = { ...emptyServerQuery(), rules: [{ id: "draft", type: "rule" as const, path: ["status"], operator: "is", value: undefined }] }
|
||||
assert.equal(validateServerQuery(draft), "draft")
|
||||
assert.throws(() => encodeServerQuery(draft))
|
||||
assert.ok(readServerUrl(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: "RU" }] } })]) {
|
||||
assert.ok(readServerUrl(new URLSearchParams({ filters })).error)
|
||||
}
|
||||
assert.ok(readServerUrl(new URLSearchParams("type=unexpected")).error)
|
||||
assert.ok(readServerUrl(new URLSearchParams("type=exit-node&type=jump-host")).error)
|
||||
})
|
||||
test("limits, IDs, operator arity and values are validated", () => {
|
||||
const rule = { id: "r", type: "rule", path: ["country"], operator: "is", value: "RU" }
|
||||
for (const rules of [[rule, rule], [{ ...rule, operator: "unknown" }], [{ ...rule, value: "XX" }], [{ ...rule, operator: "is_any_of" }], [{ ...rule, operator: "empty" }], Array.from({ length: 51 }, (_, i) => ({ ...rule, id: String(i) }))]) {
|
||||
assert.equal(validateServerQuery({ ...emptyServerQuery(), rules }), "invalid")
|
||||
}
|
||||
let nested: FilterQuery = emptyServerQuery()
|
||||
for (let i = 0; i < 6; i++) nested = { id: "group" + i, type: "group", combinator: "and", rules: [nested] }
|
||||
assert.equal(validateServerQuery(nested), "invalid")
|
||||
assert.equal(encodeServerQuery(emptyServerQuery()), null)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { SERVER_FILTER_FIELDS } from "./server-filter-fields"
|
||||
|
||||
export type ServerTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
||||
export const emptyServerQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
|
||||
const types = ["jump-host", "exit-node", "home-router"]
|
||||
const MAX_BYTES = 8192
|
||||
const options = new Map(SERVER_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 validateServerQuery(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 allowed = options.get(node.path[0])
|
||||
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" && allowed.has(node.value)
|
||||
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
|
||||
}
|
||||
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
|
||||
return draft ? "draft" : "valid"
|
||||
}
|
||||
|
||||
export function encodeServerQuery(query: FilterQuery): string | null {
|
||||
if (validateServerQuery(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 readServerUrl(params: URLSearchParams) {
|
||||
const search = params.get("q") ?? ""
|
||||
const rawType = params.get("type")
|
||||
const type: ServerTypeFilter = types.includes(rawType ?? "") ? rawType as ServerTypeFilter : "all"
|
||||
let error: string | null = rawType !== null && !types.includes(rawType) ? "В ссылке указан неизвестный тип сервера." : null
|
||||
let query = emptyServerQuery()
|
||||
const raw = params.get("filters")
|
||||
if (["q", "type", "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 || validateServerQuery(parsed.query) !== "valid") throw new Error()
|
||||
query = parsed.query as FilterQuery
|
||||
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
|
||||
}
|
||||
return { search, type, query, error }
|
||||
}
|
||||
|
||||
/** Update only owned keys; retain object context and parameters from other features. */
|
||||
export function patchServerUrl(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 "/servers" + (next.size ? "?" + next.toString() : "")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export const STATISTICS_FILTER_FIELDS: FilterField[] = [
|
||||
{
|
||||
id: "country",
|
||||
label: "Страна",
|
||||
type: "text",
|
||||
placeholder: "ISO, напр. US",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
label: "Сервис",
|
||||
type: "text",
|
||||
placeholder: "https, dns…",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
label: "ASN",
|
||||
type: "text",
|
||||
placeholder: "номер ASN",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
id: "serverId",
|
||||
label: "Сервер",
|
||||
type: "text",
|
||||
placeholder: "id сервера",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
id: "userId",
|
||||
label: "Пользователь",
|
||||
type: "text",
|
||||
placeholder: "id пользователя",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
label: "Интерфейс",
|
||||
type: "text",
|
||||
placeholder: "ether1",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
]
|
||||
@@ -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" } })
|
||||
})
|
||||
@@ -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<Record<typeof STATISTICS_SLICE_KEYS[number], string>>
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { readUptimeUrl, patchUptimeUrl } from "./uptime-url-state"
|
||||
|
||||
test("вкладка и фильтры Ping восстанавливаются из ссылки", () => {
|
||||
const state = readUptimeUrl(new URLSearchParams({ tab: "speed", q: "узел", status: "down", servers: '["1","x/2"]' }))
|
||||
assert.deepEqual(state, { tab: "speed", search: "узел", status: "down", servers: ["1", "x/2"], error: null })
|
||||
})
|
||||
test("некорректные и повторяющиеся параметры дают ошибку", () => {
|
||||
for (const raw of ['tab=unknown', 'status=online', 'q=a&q=b', 'servers={}', 'servers=[1]', 'servers=["1","1"]', 'servers=[""]']) {
|
||||
assert.ok(readUptimeUrl(new URLSearchParams(raw)).error, raw)
|
||||
}
|
||||
})
|
||||
test("сброс оставляет пробу, вкладку и чужие параметры", () => {
|
||||
const result = patchUptimeUrl(new URLSearchParams('probe=p%2F2&keep=1&tab=speed&q=a&status=down&servers=%5B%221%22%5D'), { q: null, status: null, servers: null })
|
||||
assert.equal(result, '/uptime?probe=p%2F2&keep=1&tab=speed')
|
||||
})
|
||||
|
||||
test("фильтры Resources независимы от Ping и валидируются", async () => {
|
||||
const { readResourceUrl } = await import("./uptime-url-state")
|
||||
const params = new URLSearchParams('tab=resources&q=ping&status=down&rq=CHR&rtype=exit-node')
|
||||
assert.deepEqual(readResourceUrl(params), { search: 'CHR', type: 'exit-node', error: null })
|
||||
assert.equal(readUptimeUrl(params).search, 'ping')
|
||||
for (const raw of ['rtype=unknown', 'rtype=exit-node&rtype=jump-host', 'rq=a&rq=b']) assert.ok(readResourceUrl(new URLSearchParams(raw)).error)
|
||||
assert.equal(patchUptimeUrl(params, { rq: null, rtype: null }), '/uptime?tab=resources&q=ping&status=down')
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
|
||||
export type UptimeTab = "probes" | "resources" | "speed"
|
||||
export type ProbeStatusFilter = "all" | "up" | "warn" | "down" | "unknown"
|
||||
export function readUptimeUrl(params: URLSearchParams) {
|
||||
const rawTab = params.get("tab")
|
||||
const rawStatus = params.get("status")
|
||||
const search = params.get("q") ?? ""
|
||||
const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes"
|
||||
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" || rawStatus === "unknown" ? rawStatus : "all"
|
||||
let error: string | null = null
|
||||
let servers: string[] = []
|
||||
if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) ||
|
||||
(rawStatus !== null && !["up", "warn", "down", "unknown"].includes(rawStatus)) ||
|
||||
["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга."
|
||||
const rawServers = params.get("servers")
|
||||
if (rawServers !== null) {
|
||||
try {
|
||||
if (rawServers.length > 8192) throw new Error()
|
||||
const value: unknown = JSON.parse(rawServers)
|
||||
if (!Array.isArray(value) || value.length > 50 || !value.every(id => typeof id === "string" && id.length > 0 && id.length <= 128) || new Set(value).size !== value.length) throw new Error()
|
||||
servers = value
|
||||
} catch { error = "Не удалось прочитать выбранные серверы из ссылки." }
|
||||
}
|
||||
return { tab, search, status, servers, error }
|
||||
}
|
||||
|
||||
export function patchUptimeUrl(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 "/uptime" + (next.size ? "?" + next.toString() : "")
|
||||
}
|
||||
|
||||
export type UrlSetter<T> = Dispatch<SetStateAction<T>>
|
||||
|
||||
export type ResourceTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
||||
export function readResourceUrl(params: URLSearchParams) {
|
||||
const raw = params.get("rtype")
|
||||
const valid = raw === null || ["jump-host", "exit-node", "home-router"].includes(raw)
|
||||
const error = !valid || ["rq", "rtype"].some(key => params.getAll(key).length > 1)
|
||||
? "В ссылке указаны неверные или повторяющиеся фильтры ресурсов." : null
|
||||
return { search: params.get("rq") ?? "", type: (valid && raw !== null ? raw : "all") as ResourceTypeFilter, error }
|
||||
}
|
||||
Reference in new issue
Block a user