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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user