feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
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)
}
})
+44
View File
@@ -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)
}
+36
View File
@@ -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")
})
+73
View File
@@ -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() : "")
}
+38
View File
@@ -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" },
],
},
]
+37
View File
@@ -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)
})
+75
View File
@@ -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() : "")
}
+43
View File
@@ -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: "задано",
}
+45
View File
@@ -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,
}
+37
View File
@@ -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)
})
+68
View File
@@ -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 }
}
+26
View File
@@ -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')
})
+46
View File
@@ -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 }
}