diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx
index 887f98fa..1b93354c 100644
--- a/app/(main)/backups/page.tsx
+++ b/app/(main)/backups/page.tsx
@@ -1,10 +1,9 @@
"use client"
-import { useCallback, useEffect, useRef, useState } from "react"
+import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
-import { createFilterQuery } from "@/components/reui/filters/filters-query"
-import type { FilterQuery } from "@/components/reui/filters/filters-types"
+import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
@@ -56,13 +55,13 @@ import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
type PageTab = "history" | "settings"
-type KindFilter = "all" | "auto" | "manual"
type Resource = "backups" | "servers" | "schedule" | "storage"
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
-export default function BackupsPage() {
+export default function BackupsPage() { return Загрузка контекста резервных копий…
}> }
+function BackupsSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return Загрузка настроек источника…
return
@@ -86,11 +85,8 @@ function BackupsContent() {
const settingsController = useRef(null)
const known = (resource: Resource) => !isLive || resources[resource].known
const current = (resource: Resource) => known(resource) && !resources[resource].error
- const [tab, setTab] = useState("history")
+ const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
const [backupList, setBackupList] = useState(isLive ? [] : mockBackups)
- const [kindFilter, setKindFilter] = useState("all")
- const [search, setSearch] = useState("")
- const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers)
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
const [loading, setLoading] = useState(false)
@@ -400,6 +396,7 @@ function BackupsContent() {
+ {urlError &&
Не удалось прочитать ссылку{urlError}}
{!isLive &&
Демонстрационные данные. Операции и сохранение недоступны.
}
{!canRead &&
Нет права mm:backups:read
}
{isLive && !canReadServers &&
Каталог устройств недоступен без mm:servers:read.
}
@@ -483,7 +480,7 @@ function BackupsContent() {
Настройки
- {!canRead ? История недоступна без права чтения
: !known("backups") ? {resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}
: История недоступна без права чтения : !known("backups") ? {resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}
: readBackupsUrl(new URLSearchParams(raw)), [raw])
+ const filterKey = params.get("filters")
+ const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null)
+ useEffect(() => {
+ const reset = () => setDraft(null)
+ window.addEventListener("popstate", reset)
+ return () => window.removeEventListener("popstate", reset)
+ }, [])
+ function write(changes: Record, replace = false) {
+ const href = patchBackupsUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
+ if (href === window.location.pathname + window.location.search + window.location.hash) return
+ if (replace) window.history.replaceState(null, "", href)
+ else window.history.pushState(null, "", href)
+ }
+ function setFilters(query: FilterQuery) {
+ if (validateBackupQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return }
+ try {
+ const encoded = encodeBackupQuery(query)
+ setDraft(null)
+ write({ filters: encoded })
+ } catch (error) {
+ setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." })
+ }
+ }
+ const currentDraft = draft?.key === filterKey ? draft : null
+ return {
+ search: state.search,
+ kindFilter: state.kind,
+ tab: state.tab,
+ filters: currentDraft?.query ?? state.query,
+ error: state.error ?? currentDraft?.error ?? null,
+ hasFilters: params.has("q") || params.has("kind") || params.has("filters") || currentDraft !== null,
+ setSearch: (value: string) => write({ q: value }, true),
+ setKindFilter: (value: BackupKindFilter) => write({ kind: value === "all" ? null : value }),
+ setTab: (value: BackupsTab) => write({ tab: value === "history" ? null : value }),
+ setFilters,
+ reset: () => { setDraft(null); write({ q: null, kind: null, tab: null, filters: null }) },
+ }
+}
diff --git a/lib/data-filters/backups-url-state.test.ts b/lib/data-filters/backups-url-state.test.ts
new file mode 100644
index 00000000..748fea0c
--- /dev/null
+++ b/lib/data-filters/backups-url-state.test.ts
@@ -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")
+})
diff --git a/lib/data-filters/backups-url-state.ts b/lib/data-filters/backups-url-state.ts
new file mode 100644
index 00000000..049266fa
--- /dev/null
+++ b/lib/data-filters/backups-url-state.ts
@@ -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 => typeof value === "object" && value !== null && !Array.isArray(value)
+
+export function validateBackupQuery(value: unknown): "valid" | "draft" | "invalid" {
+ const ids = new Set()
+ let count = 0
+ let draft = false
+ function visit(node: unknown, depth: number): boolean {
+ if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
+ ids.add(node.id)
+ if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
+ if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
+ const 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) {
+ 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() : "")
+}
diff --git a/tests/e2e/backups-url.spec.ts b/tests/e2e/backups-url.spec.ts
new file mode 100644
index 00000000..e839377a
--- /dev/null
+++ b/tests/e2e/backups-url.spec.ts
@@ -0,0 +1,21 @@
+import { test, expect } from "@playwright/test"
+import { backup, setupBackups } from "./fixtures/backups"
+test("Бэкапы URL: поиск, тип, вкладка, reload и Back/Forward", async ({ page }) => {
+ await setupBackups(page); await page.goto("/backups?kind=manual&q=live&keep=1#context")
+ await expect(page.getByPlaceholder("Поиск по файлу, серверу, заметке…")).toHaveValue("live"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
+ await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page).toHaveURL(/tab=settings/); await expect(page).toHaveURL(/keep=1/); await expect(page).toHaveURL(/#context$/)
+ await page.reload(); await expect(page.getByRole("tab", { name: "Настройки", exact: true })).toHaveAttribute("aria-selected", "true")
+ await page.goBack(); await expect(page.getByRole("tab", { name: "История бэкапов", exact: true })).toHaveAttribute("aria-selected", "true"); await expect(page.getByPlaceholder("Поиск по файлу, серверу, заметке…")).toHaveValue("live")
+ await page.goForward(); await expect(page.getByRole("tab", { name: "Настройки", exact: true })).toHaveAttribute("aria-selected", "true")
+})
+test("Бэкапы URL: advanced Filters из ссылки сохраняют срез после reload", async ({ page }) => {
+ await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [backup, { ...backup, id: "other", filename: "other.rsc", storage: "s3" }] }))
+ const filters = JSON.stringify({ v: 1, query: { id: "root", type: "group", combinator: "and", rules: [{ id: "rule-1", type: "rule", path: ["storage"], operator: "is", value: "s3" }] } })
+ await page.goto("/backups?filters=" + encodeURIComponent(filters)); await expect(page.getByText("other.rsc", { exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
+ await page.reload(); await expect(page.getByText("other.rsc", { exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
+})
+for (const raw of ["kind=bad", "filters=bad", "tab=settings&tab=history"]) test("Бэкапы URL: ошибочный контекст/" + raw, async ({ page }) => {
+ await setupBackups(page); await page.goto("/backups?keep=1&" + raw)
+ await expect(page.getByRole("alert", { name: "Контекст резервных копий", exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
+ await page.getByRole("button", { name: "Сбросить контекст", exact: true }).click(); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible(); await expect(page).toHaveURL(/keep=1/)
+})