feat: implement database backup and restore functionality in settings page
- 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:
@@ -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}
|
||||
|
||||
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
|
||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||
import alertsRoutes from "./routes/alerts.js"
|
||||
import backupsRoutes from "./routes/backups.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.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(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
|
||||
refreshScheduler()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user