Files
MikrotikManager/components/settings/system-database-section.tsx
T

78 lines
9.2 KiB
TypeScript

"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,receipt.maintenanceError,receipt.migrationError].filter((value):value is string=>!!value))
}catch(e){
const rejected=e instanceof ApiClientError&&[401,403,409,413].includes(e.status)
if(sent&&rejected&&current())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>
}