From 37c34fdadc23c00e46004978ca9cf4e1842a8bdf Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Sat, 23 Aug 2025 12:32:17 +0300 Subject: [PATCH 1/3] feat(volume): Add possibility to keep latest N backups for custom containers --- .../volume-backups/handle-volume-backups.tsx | 49 ++++++++++++++----- .../server/src/utils/volume-backups/utils.ts | 40 ++++++++++++--- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index 29f8f6e15..09ba71839 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -55,7 +55,13 @@ const formSchema = z cronExpression: z.string().min(1, "Cron expression is required"), volumeName: z.string().min(1, "Volume name is required"), prefix: z.string(), - // keepLatestCount: z.coerce.number().optional(), + keepLatestCount: z + .coerce + .number() + .int() + .gte(1, "Must be at least 1") + .optional() + .nullable(), turnOff: z.boolean().default(false), enabled: z.boolean().default(true), serviceType: z.enum([ @@ -108,6 +114,7 @@ export const HandleVolumeBackups = ({ }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); + const [keepLatestCountInput, setKeepLatestCountInput] = useState(""); const utils = api.useUtils(); const form = useForm>({ @@ -117,7 +124,7 @@ export const HandleVolumeBackups = ({ cronExpression: "", volumeName: "", prefix: "", - // keepLatestCount: undefined, + keepLatestCount: undefined, turnOff: false, enabled: true, serviceName: "", @@ -173,13 +180,18 @@ export const HandleVolumeBackups = ({ cronExpression: volumeBackup.cronExpression, volumeName: volumeBackup.volumeName || "", prefix: volumeBackup.prefix, - // keepLatestCount: volumeBackup.keepLatestCount || undefined, + keepLatestCount: volumeBackup.keepLatestCount || undefined, turnOff: volumeBackup.turnOff, enabled: volumeBackup.enabled || false, serviceName: volumeBackup.serviceName || "", destinationId: volumeBackup.destinationId, serviceType: volumeBackup.serviceType, }); + setKeepLatestCountInput( + volumeBackup.keepLatestCount !== null && volumeBackup.keepLatestCount !== undefined + ? String(volumeBackup.keepLatestCount) + : "", + ); } }, [form, volumeBackup, volumeBackupId]); @@ -190,8 +202,12 @@ export const HandleVolumeBackups = ({ const onSubmit = async (values: z.infer) => { if (!id && !volumeBackupId) return; + const preparedKeepLatestCount = + keepLatestCountInput === "" ? null : values.keepLatestCount ?? null; + await mutateAsync({ ...values, + keepLatestCount: preparedKeepLatestCount, destinationId: values.destinationId, volumeBackupId: volumeBackupId || "", serviceType: volumeBackupType, @@ -600,29 +616,38 @@ export const HandleVolumeBackups = ({ )} /> - {/* ( - Keep Latest Count + Keep Latest Backups - field.onChange(Number(e.target.value) || undefined) - } + type="number" + min={1} + autoComplete="off" + placeholder="Leave empty to keep all" + value={keepLatestCountInput} + onChange={(e) => { + const raw = e.target.value; + setKeepLatestCountInput(raw); + if (raw === "") { + field.onChange(undefined); + } else if (/^\d+$/.test(raw)) { + field.onChange(Number(raw)); + } + }} /> - Number of backup files to keep (optional) + How many recent backups to keep. Empty means no cleanup. )} - /> */} + /> { const volumeBackup = await findVolumeBackupById(volumeBackupId); @@ -20,6 +17,33 @@ export const removeVolumeBackupJob = async (volumeBackupId: string) => { currentJob?.cancel(); }; +const cleanupOldVolumeBackups = async ( + volumeBackup: Awaited>, + serverId?: string | null, +) => { + const { keepLatestCount, destination, prefix, volumeName } = volumeBackup; + + if (!keepLatestCount) return; + + try { + const rcloneFlags = getS3Credentials(destination); + const normalizedPrefix = normalizeS3Path(prefix); + const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`; + const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`; + const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; + const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; + const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`; + + if (serverId) { + await execAsyncRemote(serverId, fullCommand); + } else { + await execAsync(fullCommand); + } + } catch (error) { + console.error("Volume backup retention error", error); + } +}; + export const runVolumeBackup = async (volumeBackupId: string) => { const volumeBackup = await findVolumeBackupById(volumeBackupId); const serverId = @@ -40,6 +64,10 @@ export const runVolumeBackup = async (volumeBackupId: string) => { await execAsync(commandWithLog); } + if (volumeBackup.keepLatestCount && volumeBackup.keepLatestCount > 0) { + await cleanupOldVolumeBackups(volumeBackup, serverId); + } + await updateDeploymentStatus(deployment.deploymentId, "done"); } catch (error) { await updateDeploymentStatus(deployment.deploymentId, "error"); From 5e4444610cc9512f544d05f425a9e312dddd42dc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 06:33:36 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- .../volume-backups/handle-volume-backups.tsx | 8 ++++---- packages/server/src/utils/volume-backups/utils.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index 09ba71839..feb99175b 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -55,8 +55,7 @@ const formSchema = z cronExpression: z.string().min(1, "Cron expression is required"), volumeName: z.string().min(1, "Volume name is required"), prefix: z.string(), - keepLatestCount: z - .coerce + keepLatestCount: z.coerce .number() .int() .gte(1, "Must be at least 1") @@ -188,7 +187,8 @@ export const HandleVolumeBackups = ({ serviceType: volumeBackup.serviceType, }); setKeepLatestCountInput( - volumeBackup.keepLatestCount !== null && volumeBackup.keepLatestCount !== undefined + volumeBackup.keepLatestCount !== null && + volumeBackup.keepLatestCount !== undefined ? String(volumeBackup.keepLatestCount) : "", ); @@ -203,7 +203,7 @@ export const HandleVolumeBackups = ({ if (!id && !volumeBackupId) return; const preparedKeepLatestCount = - keepLatestCountInput === "" ? null : values.keepLatestCount ?? null; + keepLatestCountInput === "" ? null : (values.keepLatestCount ?? null); await mutateAsync({ ...values, diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index 7ee1ccb4d..5b55c240c 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -1,7 +1,13 @@ import { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import { scheduledJobs, scheduleJob } from "node-schedule"; -import { createDeploymentVolumeBackup, updateDeploymentStatus } from "@dokploy/server/services/deployment"; -import { execAsync, execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { + createDeploymentVolumeBackup, + updateDeploymentStatus, +} from "@dokploy/server/services/deployment"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; import { backupVolume } from "./backup"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; From 59aaa1a47a4dd7505643a8d0270ccc3ac1ae0dfc Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 24 Aug 2025 00:40:17 -0600 Subject: [PATCH 3/3] fix(ui): adjust max width for volume backup dialog based on backup type --- .../application/volume-backups/handle-volume-backups.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index feb99175b..f00b91a9d 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -273,9 +273,8 @@ export const HandleVolumeBackups = ({