From 2f16034cb048e6ff30bc476edd81907cc4fd0f55 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 30 Mar 2025 02:38:53 -0600 Subject: [PATCH] Add Duplicate Project functionality - Introduced a new component for duplicating projects, allowing users to create a new project with the same configuration as an existing one. - Implemented a mutation in the project router to handle project duplication, including optional service duplication. - Updated the project detail page to include a dropdown menu for initiating the duplication process. - Enhanced the API to validate and process the duplication request, ensuring proper handling of services associated with the project. --- .../dashboard/project/duplicate-project.tsx | 214 +++++++++++ .../pages/dashboard/project/[projectId].tsx | 24 +- apps/dokploy/server/api/routers/project.ts | 333 ++++++++++++++++++ packages/server/src/db/schema/compose.ts | 3 +- packages/server/src/db/schema/project.ts | 1 + packages/server/src/services/compose.ts | 2 +- 6 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/components/dashboard/project/duplicate-project.tsx 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..3a475232b --- /dev/null +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -0,0 +1,214 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { api } from "@/utils/api"; +import type { findProjectById } from "@dokploy/server"; +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"; +}; + +type Project = Awaited>; + +interface DuplicateProjectProps { + project: Project; + services: Services[]; +} + +export const DuplicateProject = ({ + project, + services, +}: DuplicateProjectProps) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [includeServices, setIncludeServices] = useState(true); + const [selectedServices, setSelectedServices] = useState([]); + const utils = api.useUtils(); + const router = useRouter(); + + 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: project.projectId, + name, + description, + includeServices, + selectedServices: includeServices + ? services + .filter((service) => selectedServices.includes(service.id)) + .map((service) => ({ + id: service.id, + type: service.type, + })) + : [], + }); + }; + + return ( + <> + { + e.preventDefault(); + setOpen(true); + }} + > + + Duplicate Project + + + { + setOpen(isOpen); + if (!isOpen) { + // Reset form when closing + setName(""); + setDescription(""); + setIncludeServices(true); + setSelectedServices([]); + } + }} + > + + + Duplicate Project + + Create a new project with the same configuration + + + +
+
+ + setName(e.target.value)} + placeholder="New project name" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Project description (optional)" + /> +
+ +
+ { + setIncludeServices(checked as boolean); + if (!checked) { + setSelectedServices([]); + } + }} + /> + +
+ + {includeServices && services.length > 0 && ( +
+ +
+ {services.map((service) => ( +
+ { + setSelectedServices((prev) => + checked + ? [...prev, service.id] + : prev.filter((id) => id !== service.id), + ); + }} + /> + +
+ ))} +
+
+ )} +
+ + + + + +
+
+ + ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId].tsx b/apps/dokploy/pages/dashboard/project/[projectId].tsx index d6fc9dcbf..a9ffe9c7a 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId].tsx @@ -78,6 +78,7 @@ import { FolderInput, GlobeIcon, Loader2, + MoreHorizontal, PlusIcon, Search, Trash2, @@ -92,6 +93,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 +555,7 @@ const Project = ( {data?.description} - {(auth?.role === "owner" || auth?.canCreateServices) && ( +
@@ -569,7 +571,7 @@ const Project = ( className="w-[200px] space-y-2" align="end" > - + Actions @@ -593,7 +595,23 @@ const Project = (
- )} + {auth?.role === "owner" && ( + + + + + + + + + )} +
{isLoading ? ( diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 438a3f077..d686479ba 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -14,21 +14,44 @@ import { projects, redis, } from "@/server/db/schema"; +import { z } from "zod"; import { IS_CLOUD, addNewProject, checkProjectAccess, + createApplication, + createCompose, + createMariadb, + createMongo, + createMysql, + createPostgres, createProject, + createRedis, deleteProject, + findApplicationById, + findComposeById, + findMongoById, findMemberById, + findRedisById, findProjectById, findUserById, updateProjectById, + findPostgresById, + findMariadbById, + findMySqlById, + createDomain, + createPort, + createMount, + createRedirect, + createPreviewDeployment, + createBackup, + createSecurity, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; + export const projectRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateProject) @@ -266,7 +289,317 @@ export const projectRouter = createTRPCRouter({ throw error; } }), + duplicate: protectedProcedure + .input( + z.object({ + sourceProjectId: z.string(), + name: z.string(), + description: z.string().optional(), + includeServices: z.boolean().default(true), + selectedServices: z + .array( + z.object({ + id: z.string(), + type: z.enum([ + "application", + "postgres", + "mariadb", + "mongo", + "mysql", + "redis", + "compose", + ]), + }), + ) + .optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + 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()