diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx new file mode 100644 index 000000000..038ddcb6a --- /dev/null +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -0,0 +1,172 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; +import { Copy, Loader2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; + +export type Services = { + appName: string; + serverId?: string | null; + name: string; + type: + | "mariadb" + | "application" + | "postgres" + | "mysql" + | "mongo" + | "redis" + | "compose"; + description?: string | null; + id: string; + createdAt: string; + status?: "idle" | "running" | "done" | "error"; +}; + +interface DuplicateProjectProps { + projectId: string; + services: Services[]; + selectedServiceIds: string[]; +} + +export const DuplicateProject = ({ + projectId, + services, + selectedServiceIds, +}: DuplicateProjectProps) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const utils = api.useUtils(); + const router = useRouter(); + + const selectedServices = services.filter((service) => + selectedServiceIds.includes(service.id), + ); + + const { mutateAsync: duplicateProject, isLoading } = + api.project.duplicate.useMutation({ + onSuccess: async (newProject) => { + await utils.project.all.invalidate(); + toast.success("Project duplicated successfully"); + setOpen(false); + router.push(`/dashboard/project/${newProject.projectId}`); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleDuplicate = async () => { + if (!name) { + toast.error("Project name is required"); + return; + } + + await duplicateProject({ + sourceProjectId: projectId, + name, + description, + includeServices: true, + selectedServices: selectedServices.map((service) => ({ + id: service.id, + type: service.type, + })), + }); + }; + + return ( + { + setOpen(isOpen); + if (!isOpen) { + // Reset form when closing + setName(""); + setDescription(""); + } + }} + > + + + + + + Duplicate Project + + Create a new project with the selected services + + + +
+
+ + setName(e.target.value)} + placeholder="New project name" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Project description (optional)" + /> +
+ +
+ +
+ {selectedServices.map((service) => ( +
+ + {service.name} ({service.type}) + +
+ ))} +
+
+
+ + + + + +
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId].tsx b/apps/dokploy/pages/dashboard/project/[projectId].tsx index d6fc9dcbf..e3cfce16d 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId].tsx @@ -92,6 +92,7 @@ import { useRouter } from "next/router"; import { type ReactElement, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import superjson from "superjson"; +import { DuplicateProject } from "@/components/dashboard/project/duplicate-project"; export type Services = { appName: string; @@ -553,7 +554,7 @@ const Project = ( {data?.description} - {(auth?.role === "owner" || auth?.canCreateServices) && ( +
@@ -569,7 +570,7 @@ const Project = ( className="w-[200px] space-y-2" align="end" > - + Actions @@ -593,7 +594,7 @@ const Project = (
- )} +
{isLoading ? ( @@ -670,20 +671,27 @@ const Project = ( {(auth?.role === "owner" || auth?.canDeleteServices) && ( - - - + + + + )} { + try { + if (ctx.user.rol === "member") { + await checkProjectAccess( + ctx.user.id, + "create", + ctx.session.activeOrganizationId, + ); + } + + // Get source project + const sourceProject = await findProjectById(input.sourceProjectId); + + if (sourceProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + + // Create new project + const newProject = await createProject( + { + name: input.name, + description: input.description, + env: sourceProject.env, + }, + ctx.session.activeOrganizationId, + ); + + if (input.includeServices) { + const servicesToDuplicate = input.selectedServices || []; + + // Helper function to duplicate a service + const duplicateService = async (id: string, type: string) => { + switch (type) { + case "application": { + const { + applicationId, + domains, + security, + ports, + registry, + redirects, + previewDeployments, + mounts, + ...application + } = await findApplicationById(id); + + const newApplication = await createApplication({ + ...application, + projectId: newProject.projectId, + }); + + for (const domain of domains) { + const { domainId, ...rest } = domain; + await createDomain({ + ...rest, + applicationId: newApplication.applicationId, + domainType: "application", + }); + } + + for (const port of ports) { + const { portId, ...rest } = port; + await createPort({ + ...rest, + applicationId: newApplication.applicationId, + }); + } + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newApplication.applicationId, + serviceType: "application", + }); + } + + for (const redirect of redirects) { + const { redirectId, ...rest } = redirect; + await createRedirect({ + ...rest, + applicationId: newApplication.applicationId, + }); + } + + for (const secure of security) { + const { securityId, ...rest } = secure; + await createSecurity({ + ...rest, + applicationId: newApplication.applicationId, + }); + } + + for (const previewDeployment of previewDeployments) { + const { previewDeploymentId, ...rest } = previewDeployment; + await createPreviewDeployment({ + ...rest, + applicationId: newApplication.applicationId, + }); + } + + break; + } + case "postgres": { + const { postgresId, mounts, backups, ...postgres } = + await findPostgresById(id); + + const newPostgres = await createPostgres({ + ...postgres, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newPostgres.postgresId, + serviceType: "postgres", + }); + } + + for (const backup of backups) { + const { backupId, ...rest } = backup; + await createBackup({ + ...rest, + postgresId: newPostgres.postgresId, + }); + } + break; + } + case "mariadb": { + const { mariadbId, mounts, backups, ...mariadb } = + await findMariadbById(id); + const newMariadb = await createMariadb({ + ...mariadb, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newMariadb.mariadbId, + serviceType: "mariadb", + }); + } + + for (const backup of backups) { + const { backupId, ...rest } = backup; + await createBackup({ + ...rest, + mariadbId: newMariadb.mariadbId, + }); + } + break; + } + case "mongo": { + const { mongoId, mounts, backups, ...mongo } = + await findMongoById(id); + const newMongo = await createMongo({ + ...mongo, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newMongo.mongoId, + serviceType: "mongo", + }); + } + + for (const backup of backups) { + const { backupId, ...rest } = backup; + await createBackup({ + ...rest, + mongoId: newMongo.mongoId, + }); + } + break; + } + case "mysql": { + const { mysqlId, mounts, backups, ...mysql } = + await findMySqlById(id); + const newMysql = await createMysql({ + ...mysql, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newMysql.mysqlId, + serviceType: "mysql", + }); + } + + for (const backup of backups) { + const { backupId, ...rest } = backup; + await createBackup({ + ...rest, + mysqlId: newMysql.mysqlId, + }); + } + break; + } + case "redis": { + const { redisId, mounts, ...redis } = await findRedisById(id); + const newRedis = await createRedis({ + ...redis, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newRedis.redisId, + serviceType: "redis", + }); + } + + break; + } + case "compose": { + const { composeId, mounts, domains, ...compose } = + await findComposeById(id); + const newCompose = await createCompose({ + ...compose, + projectId: newProject.projectId, + }); + + for (const mount of mounts) { + const { mountId, ...rest } = mount; + await createMount({ + ...rest, + serviceId: newCompose.composeId, + serviceType: "compose", + }); + } + + for (const domain of domains) { + const { domainId, ...rest } = domain; + await createDomain({ + ...rest, + composeId: newCompose.composeId, + domainType: "compose", + }); + } + + break; + } + } + }; + + // Duplicate selected services + + for (const service of servicesToDuplicate) { + await duplicateService(service.id, service.type); + } + } + + if (ctx.user.rol === "member") { + await addNewProject( + ctx.user.id, + newProject.projectId, + ctx.session.activeOrganizationId, + ); + } + + return newProject; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Error duplicating the project: ${error instanceof Error ? error.message : error}`, + cause: error, + }); + } + }), }); + function buildServiceFilter( fieldName: AnyPgColumn, accessedServices: string[], diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index e8d0e0329..86f1fcaf4 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -139,7 +139,7 @@ const createSchema = createInsertSchema(compose, { name: z.string().min(1), description: z.string(), env: z.string().optional(), - composeFile: z.string().min(1), + composeFile: z.string().optional(), projectId: z.string(), customGitSSHKeyId: z.string().optional(), command: z.string().optional(), @@ -155,6 +155,7 @@ export const apiCreateCompose = createSchema.pick({ composeType: true, appName: true, serverId: true, + composeFile: true, }); export const apiCreateComposeByTemplate = createSchema diff --git a/packages/server/src/db/schema/project.ts b/packages/server/src/db/schema/project.ts index deeba4aca..e40b362f9 100644 --- a/packages/server/src/db/schema/project.ts +++ b/packages/server/src/db/schema/project.ts @@ -52,6 +52,7 @@ const createSchema = createInsertSchema(projects, { export const apiCreateProject = createSchema.pick({ name: true, description: true, + env: true, }); export const apiFindOneProject = createSchema diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 3f014089e..5a318c0d5 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -69,7 +69,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => { .insert(compose) .values({ ...input, - composeFile: "", + composeFile: input.composeFile || "", appName, }) .returning()