fix(settings): защищать восстановление базы от повторов и смены контекста
This commit is contained in:
1 parent
92b65a30ce
commit
57293479d1
13 files changed
+148
-188
No files matched your search
@@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
|
||||
Рекомендуется сначала скачать актуальный бэкап.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={event => { event.preventDefault(); onConfirm() }}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
{busy ? "Восстановление…" : "Восстановить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [section, setSection] = useState<NavSection>("Общие")
|
||||
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<File | null>(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() {
|
||||
<div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
|
||||
</OpsPanel>
|
||||
|
||||
<OpsPanel
|
||||
title="База данных приложения"
|
||||
description="Резервная копия и восстановление базы приложения. Для сброса потоков откройте настройки NetFlow."
|
||||
contentClassName="divide-y px-5"
|
||||
>
|
||||
{!systemDbAvailable && (
|
||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||
<span>Доступно только в live-режиме при доступном бекенде.</span>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label="Скачать бэкап"
|
||||
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||
onClick={() => { void handleSystemDatabaseBackup() }}
|
||||
>
|
||||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||
{dbBackupBusy ? "Подготовка…" : "Скачать"}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Восстановить из файла"
|
||||
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||
onClick={() => setDbRestoreDialogOpen(true)}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
Выбрать файл
|
||||
</Button>
|
||||
{dbRestoreFile && (
|
||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Сбросить данные NetFlow"
|
||||
description="Проверка состояния и подтверждение сброса — в разделе сбора данных"
|
||||
>
|
||||
{systemDbAvailable ? <Link href="/data-collection#netflow" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть NetFlow</Link> : <Button size="sm" variant="outline" disabled>Открыть NetFlow</Button>}
|
||||
|
||||
</SettingRow>
|
||||
</OpsPanel>
|
||||
<SystemDatabaseSection />
|
||||
|
||||
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
||||
|
||||
@@ -331,34 +178,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* delete confirm */}
|
||||
{dbRestoreFile && (
|
||||
<DatabaseRestoreConfirm
|
||||
open={!!dbRestoreFile}
|
||||
filename={dbRestoreFile.name}
|
||||
busy={dbRestoreBusy}
|
||||
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
||||
onCancel={() => {
|
||||
if (dbRestoreBusy) return
|
||||
setDbRestoreFile(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileImportDialog
|
||||
open={dbRestoreDialogOpen}
|
||||
onOpenChange={setDbRestoreDialogOpen}
|
||||
title="Восстановление базы данных"
|
||||
actionLabel="Продолжить"
|
||||
successMessage={null}
|
||||
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
|
||||
accept=".dump,.backup,application/octet-stream"
|
||||
onImport={async (files) => {
|
||||
const file = files[0]
|
||||
if (!file) return
|
||||
setDbRestoreFile(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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 <DatabaseForm key={source} source={source}/>}
|
||||
function DatabaseForm({source}:{source:string}){
|
||||
const {mode,backendUrl,backendStatus}=useDataSource()
|
||||
const [busy,setBusy]=useState<"backup"|"restore"|null>(null),[file,setFile]=useState<File|null>(null),[importOpen,setImportOpen]=useState(false)
|
||||
const [unknown,setUnknown]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[warnings,setWarnings]=useState<string[]>([])
|
||||
const [ready,setReady]=useState(false)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(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?<p role="alert" className="text-sm text-destructive break-words">{error}</p>:null}
|
||||
{outcome?<p role="status" className="text-sm break-words">{outcome}</p>:null}
|
||||
{warnings.map((warning,index)=><p role="status" key={index} className="text-sm text-amber-700 dark:text-amber-400 break-words">{warning}</p>)}
|
||||
{unknown?<div className="flex flex-col gap-2 text-sm"><p>Результат восстановления неизвестен. Запрос мог выполниться на сервере. До повтора проверьте данные и журнал операций; проверка доступности сервера этого не подтверждает.</p><Button variant="outline" size="sm" className="h-auto whitespace-normal" disabled={!!busy||!available} onClick={()=>{checkpoint(source,false);setUnknown(false)}}>Я проверил состояние — разрешить новый запрос</Button></div>:null}
|
||||
</>
|
||||
return <section aria-label="Операции базы приложения" className="min-w-0">
|
||||
<OpsPanel title="База данных приложения" description="Резервная копия и восстановление PostgreSQL. Для сброса потоков откройте настройки NetFlow." contentClassName="flex min-w-0 flex-col gap-4 px-5 py-4">
|
||||
{!available?<p role="status" className="text-sm text-muted-foreground">{!allowed?"Нужны права администратора настроек.":mode!=="live"?"Операции базы доступны в live-режиме.":"Доступность сервера не подтверждена."}</p>:null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Скачать бэкап</p><p className="text-xs text-muted-foreground">Дамп PostgreSQL в формате custom (.dump)</p></div><Button variant="outline" size="sm" className="self-start" disabled={!available||!!busy} onClick={()=>void backup()}>{busy==="backup"?<LoaderCircleIcon className="size-4 animate-spin"/>:<DownloadIcon className="size-4"/>}{busy==="backup"?"Подготовка…":"Скачать"}</Button></div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Восстановить из файла</p><p className="text-xs text-muted-foreground">Полностью заменяет текущие данные. Максимум 512 МБ.</p></div><Button variant="outline" size="sm" className="self-start" disabled={!available||!!busy||unknown} onClick={()=>setImportOpen(true)}><UploadIcon className="size-4"/>Выбрать файл</Button></div>
|
||||
{!file?messages:null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Сбросить данные NetFlow</p><p className="text-xs text-muted-foreground">Проверка и подтверждение — в разделе сбора данных</p></div><Link href="/data-collection#netflow" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть NetFlow</Link></div>
|
||||
</OpsPanel>
|
||||
<FileImportDialog open={importOpen} onOpenChange={setImportOpen} title="Восстановление базы данных" actionLabel="Продолжить" successMessage={null} description="Выберите pg_dump custom (.dump). Сначала рекомендуем скачать актуальный бэкап." accept=".dump,.backup,application/octet-stream" onImport={files=>{if(!available||lock.current||unknown)return;setFile(files[0]??null);setError(null);setOutcome(null);setWarnings([])}}/>
|
||||
<AlertDialog open={!!file} onOpenChange={open=>{if(!open&&!lock.current)setFile(null)}}>
|
||||
<AlertDialogContent className="max-h-[90dvh] overflow-y-auto min-w-0"><AlertDialogHeader><AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle><AlertDialogDescription className="break-all">Файл: {file?.name}. Текущие данные PostgreSQL будут полностью заменены. Источник: {backendUrl||"текущий сервер"}. Рекомендуется сначала скачать актуальный бэкап.</AlertDialogDescription></AlertDialogHeader>{file?messages:null}<AlertDialogFooter><AlertDialogCancel disabled={!!busy} onClick={()=>setFile(null)}>Отмена</AlertDialogCancel><AlertDialogAction variant="destructive" disabled={!available||!!busy||unknown} onClick={e=>{e.preventDefault();void restore()}}>{busy==="restore"?<LoaderCircleIcon className="size-4 animate-spin"/>:null}{busy==="restore"?"Восстановление…":"Восстановить"}</AlertDialogAction></AlertDialogFooter></AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
}
|
||||
+3
-1
@@ -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.
|
||||
+10
-1
@@ -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 сохранён.
|
||||
|
||||
@@ -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; неизвестный результат → проверка данных → разрешение нового запроса |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
@@ -6,7 +6,7 @@ const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).op
|
||||
export type SystemDatabaseRestoreReceipt=z.output<typeof receiptSchema>
|
||||
const warningSchema=receiptSchema.omit({ok:true})
|
||||
export type SystemDatabaseWarnings=z.output<typeof warningSchema>
|
||||
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<unknown>(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("Восстановление базы не подтверждено сервером")
|
||||
|
||||
@@ -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<void>(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"})})
|
||||
Reference in new issue
Block a user