diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index efb076be..a551ee32 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -1,35 +1,21 @@ "use client" -import { useCallback, useState } from "react" +import { useState } from "react" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { useTheme } from "@/components/theme-provider" -import { FileImportDialog } from "@/components/file-import-dialog" +import { SystemDatabaseSection } from "@/components/settings/system-database-section" import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section" import { OpsPanel } from "@/components/ops-panel" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { useDataSource } from "@/lib/data-source" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" import { CheckIcon, AlertCircleIcon, - LoaderCircleIcon, - DownloadIcon, UploadIcon, XIcon, + XIcon, } from "lucide-react" import { cn } from "@/lib/utils" -import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database" -import { toast } from "sonner" const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const type NavSection = typeof SECTIONS_NAV[number] @@ -46,98 +32,12 @@ function SettingRow({ label, description, children }: { label: string; descripti ) } -function DatabaseRestoreConfirm({ - open, - filename, - busy, - onConfirm, - onCancel, -}: { - open: boolean - filename: string - busy?: boolean - onConfirm: () => void - onCancel: () => void -}) { - return ( - { if (!v && !busy) onCancel() }}> - - - - - - Восстановить базу приложения? - - Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены. - Рекомендуется сначала скачать актуальный бэкап. - - - - Отмена - { event.preventDefault(); onConfirm() }} - > - {busy ? : null} - {busy ? "Восстановление…" : "Восстановить"} - - - - - ) -} - // ─── page ───────────────────────────────────────────────────────────────────── export default function SettingsPage() { const [section, setSection] = useState("Общие") const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource() const { theme, setTheme } = useTheme() - const [dbBackupBusy, setDbBackupBusy] = useState(false) - const [dbRestoreBusy, setDbRestoreBusy] = useState(false) - const [dbRestoreFile, setDbRestoreFile] = useState(null) - const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false) - - const systemDbAvailable = mode === "live" && backendStatus === true - - const handleSystemDatabaseBackup = useCallback(async () => { - if (!systemDbAvailable) return - setDbBackupBusy(true) - try { - const { blob, filename, warnings } = await downloadSystemDatabaseBackup(backendUrl) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) - toast.success("Бэкап базы приложения подготовлен к скачиванию") - for (const warning of Object.values(warnings)) if (warning) toast.warning(warning) - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап") - } finally { - setDbBackupBusy(false) - } - }, [backendUrl, systemDbAvailable]) - - const handleSystemDatabaseRestoreConfirm = useCallback(async () => { - if (!dbRestoreFile || !systemDbAvailable) return - setDbRestoreBusy(true) - try { - const receipt = await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile) - toast.success("База приложения восстановлена") - for (const warning of [receipt.eventError,receipt.listenerError,receipt.schedulerError,receipt.cleanupError]) if (warning) toast.warning(warning) - setDbRestoreFile(null) - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу") - } finally { - setDbRestoreBusy(false) - } - }, [backendUrl, dbRestoreFile, systemDbAvailable]) - - - const renderContent = () => { // ── Общие ── @@ -237,60 +137,7 @@ export default function SettingsPage() {
Сбор данныхДиагностика
- - {!systemDbAvailable && ( -
- - Доступно только в live-режиме при доступном бекенде. -
- )} - - - - -
- - {dbRestoreFile && ( -

{dbRestoreFile.name}

- )} -
-
- - {systemDbAvailable ? Открыть NetFlow : } - - -
+ Открыть оптимизатор @@ -331,34 +178,6 @@ export default function SettingsPage() { - {/* delete confirm */} - {dbRestoreFile && ( - { void handleSystemDatabaseRestoreConfirm() }} - onCancel={() => { - if (dbRestoreBusy) return - setDbRestoreFile(null) - }} - /> - )} - - { - const file = files[0] - if (!file) return - setDbRestoreFile(file) - }} - /> ) } diff --git a/components/settings/system-database-section.tsx b/components/settings/system-database-section.tsx new file mode 100644 index 00000000..a674cc87 --- /dev/null +++ b/components/settings/system-database-section.tsx @@ -0,0 +1,77 @@ +"use client" + +import {useEffect,useRef,useState} from "react" +import Link from "next/link" +import {useDataSource} from "@/lib/data-source" +import {can} from "@/lib/auth" +import {ApiClientError} from "@/shared/api/http-client" +import {downloadSystemDatabaseBackup,restoreSystemDatabaseBackup} from "@/shared/api/system-database" +import {OpsPanel} from "@/components/ops-panel" +import {Button,buttonVariants} from "@/components/ui/button" +import {FileImportDialog} from "@/components/file-import-dialog" +import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction} from "@/components/ui/alert-dialog" +import {DownloadIcon,UploadIcon,LoaderCircleIcon} from "lucide-react" + +const pending=new Set() +function storageKey(source:string){return "mm:database-restore-pending:"+source} +function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(storageKey(source))==="true"}catch{return pending.has(source)}} +function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(storageKey(source),"true");else sessionStorage.removeItem(storageKey(source))}catch{/* Memory guard still survives section navigation. */}} +export function SystemDatabaseSection(){const {mode,backendUrl}=useDataSource();const source=JSON.stringify([mode,backendUrl]);return } +function DatabaseForm({source}:{source:string}){ + const {mode,backendUrl,backendStatus}=useDataSource() + const [busy,setBusy]=useState<"backup"|"restore"|null>(null),[file,setFile]=useState(null),[importOpen,setImportOpen]=useState(false) + const [unknown,setUnknown]=useState(false),[error,setError]=useState(null),[outcome,setOutcome]=useState(null),[warnings,setWarnings]=useState([]) + const [ready,setReady]=useState(false) + const active=useRef(true),lock=useRef(false),controller=useRef(null) + useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setUnknown(unresolved(source));setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source]) + const allowed=can("mm:settings:admin"),available=ready&&mode==="live"&&backendStatus===true&&allowed + function current(){return active.current&&can("mm:settings:admin")} + function begin(kind:"backup"|"restore"){ + if(!available||lock.current||!current()||(kind==="restore"&&unknown))return null + lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null);setWarnings([]);return controller.current.signal + } + function finish(){lock.current=false;controller.current=null;if(current())setBusy(null)} + async function backup(){ + const signal=begin("backup");if(!signal)return + try{ + const result=await downloadSystemDatabaseBackup(backendUrl,{signal});if(!current())return + const url=URL.createObjectURL(result.blob) + try{const a=document.createElement("a");a.href=url;a.download=result.filename;a.click()}finally{URL.revokeObjectURL(url)} + setOutcome("Бэкап базы приложения подготовлен к скачиванию");setWarnings(Object.values(result.warnings)) + }catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось подготовить бэкап")} + finally{finish()} + } + async function restore(){ + if(!file)return + const signal=begin("restore");if(!signal)return + let sent=false + try{ + const receipt=await restoreSystemDatabaseBackup(backendUrl,file,{signal,onDispatch:()=>{if(!current())throw Error("Источник изменился");sent=true;checkpoint(source,true);setUnknown(true)}}) + if(!current())return;checkpoint(source,false) + setUnknown(false);setFile(null);setOutcome("База приложения восстановлена");setWarnings([receipt.eventError,receipt.listenerError,receipt.schedulerError,receipt.cleanupError].filter((value):value is string=>!!value)) + }catch(e){ + const rejected=e instanceof ApiClientError&&[401,403,409,413].includes(e.status) + if(sent&&rejected&¤t())checkpoint(source,false) + if(current()){setUnknown(sent&&!rejected);const message=e instanceof Error?e.message:"Результат восстановления не получен";setError(message)} + }finally{finish()} + } + const messages=<> + {error?

{error}

:null} + {outcome?

{outcome}

:null} + {warnings.map((warning,index)=>

{warning}

)} + {unknown?

Результат восстановления неизвестен. Запрос мог выполниться на сервере. До повтора проверьте данные и журнал операций; проверка доступности сервера этого не подтверждает.

:null} + + return
+ + {!available?

{!allowed?"Нужны права администратора настроек.":mode!=="live"?"Операции базы доступны в live-режиме.":"Доступность сервера не подтверждена."}

:null} +

Скачать бэкап

Дамп PostgreSQL в формате custom (.dump)

+

Восстановить из файла

Полностью заменяет текущие данные. Максимум 512 МБ.

+ {!file?messages:null} +

Сбросить данные NetFlow

Проверка и подтверждение — в разделе сбора данных

Открыть NetFlow
+
+ {if(!available||lock.current||unknown)return;setFile(files[0]??null);setError(null);setOutcome(null);setWarnings([])}}/> + {if(!open&&!lock.current)setFile(null)}}> + Восстановить базу приложения?Файл: {file?.name}. Текущие данные PostgreSQL будут полностью заменены. Источник: {backendUrl||"текущий сервер"}. Рекомендуется сначала скачать актуальный бэкап.{file?messages:null}setFile(null)}>Отмена{e.preventDefault();void restore()}}>{busy==="restore"?:null}{busy==="restore"?"Восстановление…":"Восстановить"} + +
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index e6bf20af..64b9749d 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.2. Следующий блок 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.3.1. Следующий блок 11Б.3.6.2.3.2: browser source/ABA/unmount и соседняя Settings регрессия; затем локальная приёмка System DB. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -300,3 +300,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. 2026-10-03: завершён 11Б.3.6.2.2.2, подтверждённый backup/restore отделён от restart/audit warnings; nonqueued concurrency, HTTP/JWT/API/UI проверки прошли локально. Далее DB форма 11Б.3.6.2.3. + +2026-10-03: завершён 11Б.3.6.2.3.1, отдельная DB форма/lock/checkpoint/адаптивность; 10 E2E, API/proxy tests и build прошли. Далее source/ABA/unmount и приёмка DB. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 07deca34..82a9e50a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.3.6.2.3.1 — отдельная форма базы и неизвестный результат (2026-10-03) + +- Inline DB handlers вынесены в SystemDatabaseSection: source-keyed lifetime, AbortController/active guard, проверка settings admin и backend health, общий synchronous lock backup/restore, удержание confirmation и запрет cancel во время POST. Результат/ошибки/warnings показаны внутри формы; убраны дублирующие toast сообщения. +- Dispatch checkpoint ставится только после проверки PGDMP/размера до отправки POST. Неизвестный результат блокирует повтор даже после cancel, смены секции и reload: memory + sessionStorage guard по источнику. Пользователь должен проверить данные/журнал и явно разрешить новый запрос; health не считается подтверждением восстановления. HTTP 401/403/409/413 — отказ без checkpoint; malformed receipt/ошибка транспорта сохраняют его. Нет автоматического повтора. +- Старый ответ размонтированной формы не очищает checkpoint нового lifetime. Отмена reader не доказывает прекращение server restore. При недоступном sessionStorage guard сохраняется в памяти до reload; состояние выбранного файла после reload не восстанавливается, checkpoint остаётся при доступном storage. +- 10 browser сценариев (включая 3 responsive и 2 прежних receipt), 5 API unit групп, lint/typecheck и production frontend/contracts build прошли. 3 снимка confirmation, 320 просмотрен. Первый прогон двух прежних E2E выявил duplicate toast/inline/closing dialog message; устранено, повтор прошёл. +- Найдено, что same-origin Next proxy отбрасывал новый outcome header. Передача header исправлена; отдельный proxy test, lint/typecheck прошли после изменения. Остальные внутренние headers не передаются. +- Следующий точный шаг 11Б.3.6.2.3.2: browser source/ABA/unmount и соседняя Settings регрессия; затем локальная приёмка System DB. Реальный PG dump/restore/worker pause/Portal ещё требуют внешней проверки. + ## Блок 11Б.3.6.2.2.2 — подтверждённая операция и вторичные сбои (2026-10-03) - Core backup/restore отделён от restart listener/scheduler: оба restart выполняются независимо, их errors — warnings после подтверждённой команды; первичная ошибка не подменяется вторичной. Ошибка stop предотвращает команду и запускает попытки восстановления потребителей. Cleanup warning сохранён. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index df0a6e4f..04a2d642 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -226,3 +226,9 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. Завершён 11Б.3.6.2.2.2: core/restart/event outcomes, nonqueued lock/409, DI HTTP/JWT/concurrency/API и browser checks прошли; реальная БД не запускалась. Далее 11Б.3.6.2.3 — отдельная DB форма с source/active/admin/sync lock/unknown review. + +Завершён 11Б.3.6.2.3.1: DB form/rights/source lifetime/sync lock/persisted unknown review; 10 E2E/5 API/proxy unit/typecheck/lint/build прошли. 3 confirmation screenshots, 320 просмотрен. Далее 11Б.3.6.2.3.2 source/ABA/unmount и Settings регрессия. + +| Блок | Референс | Схема | +| --- | --- | --- | +| 11Б.3.6.2.3.1 База приложения | [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | Источник и доступ → скачать / выбрать файл → подтверждение → результат + warnings; неизвестный результат → проверка данных → разрешение нового запроса | diff --git a/docs/ux-ui/screens/system-database-confirm-1440.png b/docs/ux-ui/screens/system-database-confirm-1440.png new file mode 100644 index 00000000..5b06f79a Binary files /dev/null and b/docs/ux-ui/screens/system-database-confirm-1440.png differ diff --git a/docs/ux-ui/screens/system-database-confirm-320.png b/docs/ux-ui/screens/system-database-confirm-320.png new file mode 100644 index 00000000..755aac7a Binary files /dev/null and b/docs/ux-ui/screens/system-database-confirm-320.png differ diff --git a/docs/ux-ui/screens/system-database-confirm-390.png b/docs/ux-ui/screens/system-database-confirm-390.png new file mode 100644 index 00000000..7db57b38 Binary files /dev/null and b/docs/ux-ui/screens/system-database-confirm-390.png differ diff --git a/lib/proxy-backend-request.test.ts b/lib/proxy-backend-request.test.ts new file mode 100644 index 00000000..0e58f568 --- /dev/null +++ b/lib/proxy-backend-request.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {NextRequest} from "next/server" +import {proxyBackendRequest} from "./proxy-backend-request" +test("same-origin backup proxy preserves bytes, filename and secondary outcome metadata",async()=>{ + const original=globalThis.fetch;try{ + const outcome=encodeURIComponent(JSON.stringify({eventError:"audit unavailable"})) + globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"Content-Type":"application/octet-stream","Content-Disposition":"attachment; filename=fixture.dump","X-System-Database-Outcome":outcome,"X-Internal-Secret":"fixture"}}) + const response=await proxyBackendRequest(new NextRequest("http://app.test/api/system/database/backup"),"/api/system/database/backup",{method:"GET",forwardRequestBody:false}) + assert.equal(await response.text(),"PGDMPfixture");assert.equal(response.headers.get("Content-Disposition"),"attachment; filename=fixture.dump");assert.equal(response.headers.get("X-System-Database-Outcome"),outcome);assert.equal(response.headers.get("X-Internal-Secret"),null) + }finally{globalThis.fetch=original} +}) diff --git a/lib/proxy-backend-request.ts b/lib/proxy-backend-request.ts index 8881f04e..28c49d6e 100644 --- a/lib/proxy-backend-request.ts +++ b/lib/proxy-backend-request.ts @@ -38,6 +38,8 @@ export async function proxyBackendRequest( const contentDisposition = upstream.headers.get("Content-Disposition") if (contentType) responseHeaders.set("Content-Type", contentType) if (contentDisposition) responseHeaders.set("Content-Disposition", contentDisposition) + const databaseOutcome = upstream.headers.get("X-System-Database-Outcome") + if (databaseOutcome) responseHeaders.set("X-System-Database-Outcome", databaseOutcome) return new Response(upstream.body, { status: upstream.status, diff --git a/shared/api/system-database.test.ts b/shared/api/system-database.test.ts index be0c95dc..1a0a6c6d 100644 --- a/shared/api/system-database.test.ts +++ b/shared/api/system-database.test.ts @@ -38,3 +38,12 @@ test("backup warning header is strict and malformed metadata preserves valid arc } }finally{globalThis.fetch=original} }) + +test("restore dispatch checkpoint runs only after local validation and immediately before POST",async()=>{ + const original=globalThis.fetch;const calls:string[]=[];try{ + globalThis.fetch=async()=>{calls.push("POST");return Response.json({ok:true})} + const options={onDispatch:()=>calls.push("checkpoint")} + await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",new File(["wrong"],"wrong.dump"),options));assert.deepEqual(calls,[]) + await restoreSystemDatabaseBackup("https://backend.test",dump,options);assert.deepEqual(calls,["checkpoint","POST"]) + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index 4cfb97bb..fcb0fe7e 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -6,7 +6,7 @@ const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).op export type SystemDatabaseRestoreReceipt=z.output const warningSchema=receiptSchema.omit({ok:true}) export type SystemDatabaseWarnings=z.output -type Options={signal?:AbortSignal} +type Options={signal?:AbortSignal;onDispatch?:()=>void} const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000) function parseFilename(value:string|null):string{ let name="mikrotik-manager.dump" @@ -39,6 +39,7 @@ export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,optio if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413) const activeSignal=signal(options);activeSignal.throwIfAborted() await confirmDump(file);activeSignal.throwIfAborted() + options?.onDispatch?.() const raw=await requestJson(baseUrl,"/api/system/database/restore",{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:file,signal:activeSignal}) const receipt=receiptSchema.safeParse(raw) if(!receipt.success)throw new Error("Восстановление базы не подтверждено сервером") diff --git a/tests/e2e/system-database-form.spec.ts b/tests/e2e/system-database-form.spec.ts new file mode 100644 index 00000000..4037bec7 --- /dev/null +++ b/tests/e2e/system-database-form.spec.ts @@ -0,0 +1,23 @@ +import {test,expect,type Page} from "@playwright/test" +async function setup(page:Page,restore:{status?:number;json?:unknown}={json:{ok:true}}){ + let posts=0,backups=0 + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}})) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/system/database/restore"){posts++;return r.fulfill(restore)}if(path==="/api/system/database/backup"){backups++;return r.fulfill({body:Buffer.from("PGDMPfixture"),contentType:"application/octet-stream",headers:{"Content-Disposition":"attachment; filename=fixture.dump","X-System-Database-Outcome":encodeURIComponent(JSON.stringify({listenerError:"Listener restart failed"}))}})}return r.fulfill({status:503,json:{error:"outside fixture"}})}) + await page.goto("/settings");return {posts:()=>posts,backups:()=>backups} +} +async function choose(page:Page,bytes="PGDMPfixture"){ + await page.getByRole("button",{name:"Выбрать файл",exact:true}).click();await page.getByRole("dialog").locator('input[type="file"]').setInputFiles({name:"fixture.dump",mimeType:"application/octet-stream",buffer:Buffer.from(bytes)});await page.getByRole("button",{name:"Продолжить",exact:true}).click();await expect(page.getByRole("alertdialog")).toBeVisible() +} +test("System DB: unknown checkpoint survives cancel, section navigation and reload; explicit review enables retry",async({page})=>{ + const f=await setup(page,{status:503,json:{error:"Receipt lost"}});await choose(page);await page.getByRole("button",{name:"Восстановить",exact:true}).click();await expect(page.getByRole("alert")).toHaveText("Receipt lost");await expect(page.getByRole("button",{name:"Восстановить",exact:true})).toBeDisabled();expect(f.posts()).toBe(1) + await page.getByRole("button",{name:"Отмена",exact:true}).click();await page.getByRole("button",{name:"Безопасность",exact:true}).click();await page.getByRole("button",{name:"Общие",exact:true}).click();await expect(page.getByRole("button",{name:"Выбрать файл",exact:true})).toBeDisabled();await page.reload();await expect(page.getByRole("button",{name:"Выбрать файл",exact:true})).toBeDisabled();await expect(page.getByText(/Запрос мог выполниться на сервере/)).toBeVisible();expect(f.posts()).toBe(1) + await page.getByRole("button",{name:"Я проверил состояние — разрешить новый запрос",exact:true}).click();await expect(page.getByRole("button",{name:"Выбрать файл",exact:true})).toBeEnabled() +}) +test("System DB: invalid archive sends no POST and preserves selected file",async({page})=>{const f=await setup(page);await choose(page,"not a dump");await page.getByRole("button",{name:"Восстановить",exact:true}).click();await expect(page.getByRole("alert")).toContainText("PGDMP");expect(f.posts()).toBe(0);await expect(page.getByRole("alertdialog")).toContainText("fixture.dump");await expect(page.getByRole("button",{name:"Восстановить",exact:true})).toBeEnabled()}) +test("System DB: conflict is a rejection without unknown checkpoint",async({page})=>{const f=await setup(page,{status:409,json:{error:"Операция уже выполняется"}});await choose(page);await page.getByRole("button",{name:"Восстановить",exact:true}).click();await expect(page.getByRole("alert")).toContainText("уже выполняется");await expect(page.getByRole("button",{name:"Восстановить",exact:true})).toBeEnabled();expect(f.posts()).toBe(1)}) +test("System DB: prepared download keeps secondary warning and makes one request",async({page})=>{const f=await setup(page);await page.getByRole("region",{name:"Операции базы приложения"}).getByRole("button",{name:"Скачать",exact:true}).click();await expect(page.getByText("Бэкап базы приложения подготовлен к скачиванию",{exact:true})).toBeVisible();await expect(page.getByText("Listener restart failed",{exact:true})).toBeVisible();expect(f.backups()).toBe(1)}) +test("System DB: synchronous restore lock prevents double dispatch and cancellation while pending",async({page})=>{ + await setup(page);let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve);await page.route("**/api/system/database/restore",async r=>{calls++;await gate;await r.fulfill({json:{ok:true}})}) + await choose(page);await page.getByRole("button",{name:"Восстановить",exact:true}).evaluate((button:HTMLButtonElement)=>{button.click();button.click()});await expect(page.getByRole("button",{name:"Восстановление…",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await expect.poll(()=>calls).toBe(1);release();await expect(page.getByText("База приложения восстановлена",{exact:true})).toBeVisible();expect(calls).toBe(1) +}) +for(const width of [1440,390,320])test("System DB: adaptive confirmation/"+width,async({page})=>{await page.setViewportSize({width,height:900});await setup(page);await choose(page);await expect(page.getByRole("button",{name:"Восстановить",exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/system-database-confirm-"+width+".png",fullPage:true,animations:"disabled"})})