mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
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.
This commit is contained in:
parent
d4925dd2b7
commit
2f16034cb0
214
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
214
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
@ -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<ReturnType<typeof findProjectById>>;
|
||||
|
||||
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<string[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate Project
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
// Reset form when closing
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIncludeServices(true);
|
||||
setSelectedServices([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new project with the same configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Project description (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeServices"
|
||||
checked={includeServices}
|
||||
onCheckedChange={(checked) => {
|
||||
setIncludeServices(checked as boolean);
|
||||
if (!checked) {
|
||||
setSelectedServices([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="includeServices">Include services</Label>
|
||||
</div>
|
||||
|
||||
{includeServices && services.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Select services to duplicate</Label>
|
||||
<div className="space-y-2">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={service.id}
|
||||
checked={selectedServices.includes(service.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedServices((prev) =>
|
||||
checked
|
||||
? [...prev, service.id]
|
||||
: prev.filter((id) => id !== service.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={service.id}>
|
||||
{service.name} ({service.type})
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDuplicate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Duplicating...
|
||||
</>
|
||||
) : (
|
||||
"Duplicate"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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 = (
|
||||
</CardTitle>
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
</CardHeader>
|
||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
||||
<div className="flex flex-row gap-4 flex-wrap justify-between items-center">
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<ProjectEnvironment projectId={projectId}>
|
||||
<Button variant="outline">Project Environment</Button>
|
||||
@ -569,7 +571,7 @@ const Project = (
|
||||
className="w-[200px] space-y-2"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuLabel className="text-sm font-normal ">
|
||||
<DropdownMenuLabel className="text-sm font-normal">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@ -593,7 +595,23 @@ const Project = (
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{auth?.role === "owner" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DuplicateProject
|
||||
project={data}
|
||||
services={applications}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
|
||||
{isLoading ? (
|
||||
|
@ -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[],
|
||||
|
@ -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
|
||||
|
@ -52,6 +52,7 @@ const createSchema = createInsertSchema(projects, {
|
||||
export const apiCreateProject = createSchema.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
env: true,
|
||||
});
|
||||
|
||||
export const apiFindOneProject = createSchema
|
||||
|
@ -69,7 +69,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
|
||||
.insert(compose)
|
||||
.values({
|
||||
...input,
|
||||
composeFile: "",
|
||||
composeFile: input.composeFile || "",
|
||||
appName,
|
||||
})
|
||||
.returning()
|
||||
|
Loading…
Reference in New Issue
Block a user