feat: implement database backup and restore functionality in settings page
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 1m51s
Docker images / updater-image (push) Successful in 36s
Docker images / notify-webhook (push) Has been skipped

- Added UI components for downloading and restoring the application database, enhancing data management capabilities.
- Integrated new API routes for system database operations in the backend.
- Implemented state management for backup and restore processes, including loading indicators and user notifications.
This commit is contained in:
Denozordec
2026-05-12 15:59:27 +07:00
parent 1fefed2aa0
commit bb07ba0f69
5 changed files with 390 additions and 1 deletions
+162 -1
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import {
@@ -22,9 +22,11 @@ import {
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -791,6 +793,46 @@ function Field({ label, error, children }: { label: string; error?: string; chil
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
filename,
busy,
onConfirm,
onCancel,
}: {
filename: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
<AlertCircleIcon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-semibold">Восстановить базу приложения?</p>
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать
актуальный бэкап.
</p>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button>
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</Button>
</div>
</div>
</div>
)
}
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -855,6 +897,10 @@ export default function SettingsPage() {
const [timezone, setTimezone] = useState("Europe/Moscow")
const [refreshSec, setRefreshSec] = useState("30")
const [probeTimeout, setProbeTimeout] = useState("5")
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
@@ -957,6 +1003,42 @@ export default function SettingsPage() {
evo.saveSettings,
])
const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = 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("Бэкап базы приложения скачан")
} 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 {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
setDbRestoreBusy(false)
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
@@ -1113,6 +1195,73 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">База данных приложения</CardTitle>
<CardDescription className="text-xs">
Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
сбора данных приостанавливается.
</CardDescription>
</CardHeader>
<CardContent className="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="Консистентная копия файла mikrotik.db"
>
<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="Полностью заменяет текущую базу SQLite"
>
<div className="flex flex-col items-end gap-2">
<Input
ref={dbRestoreInputRef}
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
className="hidden"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
if (!file) return
setDbRestoreFile(file)
}}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => dbRestoreInputRef.current?.click()}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
</div>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
</div>
</SettingRow>
</CardContent>
</Card>
<Card id="route-ai" className="scroll-mt-4">
<CardHeader>
<CardTitle className="text-base">Route AI</CardTitle>
@@ -1696,6 +1845,18 @@ export default function SettingsPage() {
/>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
}}
/>
)}
{deleteTarget && (
<DeleteConfirm
user={deleteTarget}