From 24f3be3c00d78c2413e0d86c6ee8a2cee668569b Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:47:55 -0600 Subject: [PATCH] Add HandleBackup component to manage backup creation and updates, replacing the deprecated UpdateBackup component. Integrate dynamic form handling for various database types and metadata requirements. Update ShowBackups to utilize HandleBackup for both creating and updating backups, enhancing the user interface for better backup management. --- .../{add-backup.tsx => handle-backup.tsx} | 307 +++++--- .../database/backups/show-backups.tsx | 18 +- .../database/backups/update-backup.tsx | 681 ------------------ packages/server/src/utils/restore/compose.ts | 3 - packages/server/src/utils/restore/postgres.ts | 2 - 5 files changed, 210 insertions(+), 801 deletions(-) rename apps/dokploy/components/dashboard/database/backups/{add-backup.tsx => handle-backup.tsx} (71%) delete mode 100644 apps/dokploy/components/dashboard/database/backups/update-backup.tsx diff --git a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx similarity index 71% rename from apps/dokploy/components/dashboard/database/backups/add-backup.tsx rename to apps/dokploy/components/dashboard/database/backups/handle-backup.tsx index 1b3828e6..2d8a6b6d 100644 --- a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx @@ -49,7 +49,7 @@ import { import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { DatabaseZap, PlusIcon, RefreshCw } from "lucide-react"; +import { DatabaseZap, PenBoxIcon, PlusIcon, RefreshCw } from "lucide-react"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -58,75 +58,140 @@ import { z } from "zod"; type CacheType = "cache" | "fetch"; -const getMetadataSchema = ( - backupType: "database" | "compose", - databaseType: DatabaseType, -) => { - if (backupType !== "compose") return z.object({}).optional(); +type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; - const schemas = { - postgres: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - }), - mariadb: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - databasePassword: z.string().min(1, "Database password is required"), - }), - mongo: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - databasePassword: z.string().min(1, "Database password is required"), - }), - mysql: z.object({ - databaseRootPassword: z.string().min(1, "Root password is required"), - }), - "web-server": z.object({}), - }; - - return z.object({ - [databaseType]: schemas[databaseType], +const Schema = z + .object({ + destinationId: z.string().min(1, "Destination required"), + schedule: z.string().min(1, "Schedule (Cron) required"), + prefix: z.string().min(1, "Prefix required"), + enabled: z.boolean(), + database: z.string().min(1, "Database required"), + keepLatestCount: z.coerce.number().optional(), + serviceName: z.string().nullable(), + databaseType: z + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .optional(), + backupType: z.enum(["database", "compose"]), + metadata: z + .object({ + postgres: z + .object({ + databaseUser: z.string(), + }) + .optional(), + mariadb: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mongo: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mysql: z + .object({ + databaseRootPassword: z.string(), + }) + .optional(), + }) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.backupType === "compose" && !data.databaseType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database type is required for compose backups", + path: ["databaseType"], + }); + } + if (data.backupType === "compose" && data.databaseType) { + if (data.databaseType === "postgres") { + if (!data.metadata?.postgres?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for PostgreSQL", + path: ["metadata", "postgres", "databaseUser"], + }); + } + } else if (data.databaseType === "mariadb") { + if (!data.metadata?.mariadb?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MariaDB", + path: ["metadata", "mariadb", "databaseUser"], + }); + } + if (!data.metadata?.mariadb?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MariaDB", + path: ["metadata", "mariadb", "databasePassword"], + }); + } + } else if (data.databaseType === "mongo") { + if (!data.metadata?.mongo?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MongoDB", + path: ["metadata", "mongo", "databaseUser"], + }); + } + if (!data.metadata?.mongo?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MongoDB", + path: ["metadata", "mongo", "databasePassword"], + }); + } + } else if (data.databaseType === "mysql") { + if (!data.metadata?.mysql?.databaseRootPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Root password is required for MySQL", + path: ["metadata", "mysql", "databaseRootPassword"], + }); + } + } + } }); -}; - -const Schema = z.object({ - destinationId: z.string().min(1, "Destination required"), - schedule: z.string().min(1, "Schedule (Cron) required"), - prefix: z.string().min(1, "Prefix required"), - enabled: z.boolean(), - database: z.string().min(1, "Database required"), - keepLatestCount: z.coerce.number().optional(), - serviceName: z.string().nullable(), -}); - -type Schema = z.infer; interface Props { - id: string; + id?: string; + backupId?: string; databaseType?: DatabaseType; refetch: () => void; backupType: "database" | "compose"; } -type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; - -export const AddBackup = ({ +export const HandleBackup = ({ id, + backupId, databaseType = "postgres", refetch, backupType = "database", }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const { data, isLoading } = api.destination.all.useQuery(); + const { data: backup } = api.backup.one.useQuery( + { + backupId: backupId ?? "", + }, + { + enabled: !!backupId, + }, + ); const [cacheType, setCacheType] = useState("cache"); - const [selectedDatabaseType, setSelectedDatabaseType] = - useState(databaseType as DatabaseType); - const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } = - api.backup.create.useMutation(); + backupId + ? api.backup.update.useMutation() + : api.backup.create.useMutation(); - const schema = Schema.extend({ - metadata: getMetadataSchema(backupType, selectedDatabaseType), - }); - - const form = useForm>({ + const form = useForm>({ defaultValues: { database: databaseType === "web-server" ? "dokploy" : "", destinationId: "", @@ -135,11 +200,15 @@ export const AddBackup = ({ schedule: "", keepLatestCount: undefined, serviceName: null, + databaseType: backupType === "compose" ? undefined : databaseType, + backupType: backupType, metadata: {}, }, - resolver: zodResolver(schema), + resolver: zodResolver(Schema), }); + console.log(backup); + const { data: services, isFetching: isLoadingServices, @@ -147,38 +216,33 @@ export const AddBackup = ({ refetch: refetchServices, } = api.compose.loadServices.useQuery( { - composeId: id, + composeId: backup?.composeId ?? id ?? "", type: cacheType, }, { retry: false, refetchOnWindowFocus: false, - enabled: backupType === "compose", + enabled: backupType === "compose" && !!backup?.composeId && !!id, }, ); useEffect(() => { form.reset({ - database: databaseType === "web-server" ? "dokploy" : "", - destinationId: "", - enabled: true, - prefix: "/", - schedule: "", - keepLatestCount: undefined, - serviceName: null, - metadata: {}, + database: + (backup?.database ?? databaseType === "web-server") ? "dokploy" : "", + destinationId: backup?.destinationId ?? "", + enabled: backup?.enabled ?? true, + prefix: backup?.prefix ?? "/", + schedule: backup?.schedule ?? "", + keepLatestCount: backup?.keepLatestCount ?? undefined, + serviceName: backup?.serviceName ?? null, + databaseType: backup?.databaseType ?? databaseType, + backupType: backup?.backupType ?? backupType, + metadata: backup?.metadata ?? {}, }); - }, [form, form.reset, form.formState.isSubmitSuccessful, databaseType]); - - const onSubmit = async (data: Schema) => { - if (backupType === "compose" && !data.serviceName) { - form.setError("serviceName", { - type: "manual", - message: "Service name is required for compose backups", - }); - return; - } + }, [form, form.reset, backupId, backup]); + const onSubmit = async (data: z.infer) => { const getDatabaseId = backupType === "compose" ? { @@ -212,33 +276,50 @@ export const AddBackup = ({ schedule: data.schedule, enabled: data.enabled, database: data.database, - keepLatestCount: data.keepLatestCount, - databaseType: - backupType === "compose" ? selectedDatabaseType : databaseType, + keepLatestCount: data.keepLatestCount ?? null, + databaseType: data.databaseType || databaseType, serviceName: data.serviceName, ...getDatabaseId, + backupId: backupId ?? "", backupType, + metadata: data.metadata, }) .then(async () => { - toast.success("Backup Created"); + toast.success(`Backup ${backupId ? "Updated" : "Created"}`); refetch(); + setIsOpen(false); }) .catch(() => { - toast.error("Error creating a backup"); + toast.error(`Error ${backupId ? "updating" : "creating"} a backup`); }); }; + return ( - + - + {backupId ? ( + + ) : ( + + )} - Create a backup - Add a new backup + + {backupId ? "Update Backup" : "Create Backup"} + + + {backupId ? "Update a backup" : "Add a new backup"} +
@@ -254,25 +335,33 @@ export const AddBackup = ({ )} {backupType === "compose" && ( - - Database Type - - + ( + + Database Type + + + + )} + /> )} {backupType === "compose" && ( <> - {selectedDatabaseType === "postgres" && ( + {form.watch("databaseType") === "postgres" && ( )} - {selectedDatabaseType === "mariadb" && ( + {form.watch("databaseType") === "mariadb" && ( <> )} - {selectedDatabaseType === "mongo" && ( + {form.watch("databaseType") === "mongo" && ( <> )} - {selectedDatabaseType === "mysql" && ( + {form.watch("databaseType") === "mysql" && ( - Create + {backupId ? "Update" : "Create"} diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 062e7be7..0a6517f9 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -19,10 +19,10 @@ import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import type { ServiceType } from "../../application/advanced/show-resources"; -import { AddBackup } from "./add-backup"; import { RestoreBackup } from "./restore-backup"; -import { UpdateBackup } from "./update-backup"; import { AlertBlock } from "@/components/shared/alert-block"; +import { HandleBackup } from "./handle-backup"; +import { cn } from "@/lib/utils"; interface Props { id: string; @@ -100,7 +100,7 @@ export const ShowBackups = ({ {postgres && postgres?.backups?.length > 0 && (
{databaseType !== "web-server" && ( -
- (
-
+
{backup.backupType === "compose" && ( <>
@@ -265,7 +270,8 @@ export const ShowBackups = ({ - diff --git a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx b/apps/dokploy/components/dashboard/database/backups/update-backup.tsx deleted file mode 100644 index 8a12c535..00000000 --- a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx +++ /dev/null @@ -1,681 +0,0 @@ -import { AlertBlock } from "@/components/shared/alert-block"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - CheckIcon, - ChevronsUpDown, - DatabaseZap, - PenBoxIcon, - RefreshCw, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; - -type CacheType = "cache" | "fetch"; - -const getMetadataSchema = ( - backupType: "database" | "compose", - databaseType: DatabaseType, -) => { - if (backupType !== "compose") return z.object({}).optional(); - - const schemas = { - postgres: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - }), - mariadb: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - databasePassword: z.string().min(1, "Database password is required"), - }), - mongo: z.object({ - databaseUser: z.string().min(1, "Database user is required"), - databasePassword: z.string().min(1, "Database password is required"), - }), - mysql: z.object({ - databaseRootPassword: z.string().min(1, "Root password is required"), - }), - "web-server": z.object({}), - }; - - return z.object({ - [databaseType]: schemas[databaseType as keyof typeof schemas], - }); -}; - -const Schema = z.object({ - destinationId: z.string().min(1, "Destination required"), - schedule: z.string().min(1, "Schedule (Cron) required"), - prefix: z.string().min(1, "Prefix required"), - enabled: z.boolean(), - database: z.string().min(1, "Database required"), - keepLatestCount: z.coerce.number().optional(), - serviceName: z.string().nullable(), - metadata: z.object({}).optional(), -}); - -interface Props { - backupId: string; - refetch: () => void; -} - -type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; - -export const UpdateBackup = ({ backupId, refetch }: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [cacheType, setCacheType] = useState("cache"); - - const { data, isLoading } = api.destination.all.useQuery(); - const { data: backup } = api.backup.one.useQuery( - { - backupId, - }, - { - enabled: !!backupId, - }, - ); - const [selectedDatabaseType, setSelectedDatabaseType] = - useState( - (backup?.databaseType as DatabaseType) || "postgres", - ); - const { - data: services, - isFetching: isLoadingServices, - error: errorServices, - refetch: refetchServices, - } = api.compose.loadServices.useQuery( - { - composeId: backup?.composeId || "", - type: cacheType, - }, - { - retry: false, - refetchOnWindowFocus: false, - enabled: - isOpen && backup?.backupType === "compose" && !!backup?.composeId, - }, - ); - - const { mutateAsync, isLoading: isLoadingUpdate } = - api.backup.update.useMutation(); - - const schema = Schema.extend({ - metadata: getMetadataSchema( - backup?.backupType || "database", - selectedDatabaseType, - ), - }); - - const form = useForm>({ - defaultValues: { - database: "", - destinationId: "", - enabled: true, - prefix: "/", - schedule: "", - keepLatestCount: undefined, - serviceName: null, - metadata: {}, - }, - resolver: zodResolver(schema), - }); - - useEffect(() => { - if (backup) { - form.reset({ - database: backup.database, - destinationId: backup.destinationId, - enabled: backup.enabled || false, - prefix: backup.prefix, - schedule: backup.schedule, - serviceName: backup.serviceName || null, - keepLatestCount: backup.keepLatestCount - ? Number(backup.keepLatestCount) - : undefined, - metadata: backup.metadata || {}, - }); - } - }, [form, form.reset, backup]); - - useEffect(() => { - if (backup?.backupType === "compose") { - const currentValues = form.getValues(); - form.reset( - { - ...currentValues, - }, - { keepDefaultValues: true }, - ); - } - }, [selectedDatabaseType, backup?.backupType, form]); - - const onSubmit = async (data: z.infer) => { - if (backup?.backupType === "compose" && !data.serviceName) { - form.setError("serviceName", { - type: "manual", - message: "Service name is required for compose backups", - }); - return; - } - - await mutateAsync({ - backupId, - destinationId: data.destinationId, - prefix: data.prefix, - schedule: data.schedule, - enabled: data.enabled, - database: data.database, - serviceName: data.serviceName, - keepLatestCount: data.keepLatestCount as number | null, - metadata: data.metadata || {}, - databaseType: - backup?.backupType === "compose" - ? selectedDatabaseType - : (backup?.databaseType as DatabaseType), - }) - .then(async () => { - toast.success("Backup Updated"); - refetch(); - setIsOpen(false); - }) - .catch(() => { - toast.error("Error updating the Backup"); - }); - }; - - return ( - - - - - - - Update Backup - Update the backup - - -
- -
- {errorServices && ( - - {errorServices?.message} - - )} - {backup?.backupType === "compose" && ( - - Database Type - - - )} - ( - - Destination - - - - - - - - - - {isLoading && ( - - Loading Destinations.... - - )} - No destinations found. - - - {data?.map((destination) => ( - { - form.setValue( - "destinationId", - destination.destinationId, - ); - }} - > - {destination.name} - - - ))} - - - - - - - - - )} - /> - {backup?.backupType === "compose" && ( -
- ( - - Service Name -
- - - - - - - -

- Fetch: Will clone the repository and load the - services -

-
-
-
- - - - - - -

- Cache: If you previously deployed this - compose, it will read the services from the - last deployment/fetch from the repository -

-
-
-
-
- - -
- )} - /> -
- )} - { - return ( - - Database - - - - - - ); - }} - /> - { - return ( - - Schedule (Cron) - - - - - - ); - }} - /> - { - return ( - - Prefix Destination - - - - - Use if you want to back up in a specific path of your - destination/bucket - - - - - ); - }} - /> - { - return ( - - Keep the latest - - - - - Optional. If provided, only keeps the latest N backups - in the cloud. - - - - ); - }} - /> - ( - -
- Enabled - - Enable or disable the backup - -
- - - -
- )} - /> - {backup?.backupType === "compose" && ( - <> - {selectedDatabaseType === "postgres" && ( - ( - - Database User - - - - - - )} - /> - )} - - {selectedDatabaseType === "mariadb" && ( - <> - ( - - Database User - - - - - - )} - /> - ( - - Database Password - - - - - - )} - /> - - )} - - {selectedDatabaseType === "mongo" && ( - <> - ( - - Database User - - - - - - )} - /> - ( - - Database Password - - - - - - )} - /> - - )} - - {selectedDatabaseType === "mysql" && ( - ( - - Root Password - - - - - - )} - /> - )} - - )} -
- - - -
- -
-
- ); -}; diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index f9deb52b..1b7f9fd9 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -14,7 +14,6 @@ export const restoreComposeBackup = async ( emit: (log: string) => void, ) => { try { - console.log({ metadata }); const { serverId } = compose; const rcloneFlags = getS3Credentials(destination); @@ -78,8 +77,6 @@ export const restoreComposeBackup = async ( emit(stderr); } else { const { stdout, stderr } = await execAsync(restoreCommand); - console.log("stdout", stdout); - console.log("stderr", stderr); emit(stdout); emit(stderr); } diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 5ab7c74e..d6eae283 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -40,8 +40,6 @@ rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${ emit(stderr); } else { const { stdout, stderr } = await execAsync(command); - console.log("stdout", stdout); - console.log("stderr", stderr); emit(stdout); emit(stderr); }