feat: change backup format to plain SQL, admin-only cron, backup now button, import restore UI

This commit is contained in:
ff
2026-04-11 23:37:43 -04:00
parent 4025ca45e0
commit 66a3d271f1
6 changed files with 329 additions and 269 deletions

View File

@@ -2,7 +2,6 @@ import cron from "node-cron";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { storage } from "../storage"; import { storage } from "../storage";
import { NotificationTypes } from "@repo/db/types";
import { backupDatabaseToPath } from "../services/databaseBackupService"; import { backupDatabaseToPath } from "../services/databaseBackupService";
// Local backup folder in the app root (apps/Backend/backups) // Local backup folder in the app root (apps/Backend/backups)
@@ -17,24 +16,17 @@ function ensureLocalBackupDir() {
} }
} }
async function runForAllUsers( async function getAdminUser() {
handler: (user: Awaited<ReturnType<typeof storage.getUsers>>[number]) => Promise<void>
) {
const batchSize = 100; const batchSize = 100;
let offset = 0; let offset = 0;
while (true) { while (true) {
const users = await storage.getUsers(batchSize, offset); const users = await storage.getUsers(batchSize, offset);
if (!users || users.length === 0) break; if (!users || users.length === 0) break;
for (const user of users) { const admin = users.find((u) => u.username === "admin");
if (user.id == null) continue; if (admin) return admin;
try {
await handler(user);
} catch (err) {
console.error(`Error processing user ${user.id}:`, err);
}
}
offset += batchSize; offset += batchSize;
} }
return null;
} }
export const startBackupCron = () => { export const startBackupCron = () => {
@@ -45,39 +37,31 @@ export const startBackupCron = () => {
console.log("🔄 [8 PM] Running local auto-backup..."); console.log("🔄 [8 PM] Running local auto-backup...");
ensureLocalBackupDir(); ensureLocalBackupDir();
await runForAllUsers(async (user) => { const admin = await getAdminUser();
if (!user.autoBackupEnabled) { if (!admin) {
// No local backup — check if a 7-day reminder is needed console.warn("No admin user found, skipping local backup.");
const lastBackup = await storage.getLastBackup(user.id); return;
const daysSince = lastBackup?.createdAt }
? (Date.now() - new Date(lastBackup.createdAt).getTime()) / (1000 * 60 * 60 * 24)
: Infinity;
if (daysSince >= 7) {
await storage.createNotification(
user.id,
"BACKUP" as NotificationTypes,
"⚠️ It has been more than 7 days since your last backup."
);
console.log(`Reminder notification created for user ${user.id}`);
}
return;
}
try { if (!admin.autoBackupEnabled) {
const filename = `dental_backup_user${user.id}_${Date.now()}.zip`; console.log("✅ [8 PM] Auto-backup is disabled for admin, skipped.");
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename }); return;
await storage.createBackup(user.id); }
await storage.deleteNotificationsByType(user.id, "BACKUP");
console.log(`✅ Local backup done for user ${user.id}${filename}`); try {
} catch (err) { const filename = `dental_backup_${Date.now()}.sql`;
console.error(`Local backup failed for user ${user.id}`, err); await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
await storage.createNotification( await storage.createBackup(admin.id);
user.id, await storage.deleteNotificationsByType(admin.id, "BACKUP");
"BACKUP", console.log(`✅ Local backup done → ${filename}`);
"❌ Automatic backup failed. Please check the server backup folder." } catch (err) {
); console.error("Local backup failed:", err);
} await storage.createNotification(
}); admin.id,
"BACKUP",
"❌ Automatic backup failed. Please check the server backup folder."
);
}
console.log("✅ [8 PM] Local backup complete."); console.log("✅ [8 PM] Local backup complete.");
}); });
@@ -88,46 +72,52 @@ export const startBackupCron = () => {
cron.schedule("0 21 * * *", async () => { cron.schedule("0 21 * * *", async () => {
console.log("🔄 [9 PM] Running USB backup..."); console.log("🔄 [9 PM] Running USB backup...");
await runForAllUsers(async (user) => { const admin = await getAdminUser();
if (!user.usbBackupEnabled) return; if (!admin) {
console.warn("No admin user found, skipping USB backup.");
return;
}
const destination = await storage.getActiveBackupDestination(user.id); if (!admin.usbBackupEnabled) {
if (!destination) { console.log("✅ [9 PM] USB backup is disabled for admin, skipped.");
await storage.createNotification( return;
user.id, }
"BACKUP",
"❌ USB backup failed: no backup destination configured."
);
return;
}
// The target is the "USB Backup" subfolder inside the configured drive path const destination = await storage.getActiveBackupDestination(admin.id);
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME); if (!destination) {
await storage.createNotification(
admin.id,
"BACKUP",
"❌ USB backup failed: no backup destination configured."
);
return;
}
if (!fs.existsSync(usbBackupPath)) { const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
await storage.createNotification(
user.id,
"BACKUP",
`❌ USB backup failed: folder "${USB_BACKUP_FOLDER_NAME}" not found on the drive. Make sure the USB drive is connected and the folder exists.`
);
return;
}
try { if (!fs.existsSync(usbBackupPath)) {
const filename = `dental_backup_usb_${Date.now()}.zip`; await storage.createNotification(
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename }); admin.id,
await storage.createBackup(user.id); "BACKUP",
await storage.deleteNotificationsByType(user.id, "BACKUP"); `❌ USB backup failed: folder "${USB_BACKUP_FOLDER_NAME}" not found on the drive. Make sure the USB drive is connected and the folder exists.`
console.log(`✅ USB backup done for user ${user.id}${usbBackupPath}/${filename}`); );
} catch (err) { return;
console.error(`USB backup failed for user ${user.id}`, err); }
await storage.createNotification(
user.id, try {
"BACKUP", const filename = `dental_backup_usb_${Date.now()}.sql`;
"❌ USB backup failed. Please check the USB drive and try again." await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
); await storage.createBackup(admin.id);
} await storage.deleteNotificationsByType(admin.id, "BACKUP");
}); console.log(`✅ USB backup done → ${usbBackupPath}/${filename}`);
} catch (err) {
console.error("USB backup failed:", err);
await storage.createNotification(
admin.id,
"BACKUP",
"❌ USB backup failed. Please check the USB drive and try again."
);
}
console.log("✅ [9 PM] USB backup complete."); console.log("✅ [9 PM] USB backup complete.");
}); });

View File

@@ -3,30 +3,22 @@ import { spawn } from "child_process";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import fs from "fs"; import fs from "fs";
import multer from "multer";
import { prisma } from "@repo/db/client"; import { prisma } from "@repo/db/client";
import { storage } from "../storage"; import { storage } from "../storage";
import archiver from "archiver";
import { backupDatabaseToPath } from "../services/databaseBackupService"; import { backupDatabaseToPath } from "../services/databaseBackupService";
const restoreUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
fileFilter: (_req, file, cb) => {
if (file.originalname.toLowerCase().endsWith(".sql")) cb(null, true);
else cb(new Error("Only .sql files are allowed"));
},
});
const router = Router(); const router = Router();
/**
* Create a database backup
*
* - Uses pg_dump in directory format for parallel dump to a tmp dir
* - Uses 'archiver' to create zip or gzipped tar stream directly to response
* - Supports explicit override via BACKUP_ARCHIVE_FORMAT env var ('zip' or 'tar')
* - Ensures cleanup of tmp dir on success/error/client disconnect
*/
// helper to remove directory (sync to keep code straightforward)
function safeRmDir(dir: string) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch (e) {
/* ignore */
}
}
router.post("/backup", async (req: Request, res: Response): Promise<any> => { router.post("/backup", async (req: Request, res: Response): Promise<any> => {
try { try {
const userId = req.user?.id; const userId = req.user?.id;
@@ -34,32 +26,12 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
const destination = await storage.getActiveBackupDestination(userId); const filename = `dental_backup_${Date.now()}.sql`;
// create a unique tmp directory for directory-format dump // Spawn pg_dump in plain SQL format, streaming stdout directly to response
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
// Decide archive format
// BACKUP_ARCHIVE_FORMAT can be 'zip' or 'tar' (case-insensitive)
const forced = (process.env.BACKUP_ARCHIVE_FORMAT || "").toLowerCase();
const useZip =
forced === "zip"
? true
: forced === "tar"
? false
: process.platform === "win32";
const filename = useZip
? `dental_backup_${Date.now()}.zip`
: `dental_backup_${Date.now()}.tar.gz`;
// Spawn pg_dump
const pgDump = spawn( const pgDump = spawn(
"pg_dump", "pg_dump",
[ [
"-Fd", // DIRECTORY format (required for parallel dump)
"-j",
"4", // number of parallel jobs — MUST be >0 for parallelism
"--no-acl", "--no-acl",
"--no-owner", "--no-owner",
"-h", "-h",
@@ -67,8 +39,6 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
"-U", "-U",
process.env.DB_USER || "postgres", process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db", process.env.DB_NAME || "dental_db",
"-f",
tmpDir, // write parallely
], ],
{ {
env: { env: {
@@ -84,127 +54,51 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
}); });
pgDump.on("error", (err) => { pgDump.on("error", (err) => {
safeRmDir(tmpDir);
console.error("Failed to start pg_dump:", err); console.error("Failed to start pg_dump:", err);
// If headers haven't been sent, respond; otherwise just end socket
if (!res.headersSent) { if (!res.headersSent) {
return res res.status(500).json({ error: "Failed to run pg_dump", details: err.message });
.status(500)
.json({ error: "Failed to run pg_dump", details: err.message });
} else { } else {
res.destroy(err); res.destroy(err);
} }
}); });
// Buffer first chunk to detect early failure before sending headers
let headersSent = false;
pgDump.stdout.once("data", (firstChunk) => {
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Type", "application/sql");
headersSent = true;
res.write(firstChunk);
pgDump.stdout.pipe(res);
});
pgDump.on("close", async (code) => { pgDump.on("close", async (code) => {
if (code !== 0) { if (code !== 0) {
safeRmDir(tmpDir);
console.error("pg_dump failed:", pgStderr || `exit ${code}`); console.error("pg_dump failed:", pgStderr || `exit ${code}`);
if (!res.headersSent) { if (!headersSent) {
return res.status(500).json({ return res.status(500).json({
error: "Backup failed", error: "Backup failed",
details: pgStderr || `pg_dump exited with ${code}`, details: pgStderr || `pg_dump exited with ${code}`,
}); });
} else { } else {
// headers already sent — destroy response
res.destroy(new Error("pg_dump failed")); res.destroy(new Error("pg_dump failed"));
return; return;
} }
} }
// pg_dump succeeded — stream archive directly to response using archiver
// Set headers before piping
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`
);
res.setHeader(
"Content-Type",
useZip ? "application/zip" : "application/gzip"
);
const archive = archiver(
useZip ? "zip" : "tar",
useZip ? {} : { gzip: true, gzipOptions: { level: 6 } }
);
let archErr: string | null = null;
archive.on("error", (err) => {
archErr = err.message;
console.error("Archiver error:", err);
// attempt to respond with error if possible
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to create archive",
details: err.message,
});
} else {
// if streaming already started, destroy the connection
res.destroy(err);
}
} catch (e) {
// swallow
} finally {
safeRmDir(tmpDir);
}
});
// If client disconnects while streaming
res.once("close", () => {
// destroy archiver (stop processing) and cleanup tmpDir
try {
archive.destroy();
} catch (e) {}
safeRmDir(tmpDir);
});
// When streaming finishes successfully
res.once("finish", async () => {
// cleanup the tmp dir used by pg_dump
safeRmDir(tmpDir);
// update metadata (try/catch so it won't break response flow)
try {
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
} catch (err) {
console.error("Backup saved but metadata update failed:", err);
}
});
// Pipe archive into response
archive.pipe(res);
// Add the dumped directory contents to the archive root
// `directory(source, dest)` where dest is false/'' to place contents at archive root
archive.directory(tmpDir + path.sep, false);
// finalize archive (this starts streaming)
try { try {
await archive.finalize(); await storage.createBackup(userId);
} catch (err: any) { await storage.deleteNotificationsByType(userId, "BACKUP");
console.error("Failed to finalize archive:", err); } catch (err) {
// if headers not sent, send 500; otherwise destroy console.error("Backup saved but metadata update failed:", err);
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to finalize archive",
details: String(err),
});
} else {
res.destroy(err);
}
} catch (e) {}
safeRmDir(tmpDir);
} }
if (!res.writableEnded) res.end();
}); });
} catch (err: any) { } catch (err: any) {
console.error("Unexpected error in /backup:", err); console.error("Unexpected error in /backup:", err);
if (!res.headersSent) { if (!res.headersSent) {
return res res.status(500).json({ message: "Internal server error", details: String(err) });
.status(500)
.json({ message: "Internal server error", details: String(err) });
} else { } else {
res.destroy(err); res.destroy(err);
} }
@@ -418,7 +312,7 @@ router.post("/backup-path", async (req, res) => {
}); });
} }
const filename = `dental_backup_${Date.now()}.zip`; const filename = `dental_backup_${Date.now()}.sql`;
try { try {
await backupDatabaseToPath({ await backupDatabaseToPath({
@@ -439,4 +333,43 @@ router.post("/backup-path", async (req, res) => {
} }
}); });
router.post("/restore", restoreUpload.single("file"), async (req: Request, res: Response): Promise<any> => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!req.file) return res.status(400).json({ error: "No file provided" });
const psql = spawn(
"psql",
[
"-h", process.env.DB_HOST || "localhost",
"-U", process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db",
],
{
env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD },
}
);
let stderr = "";
psql.stderr.on("data", (d) => (stderr += d.toString()));
psql.on("error", (err) => {
console.error("Failed to start psql:", err);
if (!res.headersSent)
res.status(500).json({ error: "Failed to run psql", details: err.message });
});
psql.on("close", (code) => {
if (code !== 0) {
console.error("psql restore failed:", stderr);
return res.status(500).json({ error: "Restore failed", details: stderr });
}
res.json({ success: true });
});
psql.stdin.write(req.file.buffer);
psql.stdin.end();
});
export default router; export default router;

View File

@@ -1,14 +1,4 @@
import { spawn } from "child_process"; import { spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import archiver from "archiver";
function safeRmDir(dir: string) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {}
}
interface BackupToPathParams { interface BackupToPathParams {
destinationPath: string; destinationPath: string;
@@ -19,24 +9,24 @@ export async function backupDatabaseToPath({
destinationPath, destinationPath,
filename, filename,
}: BackupToPathParams): Promise<void> { }: BackupToPathParams): Promise<void> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); const path = await import("path");
const fs = await import("fs");
const outputFile = path.join(destinationPath, filename);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const pgDump = spawn( const pgDump = spawn(
"pg_dump", "pg_dump",
[ [
"-Fd",
"-j",
"4",
"--no-acl", "--no-acl",
"--no-owner", "--no-owner",
"-h", "-h",
process.env.DB_HOST || "localhost", process.env.DB_HOST || "localhost",
"-U", "-U",
process.env.DB_USER || "postgres", process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db",
"-f", "-f",
tmpDir, outputFile,
process.env.DB_NAME || "dental_db",
], ],
{ {
env: { env: {
@@ -50,36 +40,15 @@ export async function backupDatabaseToPath({
pgDump.stderr.on("data", (d) => (pgError += d.toString())); pgDump.stderr.on("data", (d) => (pgError += d.toString()));
pgDump.on("close", async (code) => { pgDump.on("close", (code) => {
if (code !== 0) { if (code !== 0) {
safeRmDir(tmpDir); // clean up partial file if it was created
try {
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
} catch {}
return reject(new Error(pgError || "pg_dump failed")); return reject(new Error(pgError || "pg_dump failed"));
} }
resolve();
const outputFile = path.join(destinationPath, filename);
const outputStream = fs.createWriteStream(outputFile);
const archive = archiver("zip");
outputStream.on("error", (err) => {
safeRmDir(tmpDir);
reject(err);
});
archive.on("error", (err) => {
safeRmDir(tmpDir);
reject(err);
});
archive.pipe(outputStream);
archive.directory(tmpDir + path.sep, false);
archive.finalize();
archive.on("end", () => {
safeRmDir(tmpDir);
resolve();
});
}); });
}); });
} }

View File

@@ -14,7 +14,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { FolderOpen, Trash2 } from "lucide-react"; import { FolderOpen, HardDrive, Trash2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { FolderBrowserModal } from "./folder-browser-modal"; import { FolderBrowserModal } from "./folder-browser-modal";
@@ -102,6 +102,21 @@ export function BackupDestinationManager() {
}, },
}); });
const backupNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/backup-path");
if (!res.ok) throw new Error((await res.json()).error || "Backup failed");
return res.json();
},
onSuccess: (data) => {
toast({ title: "Backup complete", description: `Saved: ${data.filename}` });
queryClient.invalidateQueries({ queryKey: ["/db/status"] });
},
onError: (err: any) => {
toast({ title: "Backup failed", description: err.message, variant: "destructive" });
},
});
// ============================== // ==============================
// Folder browser // Folder browser
// ============================== // ==============================
@@ -172,13 +187,25 @@ export function BackupDestinationManager() {
className="flex justify-between items-center border rounded p-2" className="flex justify-between items-center border rounded p-2"
> >
<span className="text-sm text-gray-700">{d.path}</span> <span className="text-sm text-gray-700">{d.path}</span>
<Button <div className="flex gap-2">
size="sm" <Button
variant="destructive" size="sm"
onClick={() => setDeleteId(d.id)} variant="outline"
> onClick={() => backupNowMutation.mutate()}
<Trash2 className="h-4 w-4" /> disabled={backupNowMutation.isPending}
</Button> title="Backup now to this destination"
>
<HardDrive className="h-4 w-4 mr-1" />
{backupNowMutation.isPending ? "Backing up..." : "Backup Now"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteId(d.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,137 @@
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Upload, UploadCloud } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
export function ImportDatabaseSection() {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const restoreMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const token = localStorage.getItem("token");
const res = await fetch("/api/database-management/restore", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "Restore failed");
}
return res.json();
},
onSuccess: () => {
toast({
title: "Database Restored",
description: "The database has been successfully restored from the backup file.",
});
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
},
onError: (err: any) => {
toast({
title: "Restore Failed",
description: err.message,
variant: "destructive",
});
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
};
const handleImportClick = () => {
if (!selectedFile) return;
setConfirmOpen(true);
};
const handleConfirm = () => {
setConfirmOpen(false);
if (selectedFile) restoreMutation.mutate(selectedFile);
};
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<UploadCloud className="h-5 w-5" />
<span>Import Database</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-500">
Restore the database from a <span className="font-medium text-gray-700">.sql</span> backup file.
This will overwrite all existing data.
</p>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".sql"
onChange={handleFileChange}
className="block text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border file:border-gray-300 file:text-sm file:bg-white file:text-gray-700 hover:file:bg-gray-50 cursor-pointer"
/>
</div>
{selectedFile && (
<p className="text-sm text-gray-500">
Selected: <span className="font-medium text-gray-800">{selectedFile.name}</span>{" "}
({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)
</p>
)}
<Button
onClick={handleImportClick}
disabled={!selectedFile || restoreMutation.isPending}
variant="destructive"
className="flex items-center space-x-2"
>
<Upload className="h-4 w-4" />
<span>{restoreMutation.isPending ? "Restoring..." : "Import & Restore"}</span>
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle>
<AlertDialogDescription>
This will overwrite <strong>all existing data</strong> with the contents of{" "}
<strong>{selectedFile?.name}</strong>. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} className="bg-red-600 hover:bg-red-700">
Yes, restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -14,6 +14,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { formatDateToHumanReadable } from "@/utils/dateUtils"; import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager"; import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
import { ImportDatabaseSection } from "@/components/database-management/import-database-section";
export default function DatabaseManagementPage() { export default function DatabaseManagementPage() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -260,6 +261,9 @@ export default function DatabaseManagementPage() {
{/* Externa Drive automatic backup manager */} {/* Externa Drive automatic backup manager */}
<BackupDestinationManager /> <BackupDestinationManager />
{/* Import / Restore Database */}
<ImportDatabaseSection />
{/* Database Status Section */} {/* Database Status Section */}
<Card> <Card>
<CardHeader> <CardHeader>