diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx
index 7ec6b1c..a711b4f 100644
--- a/app/(main)/settings/page.tsx
+++ b/app/(main)/settings/page.tsx
@@ -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 (
+
+
+
+
+
+
+
Восстановить базу приложения?
+
{filename}
+
+
+
+ Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать
+ актуальный бэкап.
+
+
+
+
+
+
+
+ )
+}
+
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
return (
@@ -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
(null)
+ const dbRestoreInputRef = useRef(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() {
+
+
+ База данных приложения
+
+ Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
+ сбора данных приостанавливается.
+
+
+
+ {!systemDbAvailable && (
+
+
+
Доступно только в live-режиме при доступном бекенде.
+
+ )}
+
+
+
+
+
+
{
+ const file = e.target.files?.[0] ?? null
+ if (!file) return
+ setDbRestoreFile(file)
+ }}
+ />
+
+
+
+ {dbRestoreFile && (
+
{dbRestoreFile.name}
+ )}
+
+
+
+
+
Route AI
@@ -1696,6 +1845,18 @@ export default function SettingsPage() {
/>
{/* delete confirm */}
+ {dbRestoreFile && (
+ { void handleSystemDatabaseRestoreConfirm() }}
+ onCancel={() => {
+ if (dbRestoreBusy) return
+ setDbRestoreFile(null)
+ if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
+ }}
+ />
+ )}
{deleteTarget && (
{
+ 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
diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts
new file mode 100644
index 0000000..8b3cd78
--- /dev/null
+++ b/backend/src/services/system-database-backup.ts
@@ -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
+
+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(fn: () => Promise | T): Promise {
+ 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 {
+ 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()
+}
diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts
new file mode 100644
index 0000000..0a34b62
--- /dev/null
+++ b/shared/api/system-database.ts
@@ -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 {
+ 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)
+ }
+}