diff --git a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx index f2ca41b85e..5f86cb9ee3 100644 --- a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx @@ -6,6 +6,7 @@ import { PenBoxIcon, PlusIcon, RefreshCw, + X, } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -106,6 +107,8 @@ const Schema = z .optional(), }) .optional(), + includePaths: z.array(z.string()).optional(), + excludePaths: z.array(z.string()).optional(), }) .superRefine((data, ctx) => { if (data.backupType === "compose" && !data.databaseType) { @@ -219,6 +222,8 @@ export const HandleBackup = ({ databaseType: backupType === "compose" ? undefined : databaseType, backupType: backupType, metadata: {}, + includePaths: undefined, + excludePaths: databaseType === "web-server" ? ["/applications/**", "/compose/**"] : undefined, }, resolver: zodResolver(Schema), }); @@ -256,8 +261,10 @@ export const HandleBackup = ({ databaseType: backup?.databaseType ?? databaseType, backupType: backup?.backupType ?? backupType, metadata: backup?.metadata ?? {}, + includePaths: backup?.includePaths ?? undefined, + excludePaths: backup?.excludePaths ?? (databaseType === "web-server" ? ["/applications/**", "/compose/**"] : undefined), }); - }, [form, form.reset, backupId, backup]); + }, [form, form.reset, backupId, backup, databaseType]); const onSubmit = async (data: z.infer) => { const getDatabaseId = @@ -300,6 +307,8 @@ export const HandleBackup = ({ backupId: backupId ?? "", backupType, metadata: data.metadata, + includePaths: data.includePaths, + excludePaths: data.excludePaths, }) .then(async () => { toast.success(`Backup ${backupId ? "Updated" : "Created"}`); @@ -624,6 +633,142 @@ export const HandleBackup = ({ ); }} /> + {databaseType === "web-server" && ( + <> + { + const [inputValue, setInputValue] = useState(""); + const paths = field.value || []; + + const addPath = () => { + if (inputValue.trim() && !paths.includes(inputValue.trim())) { + field.onChange([...paths, inputValue.trim()]); + setInputValue(""); + } + }; + + const removePath = (path: string) => { + field.onChange(paths.filter((p) => p !== path)); + }; + + return ( + + Exclude Paths + + Specify paths to exclude from the backup using glob patterns (e.g., /applications/**, /compose/**) + +
+ + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addPath(); + } + }} + /> + + +
+ {paths.length > 0 && ( +
+ {paths.map((path) => ( +
+ {path} + +
+ ))} +
+ )} + +
+ ); + }} + /> + { + const [inputValue, setInputValue] = useState(""); + const paths = field.value || []; + + const addPath = () => { + if (inputValue.trim() && !paths.includes(inputValue.trim())) { + field.onChange([...paths, inputValue.trim()]); + setInputValue(""); + } + }; + + const removePath = (path: string) => { + field.onChange(paths.filter((p) => p !== path)); + }; + + return ( + + Include Paths (Optional) + + Specify specific paths to include. If specified, only these paths will be backed up (e.g., /traefik/**, /ssh/**) + +
+ + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addPath(); + } + }} + /> + + +
+ {paths.length > 0 && ( +
+ {paths.map((path) => ( +
+ {path} + +
+ ))} +
+ )} + +
+ ); + }} + /> + + )} (), + // Path filtering for web-server backups + includePaths: jsonb("includePaths").$type(), + excludePaths: jsonb("excludePaths").$type(), }); export const backupsRelations = relations(backups, ({ one, many }) => ({ @@ -144,6 +147,8 @@ const createSchema = createInsertSchema(backups, { mongoId: z.string().optional(), userId: z.string().optional(), metadata: z.any().optional(), + includePaths: z.array(z.string()).optional(), + excludePaths: z.array(z.string()).optional(), }); export const apiCreateBackup = createSchema.pick({ @@ -163,6 +168,8 @@ export const apiCreateBackup = createSchema.pick({ composeId: true, serviceName: true, metadata: true, + includePaths: true, + excludePaths: true, }); export const apiFindOneBackup = createSchema @@ -189,6 +196,8 @@ export const apiUpdateBackup = createSchema serviceName: true, metadata: true, databaseType: true, + includePaths: true, + excludePaths: true, }) .required(); diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 4d13ae31ae..d8780f7d04 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -66,9 +66,29 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { writeStream.write(`Cleaning up temp file: ${cleanupCommand}\n`); await execAsync(cleanupCommand); - await execAsync( - `rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`, - ); + // Build rsync command with include/exclude patterns + let rsyncCommand = "rsync -a --ignore-errors"; + + // Add include patterns if specified + if (backup.includePaths && backup.includePaths.length > 0) { + for (const pattern of backup.includePaths) { + rsyncCommand += ` --include="${pattern}"`; + } + // When using --include, we need to exclude everything else + rsyncCommand += ' --exclude="*"'; + } + + // Add exclude patterns if specified + if (backup.excludePaths && backup.excludePaths.length > 0) { + for (const pattern of backup.excludePaths) { + rsyncCommand += ` --exclude="${pattern}"`; + } + } + + rsyncCommand += ` ${BASE_PATH}/ ${tempDir}/filesystem/`; + + writeStream.write(`Running rsync command: ${rsyncCommand}\n`); + await execAsync(rsyncCommand); writeStream.write("Copied filesystem to temp directory\n");