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) + } +}