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"
|
"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}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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