diff --git a/apps/dokploy/components/dashboard/settings/destination/constants.ts b/apps/dokploy/components/dashboard/settings/destination/constants.ts index f43e47d1a1..096c76d8f4 100644 --- a/apps/dokploy/components/dashboard/settings/destination/constants.ts +++ b/apps/dokploy/components/dashboard/settings/destination/constants.ts @@ -131,3 +131,27 @@ export const S3_PROVIDERS: Array<{ name: "Any other S3 compatible provider", }, ]; + +export const DESTINATION_TYPES: Array<{ + key: "s3" | "sftp" | "rclone"; + name: string; + description: string; +}> = [ + { + key: "s3", + name: "S3 Compatible", + description: + "Amazon S3, Cloudflare R2, DigitalOcean Spaces, MinIO, and other S3-compatible storage", + }, + { + key: "sftp", + name: "SFTP / SSH", + description: "Upload backups via SFTP to any remote server", + }, + { + key: "rclone", + name: "Rclone (Custom)", + description: + "Use a custom rclone configuration to support Google Drive, OneDrive, Azure Blob, FTP, and many more providers", + }, +]; diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index dae069e919..e716a2cd0e 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -33,18 +33,32 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { S3_PROVIDERS } from "./constants"; +import { DESTINATION_TYPES, S3_PROVIDERS } from "./constants"; const addDestination = z.object({ name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), + destinationType: z.enum(["s3", "sftp", "rclone"]).default("s3"), + // S3 fields + provider: z.string().optional(), + accessKeyId: z.string().optional(), + secretAccessKey: z.string().optional(), + bucket: z.string().optional(), + region: z.string().optional(), + endpoint: z.string().optional(), + // SFTP fields + sftpHost: z.string().optional(), + sftpPort: z.number().optional(), + sftpUsername: z.string().optional(), + sftpPassword: z.string().optional(), + sftpKeyPath: z.string().optional(), + sftpRemotePath: z.string().optional(), + // Rclone fields + rcloneConfig: z.string().optional(), + rcloneRemoteName: z.string().optional(), + rcloneRemotePath: z.string().optional(), serverId: z.string().optional(), }); @@ -82,6 +96,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { const form = useForm({ defaultValues: { + destinationType: "s3", provider: "", accessKeyId: "", bucket: "", @@ -89,19 +104,41 @@ export const HandleDestinations = ({ destinationId }: Props) => { region: "", secretAccessKey: "", endpoint: "", + sftpHost: "", + sftpPort: 22, + sftpUsername: "", + sftpPassword: "", + sftpKeyPath: "", + sftpRemotePath: "/backups", + rcloneConfig: "", + rcloneRemoteName: "", + rcloneRemotePath: "", }, resolver: zodResolver(addDestination), }); + + const destinationType = form.watch("destinationType"); + useEffect(() => { if (destination) { form.reset({ name: destination.name, + destinationType: destination.destinationType || "s3", provider: destination.provider || "", accessKeyId: destination.accessKey, secretAccessKey: destination.secretAccessKey, bucket: destination.bucket, region: destination.region, endpoint: destination.endpoint, + sftpHost: destination.sftpHost || "", + sftpPort: destination.sftpPort || 22, + sftpUsername: destination.sftpUsername || "", + sftpPassword: destination.sftpPassword || "", + sftpKeyPath: destination.sftpKeyPath || "", + sftpRemotePath: destination.sftpRemotePath || "/backups", + rcloneConfig: destination.rcloneConfig || "", + rcloneRemoteName: destination.rcloneRemoteName || "", + rcloneRemotePath: destination.rcloneRemotePath || "", }); } else { form.reset(); @@ -110,14 +147,24 @@ export const HandleDestinations = ({ destinationId }: Props) => { const onSubmit = async (data: AddDestination) => { await mutateAsync({ + destinationType: data.destinationType || "s3", provider: data.provider || "", - accessKey: data.accessKeyId, - bucket: data.bucket, - endpoint: data.endpoint, + accessKey: data.accessKeyId || "", + bucket: data.bucket || "", + endpoint: data.endpoint || "", name: data.name, - region: data.region, - secretAccessKey: data.secretAccessKey, + region: data.region || "", + secretAccessKey: data.secretAccessKey || "", destinationId: destinationId || "", + sftpHost: data.sftpHost || undefined, + sftpPort: data.sftpPort || undefined, + sftpUsername: data.sftpUsername || undefined, + sftpPassword: data.sftpPassword || undefined, + sftpKeyPath: data.sftpKeyPath || undefined, + sftpRemotePath: data.sftpRemotePath || undefined, + rcloneConfig: data.rcloneConfig || undefined, + rcloneRemoteName: data.rcloneRemoteName || undefined, + rcloneRemotePath: data.rcloneRemotePath || undefined, }) .then(async () => { toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); @@ -135,25 +182,39 @@ export const HandleDestinations = ({ destinationId }: Props) => { }; const handleTestConnection = async (serverId?: string) => { - const result = await form.trigger([ - "provider", - "accessKeyId", - "secretAccessKey", - "bucket", - "endpoint", - ]); - - if (!result) { - const errors = form.formState.errors; - const errorFields = Object.entries(errors) - .map(([field, error]) => `${field}: ${error?.message}`) - .filter(Boolean) - .join("\n"); + const destType = form.getValues("destinationType") || "s3"; - toast.error("Please fill all required fields", { - description: errorFields, - }); - return; + if (destType === "s3") { + const result = await form.trigger([ + "provider", + "accessKeyId", + "secretAccessKey", + "bucket", + "endpoint", + ]); + if (!result) { + const errors = form.formState.errors; + const errorFields = Object.entries(errors) + .map(([field, error]) => `${field}: ${error?.message}`) + .filter(Boolean) + .join("\n"); + toast.error("Please fill all required fields", { + description: errorFields, + }); + return; + } + } else if (destType === "sftp") { + const result = await form.trigger(["sftpHost", "sftpUsername"]); + if (!result) { + toast.error("Please fill host and username"); + return; + } + } else if (destType === "rclone") { + const result = await form.trigger(["rcloneConfig", "rcloneRemoteName"]); + if (!result) { + toast.error("Please fill rclone config and remote name"); + return; + } } if (isCloud && !serverId) { @@ -161,31 +222,32 @@ export const HandleDestinations = ({ destinationId }: Props) => { return; } - const provider = form.getValues("provider"); - const accessKey = form.getValues("accessKeyId"); - const secretKey = form.getValues("secretAccessKey"); - const bucket = form.getValues("bucket"); - const endpoint = form.getValues("endpoint"); - const region = form.getValues("region"); - - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; - await testConnection({ - provider, - accessKey, - bucket, - endpoint, + destinationType: destType, + provider: form.getValues("provider") || "", + accessKey: form.getValues("accessKeyId") || "", + bucket: form.getValues("bucket") || "", + endpoint: form.getValues("endpoint") || "", name: "Test", - region, - secretAccessKey: secretKey, + region: form.getValues("region") || "", + secretAccessKey: form.getValues("secretAccessKey") || "", + sftpHost: form.getValues("sftpHost") || undefined, + sftpPort: form.getValues("sftpPort") || undefined, + sftpUsername: form.getValues("sftpUsername") || undefined, + sftpPassword: form.getValues("sftpPassword") || undefined, + sftpKeyPath: form.getValues("sftpKeyPath") || undefined, + sftpRemotePath: form.getValues("sftpRemotePath") || undefined, + rcloneConfig: form.getValues("rcloneConfig") || undefined, + rcloneRemoteName: form.getValues("rcloneRemoteName") || undefined, + rcloneRemotePath: form.getValues("rcloneRemotePath") || undefined, serverId, }) .then(() => { toast.success("Connection Success"); }) .catch((e) => { - toast.error("Error connecting to provider", { - description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`, + toast.error("Error connecting to destination", { + description: e.message, }); }); }; @@ -208,15 +270,15 @@ export const HandleDestinations = ({ destinationId }: Props) => { )} - + {destinationId ? "Update" : "Add"} Destination - In this section, you can configure and add new destinations for your - backups. Please ensure that you provide the correct information to - guarantee secure and efficient storage. + Configure a backup destination. Choose from S3-compatible storage, + SFTP servers, or use a custom rclone configuration for providers + like Google Drive, OneDrive, Azure Blob, FTP, and more. {(isError || isErrorConnection) && ( @@ -239,20 +301,21 @@ export const HandleDestinations = ({ destinationId }: Props) => { Name - + ); }} /> + { return ( - Provider + Destination Type - - - - ); - }} - /> - ( - -
- Secret Access Key -
- - - - -
- )} - /> - ( - -
- Bucket -
- - - - -
- )} - /> - ( - -
- Region -
- - - - -
- )} - /> - ( - - Endpoint - - - - - - )} - /> + {/* S3 Fields */} + {destinationType === "s3" && ( + <> + { + return ( + + Provider + + + + + + ); + }} + /> + + { + return ( + + Access Key Id + + + + + + ); + }} + /> + ( + +
+ Secret Access Key +
+ + + + +
+ )} + /> + ( + +
+ Bucket +
+ + + + +
+ )} + /> + ( + +
+ Region +
+ + + + +
+ )} + /> + ( + + Endpoint + + + + + + )} + /> + + )} + + {/* SFTP Fields */} + {destinationType === "sftp" && ( + <> + ( + + Host + + + + + + )} + /> + ( + + Port + + + field.onChange( + e.target.value + ? Number.parseInt(e.target.value) + : undefined, + ) + } + /> + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Password (optional if using key) + + + + + + )} + /> + ( + + + SSH Key Path (optional if using password) + + + + + + + )} + /> + ( + + Remote Path + + + + + + )} + /> + + )} + + {/* Rclone Custom Fields */} + {destinationType === "rclone" && ( + <> + ( + + Rclone Configuration + +