fix(backups): восстанавливать вкладку и фильтры из ссылки

This commit is contained in:
Denozordec committed 2026-10-01 16:37:34 +07:00
1 parent 6f152c8dfe
commit 11e357cc94
8 files changed
+187 -12

No files matched your search

+7 -10
View File
@@ -1,10 +1,9 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data" import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import { createFilterQuery } from "@/components/reui/filters/filters-query" import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups" import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert" 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" import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
type PageTab = "history" | "settings" type PageTab = "history" | "settings"
type KindFilter = "all" | "auto" | "manual"
type Resource = "backups" | "servers" | "schedule" | "storage" type Resource = "backups" | "servers" | "schedule" | "storage"
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null } type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null }) const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
export default function BackupsPage() { export default function BackupsPage() { return <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
function BackupsSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p> if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <BackupsContent key={mode + ":" + backendUrl} /> return <BackupsContent key={mode + ":" + backendUrl} />
@@ -86,11 +85,8 @@ function BackupsContent() {
const settingsController = useRef<AbortController | null>(null) const settingsController = useRef<AbortController | null>(null)
const known = (resource: Resource) => !isLive || resources[resource].known const known = (resource: Resource) => !isLive || resources[resource].known
const current = (resource: Resource) => known(resource) && !resources[resource].error const current = (resource: Resource) => known(resource) && !resources[resource].error
const [tab, setTab] = useState<PageTab>("history") const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups) const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [search, setSearch] = useState("")
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers) const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -400,6 +396,7 @@ function BackupsContent() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>} {!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
{!canRead && <p role="status">Нет права mm:backups:read</p>} {!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>} {isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
@@ -483,7 +480,7 @@ function BackupsContent() {
<TabsTrigger value="settings">Настройки</TabsTrigger> <TabsTrigger value="settings">Настройки</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="history" className="flex flex-col gap-5"> <TabsContent value="history" className="flex flex-col gap-5">
{!canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory {urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
backups={backupList} backups={backupList}
kindFilter={kindFilter} kindFilter={kindFilter}
onKindFilterChange={setKindFilter} onKindFilterChange={setKindFilter}
+1 -1
View File
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.30 — URL/Filters контекст резервных копий. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. Актуальная точка продолжения: 11А.31 — Frame/адаптивность и визуальная приёмка резервных копий. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
+9 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.5; пользователи/контейнеры/сертификаты приняты локально; резервные копии: checkpoint restore, далее 11А.30 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.30; пользователи/контейнеры/сертификаты приняты локально; резервные копии: URL/Filters, далее 11А.31 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11А.30 — URL контекст истории и ReUI Filters (2026-10-01)
- Поиск q, kind и вкладка tab читаются из URL. Полный FilterQuery сохраняется как versioned filters после завершения значений; draft остаётся локальным до завершения. Back/Forward сбрасывает локальный draft и восстанавливает контекст. Search использует replace, вкладка/тип/Filters — push; чужие параметры и hash сохраняются.
- Validator ограничивает размер 8192 bytes/50 узлов/4 уровня, уникальные IDs, известные поля/operator/value, text/select наборы. Некорректная/повторяющаяся ссылка показывает постоянную ошибку со сбросом контекста; история не подменяется unfiltered списком. Сброс удаляет только собственные ключи.
- 2 URL validator unit, 5 URL E2E + 2 исходных table/Filters E2E, 12 operations E2E, TypeScript/профильный ESLint и production frontend прошли. Первый URL E2E уточнён по фактическому имени вкладки «История бэкапов»; повтор прошёл.
- Создание/restore checkpoints остаются привязаны к источнику, URL не запускает операции. Реальные RouterOS/PostgreSQL/S3 не проверены.
- **Следующий блок 11А.31:** ReUI Frame, desktop/390/320, визуальные состояния истории/настроек/форм/ошибок, nullable статус устройств; затем scheduler cleanup и приёмка backups.
## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01) ## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01)
- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало. - До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало.
+9
View File
@@ -409,3 +409,12 @@
- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием. - Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием.
- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups. - **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups.
## Блок 11А.30 — URL контекст истории и ReUI Filters (2026-10-01)
- Поиск q, kind и вкладка tab читаются из URL. Полный FilterQuery сохраняется как versioned filters после завершения значений; draft остаётся локальным до завершения. Back/Forward сбрасывает локальный draft и восстанавливает контекст. Search использует replace, вкладка/тип/Filters — push; чужие параметры и hash сохраняются.
- Validator ограничивает размер 8192 bytes/50 узлов/4 уровня, уникальные IDs, известные поля/operator/value, text/select наборы. Некорректная/повторяющаяся ссылка показывает постоянную ошибку со сбросом контекста; история не подменяется unfiltered списком. Сброс удаляет только собственные ключи.
- 2 URL validator unit, 5 URL E2E + 2 исходных table/Filters E2E, 12 operations E2E, TypeScript/профильный ESLint и production frontend прошли. Первый URL E2E уточнён по фактическому имени вкладки «История бэкапов»; повтор прошёл.
- Создание/restore checkpoints остаются привязаны к источнику, URL не запускает операции. Реальные RouterOS/PostgreSQL/S3 не проверены.
- **Следующий блок 11А.31:** ReUI Frame, desktop/390/320, визуальные состояния истории/настроек/форм/ошибок, nullable статус устройств; затем scheduler cleanup и приёмка backups.
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { encodeBackupQuery, patchBackupsUrl, readBackupsUrl, validateBackupQuery, type BackupKindFilter, type BackupsTab } from "@/lib/data-filters/backups-url-state"
export function useBackupsUrlState() {
const params = useSearchParams()
const raw = params.toString()
const state = useMemo(() => 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<string, string | null>, 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 }) },
}
}
@@ -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() : "")
}
+21
View File
@@ -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/)
})