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" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { import {
@@ -22,9 +22,11 @@ import {
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon, SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon, XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon, ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner" import { toast } from "sonner"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -791,6 +793,46 @@ function Field({ label, error, children }: { label: string; error?: string; chil
// ─── delete confirm ─────────────────────────────────────────────────────────── // ─── 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 }) { function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <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 [timezone, setTimezone] = useState("Europe/Moscow")
const [refreshSec, setRefreshSec] = useState("30") const [refreshSec, setRefreshSec] = useState("30")
const [probeTimeout, setProbeTimeout] = useState("5") 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 // notifications
const [notifEmail, setNotifEmail] = useState(true) const [notifEmail, setNotifEmail] = useState(true)
@@ -957,6 +1003,42 @@ export default function SettingsPage() {
evo.saveSettings, 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 renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
@@ -1113,6 +1195,73 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </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"> <Card id="route-ai" className="scroll-mt-4">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Route AI</CardTitle> <CardTitle className="text-base">Route AI</CardTitle>
@@ -1696,6 +1845,18 @@ export default function SettingsPage() {
/> />
{/* delete confirm */} {/* 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 && ( {deleteTarget && (
<DeleteConfirm <DeleteConfirm
user={deleteTarget} user={deleteTarget}
+2
View File
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
import sidebarCountsRoutes from "./routes/sidebar-counts.js" import sidebarCountsRoutes from "./routes/sidebar-counts.js"
import alertsRoutes from "./routes/alerts.js" import alertsRoutes from "./routes/alerts.js"
import backupsRoutes from "./routes/backups.js" import backupsRoutes from "./routes/backups.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js" import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
@@ -65,6 +66,7 @@ await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" }) await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" }) await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" }) await app.register(backupsRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" })
refreshScheduler() refreshScheduler()
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyPluginAsync } from "fastify"
import { appendEvent } from "../modules/events/service/events-service.js"
import {
exportSystemDatabaseBackup,
getSystemDatabasePath,
restoreSystemDatabaseBackup,
} from "../services/system-database-backup.js"
const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.addContentTypeParser(
"application/octet-stream",
{ parseAs: "buffer", bodyLimit: 512 * 1024 * 1024 },
(_req, body, done) => {
done(null, body)
},
)
app.get("/system/database/backup", async (_req, reply) => {
try {
const { filename, buffer } = await exportSystemDatabaseBackup()
appendEvent({
level: "info",
eventType: "system.database.backup",
sourceModule: "system",
title: "Создан бэкап базы приложения",
message: filename,
entityType: "system_database",
entityId: filename,
payload: {
filename,
sizeBytes: buffer.length,
databasePath: getSystemDatabasePath(),
},
})
reply.header("Content-Type", "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
return reply.send(buffer)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return reply.status(500).send({ error: message })
}
})
app.post("/system/database/restore", async (req, reply) => {
const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: "Ожидается тело запроса с файлом SQLite" })
}
try {
await restoreSystemDatabaseBackup(body)
appendEvent({
level: "warning",
eventType: "system.database.restore",
sourceModule: "system",
title: "Восстановлена база приложения",
message: "Данные SQLite заменены из загруженного файла",
entityType: "system_database",
entityId: "restore",
payload: {
sizeBytes: body.length,
databasePath: getSystemDatabasePath(),
},
})
return reply.send({ ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 400
return reply.status(status).send({ error: message })
}
})
}
export default systemDatabaseRoutes
@@ -0,0 +1,93 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import Database from "better-sqlite3"
import { env } from "../config.js"
import { sqliteDatabase } from "../db/index.js"
import { refreshScheduler, stopScheduler } from "./scheduler.js"
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
type SqliteHandle = InstanceType<typeof Database>
let operationInFlight = false
function fmtTimestamp(date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
}
function resolveDatabasePath(): string {
return path.resolve(process.cwd(), env.DATABASE_PATH)
}
function assertSqliteFile(buffer: Buffer): void {
if (buffer.length < SQLITE_MAGIC.length) {
throw new Error("Файл слишком маленький для SQLite")
}
if (!buffer.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
throw new Error("Файл не похож на резервную копию SQLite")
}
}
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
if (operationInFlight) {
throw new Error("Операция с базой данных уже выполняется")
}
operationInFlight = true
stopScheduler()
try {
return await fn()
} finally {
refreshScheduler()
operationInFlight = false
}
}
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
return withDatabaseOperation(async () => {
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
const tempDir = path.join(os.tmpdir(), "mmapp-db-backup")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `manager-${randomUUID()}.db`)
try {
await sqliteDatabase.backup(tempPath)
const buffer = await readFile(tempPath)
return {
filename: `mikrotik-manager_${fmtTimestamp()}.db`,
buffer,
}
} finally {
await rm(tempPath, { force: true })
}
})
}
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void> {
if (buffer.length > MAX_RESTORE_BYTES) {
throw new Error("Файл бэкапа слишком большой")
}
assertSqliteFile(buffer)
await withDatabaseOperation(async () => {
const tempDir = path.join(os.tmpdir(), "mmapp-db-restore")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `restore-${randomUUID()}.db`)
let source: SqliteHandle | null = null
try {
await writeFile(tempPath, buffer)
source = new Database(tempPath, { readonly: true, fileMustExist: true })
await source.backup(resolveDatabasePath())
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
} finally {
source?.close()
await rm(tempPath, { force: true })
}
})
}
export function getSystemDatabasePath(): string {
return resolveDatabasePath()
}
+60
View File
@@ -0,0 +1,60 @@
import { ApiClientError } from "@/shared/api/http-client"
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function parseFilename(contentDisposition: string | null, fallback: string): string {
if (!contentDisposition) return fallback
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
if (utfMatch?.[1]) {
try {
return decodeURIComponent(utfMatch[1])
} catch {
return utfMatch[1]
}
}
const plainMatch = /filename="([^"]+)"/i.exec(contentDisposition)
if (plainMatch?.[1]) return plainMatch[1]
return fallback
}
export async function downloadSystemDatabaseBackup(
baseUrl: string,
): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/backup`)
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
const blob = await res.blob()
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
return { blob, filename }
}
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
const body = await file.arrayBuffer()
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/restore`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body,
})
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
}