Adding uploading backup feature.

This commit is contained in:
charlesgauthereau
2025-12-26 16:08:36 +01:00
parent ee285df7b5
commit fd73d383f9
9 changed files with 1267 additions and 2 deletions
@@ -0,0 +1,46 @@
"use client"
import {useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {DatabaseWith} from "@/db/schema/07_database";
import {Separator} from "@/components/ui/separator";
import {Import} from "lucide-react";
import {UploadBackupZone} from "@/components/wrappers/dashboard/database/import/upload-backup-zone";
type ImportModalProps = {
database: DatabaseWith;
}
export const ImportModal = ({database}: ImportModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)}>
<Import/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Backup</DialogTitle>
<DialogDescription>
Import backup file of your choice.
</DialogDescription>
<Separator className="mt-3 "/>
<UploadBackupZone
onSuccessAction={() => setOpen(false)}
databaseId={database.id}/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
"use client";
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
import {useMutation} from "@tanstack/react-query";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {useRouter} from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {toast} from "sonner";
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
import {Card, CardContent} from "@/components/ui/card";
type UploadRetentionZoneProps = {
onSuccessAction?: () => void;
databaseId: string;
};
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
const [file, setFile] = useState<File | null>(null);
const mutationUpload = useMutation({
mutationFn: async (file: File) => {
try {
setIsProcessing(true);
const formData = new FormData();
formData.append("file", file);
formData.append("databaseId", databaseId);
const result = await uploadBackupAction(formData)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
} catch (err) {
console.error(err);
toast.error("An error occurred while upload in the backup");
} finally {
router.refresh();
setIsProcessing(false);
}
},
});
const acceptDbImportFiles: Record<string, string[]> = {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
"text/plain": [".sql"],
"application/octet-stream": [".dump"],
};
return (
<>
{isProcessing ? (
<UploadLoader label="Uploading database backup…"/>
) : (
<DropZoneFile
accept={acceptDbImportFiles}
maxSize={500 * 1024 * 1024}
maxFiles={1}
description="Import database backup"
fileKind="Database file (.sql, .dump)"
dragMessage="Click or drag a database dump here"
onFileDropAction={(file: File) => setFile(file)}
/>
)}
{(!isProcessing && file) && (
<div className="flex gap-4 justify-end">
<ButtonWithLoading
onClick={() => mutationUpload.mutateAsync(file)}
isPending={false}>
Upload
</ButtonWithLoading>
</div>
)}
</>
);
};
const UploadLoader = ({label = "Processing uploading..."}: { label?: string }) => {
return (
<Card className="w-full border">
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-muted-foreground">
<div className="relative">
<Loader2 className="w-8 h-8 animate-spin inset-0"/>
</div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">
Do not close this window
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,111 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {z} from "zod";
import {env} from "@/env.mjs";
export const uploadBackupAction = userAction
.schema(z.instanceof(FormData))
.action(async ({parsedInput: formData}): Promise<ServerActionResult<Backup>> => {
try {
const file = formData.get("file") as File;
const databaseId = formData.get("databaseId") as string;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
alertPolicies: true
}
});
if (!database) {
return {
success: false,
actionError: {
message: "Database does not exist",
status: 500,
cause: "Unknown error",
},
};
}
const fileExtension = getFileExtension(database.dbms)
const arrayBuffer = await file.arrayBuffer();
const uuid = uuidv4();
const fileName = `imported_${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer);
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return {
success: false,
actionError: {
message: "Settings not set",
status: 500,
cause: "Unknown error",
},
};
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return {
success: false,
actionError: {
message: "An error has occurred while uploading file",
status: 500,
cause: "Unknown error",
},
};
}
const [backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'success',
databaseId: database.id,
file: fileName,
})
.returning();
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup successfully imported`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to import backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -38,8 +38,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
if (!props.backups) return [];
return props.backups.filter(backup => {
// --- Status Filter ---
if (selectedFilters.length > 0) {
const selectedValues = selectedFilters.map(f => f.value);
const status = backup.deletedAt != null ? "deleted" : "available";