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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user