Skip to content

Commit bf0668c

Browse files
authored
Merge pull request #1435 from Dokploy/969-move-services-between-projects
feat(services): add bulk service move functionality across projects
2 parents ff8d922 + fc1dbcf commit bf0668c

File tree

8 files changed

+528
-3
lines changed

8 files changed

+528
-3
lines changed

apps/dokploy/pages/dashboard/project/[projectId].tsx

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ import { useRouter } from "next/router";
7575
import { type ReactElement, useMemo, useState } from "react";
7676
import { toast } from "sonner";
7777
import superjson from "superjson";
78+
import {
79+
Dialog,
80+
DialogContent,
81+
DialogDescription,
82+
DialogFooter,
83+
DialogHeader,
84+
DialogTitle,
85+
DialogTrigger,
86+
} from "@/components/ui/dialog";
87+
import {
88+
Select,
89+
SelectContent,
90+
SelectItem,
91+
SelectTrigger,
92+
SelectValue,
93+
} from "@/components/ui/select";
7894

7995
export type Services = {
8096
appName: string;
@@ -205,8 +221,13 @@ const Project = (
205221
const { data: auth } = api.user.get.useQuery();
206222

207223
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
224+
const { data: allProjects } = api.project.all.useQuery();
208225
const router = useRouter();
209226

227+
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
228+
const [selectedTargetProject, setSelectedTargetProject] =
229+
useState<string>("");
230+
210231
const emptyServices =
211232
data?.mariadb?.length === 0 &&
212233
data?.mongo?.length === 0 &&
@@ -254,6 +275,31 @@ const Project = (
254275
const composeActions = {
255276
start: api.compose.start.useMutation(),
256277
stop: api.compose.stop.useMutation(),
278+
move: api.compose.move.useMutation(),
279+
};
280+
281+
const applicationActions = {
282+
move: api.application.move.useMutation(),
283+
};
284+
285+
const postgresActions = {
286+
move: api.postgres.move.useMutation(),
287+
};
288+
289+
const mysqlActions = {
290+
move: api.mysql.move.useMutation(),
291+
};
292+
293+
const mariadbActions = {
294+
move: api.mariadb.move.useMutation(),
295+
};
296+
297+
const redisActions = {
298+
move: api.redis.move.useMutation(),
299+
};
300+
301+
const mongoActions = {
302+
move: api.mongo.move.useMutation(),
257303
};
258304

259305
const handleBulkStart = async () => {
@@ -296,6 +342,80 @@ const Project = (
296342
setIsBulkActionLoading(false);
297343
};
298344

345+
const handleBulkMove = async () => {
346+
if (!selectedTargetProject) {
347+
toast.error("Please select a target project");
348+
return;
349+
}
350+
351+
let success = 0;
352+
setIsBulkActionLoading(true);
353+
for (const serviceId of selectedServices) {
354+
try {
355+
const service = filteredServices.find((s) => s.id === serviceId);
356+
if (!service) continue;
357+
358+
switch (service.type) {
359+
case "application":
360+
await applicationActions.move.mutateAsync({
361+
applicationId: serviceId,
362+
targetProjectId: selectedTargetProject,
363+
});
364+
break;
365+
case "compose":
366+
await composeActions.move.mutateAsync({
367+
composeId: serviceId,
368+
targetProjectId: selectedTargetProject,
369+
});
370+
break;
371+
case "postgres":
372+
await postgresActions.move.mutateAsync({
373+
postgresId: serviceId,
374+
targetProjectId: selectedTargetProject,
375+
});
376+
break;
377+
case "mysql":
378+
await mysqlActions.move.mutateAsync({
379+
mysqlId: serviceId,
380+
targetProjectId: selectedTargetProject,
381+
});
382+
break;
383+
case "mariadb":
384+
await mariadbActions.move.mutateAsync({
385+
mariadbId: serviceId,
386+
targetProjectId: selectedTargetProject,
387+
});
388+
break;
389+
case "redis":
390+
await redisActions.move.mutateAsync({
391+
redisId: serviceId,
392+
targetProjectId: selectedTargetProject,
393+
});
394+
break;
395+
case "mongo":
396+
await mongoActions.move.mutateAsync({
397+
mongoId: serviceId,
398+
targetProjectId: selectedTargetProject,
399+
});
400+
break;
401+
}
402+
success++;
403+
} catch (error) {
404+
toast.error(
405+
`Error moving service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
406+
);
407+
}
408+
}
409+
if (success > 0) {
410+
toast.success(`${success} services moved successfully`);
411+
refetch();
412+
}
413+
setSelectedServices([]);
414+
setIsDropdownOpen(false);
415+
setIsMoveDialogOpen(false);
416+
setIsBulkActionLoading(false);
417+
};
418+
299419
const filteredServices = useMemo(() => {
300420
if (!applications) return [];
301421
return applications.filter(
@@ -445,6 +565,84 @@ const Project = (
445565
Stop
446566
</Button>
447567
</DialogAction>
568+
<Dialog
569+
open={isMoveDialogOpen}
570+
onOpenChange={setIsMoveDialogOpen}
571+
>
572+
<DialogTrigger asChild>
573+
<Button
574+
variant="ghost"
575+
className="w-full justify-start"
576+
>
577+
<FolderInput className="mr-2 h-4 w-4" />
578+
Move
579+
</Button>
580+
</DialogTrigger>
581+
<DialogContent>
582+
<DialogHeader>
583+
<DialogTitle>Move Services</DialogTitle>
584+
<DialogDescription>
585+
Select the target project to move{" "}
586+
{selectedServices.length} services
587+
</DialogDescription>
588+
</DialogHeader>
589+
<div className="flex flex-col gap-4">
590+
{allProjects?.filter(
591+
(p) => p.projectId !== projectId,
592+
).length === 0 ? (
593+
<div className="flex flex-col items-center justify-center gap-2 py-4">
594+
<FolderInput className="h-8 w-8 text-muted-foreground" />
595+
<p className="text-sm text-muted-foreground text-center">
596+
No other projects available. Create a new
597+
project first to move services.
598+
</p>
599+
</div>
600+
) : (
601+
<Select
602+
value={selectedTargetProject}
603+
onValueChange={setSelectedTargetProject}
604+
>
605+
<SelectTrigger>
606+
<SelectValue placeholder="Select target project" />
607+
</SelectTrigger>
608+
<SelectContent>
609+
{allProjects
610+
?.filter(
611+
(p) => p.projectId !== projectId,
612+
)
613+
.map((project) => (
614+
<SelectItem
615+
key={project.projectId}
616+
value={project.projectId}
617+
>
618+
{project.name}
619+
</SelectItem>
620+
))}
621+
</SelectContent>
622+
</Select>
623+
)}
624+
</div>
625+
<DialogFooter>
626+
<Button
627+
variant="outline"
628+
onClick={() => setIsMoveDialogOpen(false)}
629+
>
630+
Cancel
631+
</Button>
632+
<Button
633+
onClick={handleBulkMove}
634+
isLoading={isBulkActionLoading}
635+
disabled={
636+
allProjects?.filter(
637+
(p) => p.projectId !== projectId,
638+
).length === 0
639+
}
640+
>
641+
Move Services
642+
</Button>
643+
</DialogFooter>
644+
</DialogContent>
645+
</Dialog>
448646
</DropdownMenuContent>
449647
</DropdownMenu>
450648
</div>

apps/dokploy/server/api/routers/application.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,4 +668,49 @@ export const applicationRouter = createTRPCRouter({
668668

669669
return stats;
670670
}),
671+
move: protectedProcedure
672+
.input(
673+
z.object({
674+
applicationId: z.string(),
675+
targetProjectId: z.string(),
676+
}),
677+
)
678+
.mutation(async ({ input, ctx }) => {
679+
const application = await findApplicationById(input.applicationId);
680+
if (
681+
application.project.organizationId !== ctx.session.activeOrganizationId
682+
) {
683+
throw new TRPCError({
684+
code: "UNAUTHORIZED",
685+
message: "You are not authorized to move this application",
686+
});
687+
}
688+
689+
const targetProject = await findProjectById(input.targetProjectId);
690+
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
691+
throw new TRPCError({
692+
code: "UNAUTHORIZED",
693+
message: "You are not authorized to move to this project",
694+
});
695+
}
696+
697+
// Update the application's projectId
698+
const updatedApplication = await db
699+
.update(applications)
700+
.set({
701+
projectId: input.targetProjectId,
702+
})
703+
.where(eq(applications.applicationId, input.applicationId))
704+
.returning()
705+
.then((res) => res[0]);
706+
707+
if (!updatedApplication) {
708+
throw new TRPCError({
709+
code: "INTERNAL_SERVER_ERROR",
710+
message: "Failed to move application",
711+
});
712+
}
713+
714+
return updatedApplication;
715+
}),
671716
});

apps/dokploy/server/api/routers/compose.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
apiFindCompose,
99
apiRandomizeCompose,
1010
apiUpdateCompose,
11-
compose,
11+
compose as composeTable,
1212
} from "@/server/db/schema";
1313
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
1414
import { templates } from "@/templates/templates";
@@ -24,6 +24,7 @@ import { dump } from "js-yaml";
2424
import _ from "lodash";
2525
import { nanoid } from "nanoid";
2626
import { createTRPCRouter, protectedProcedure } from "../trpc";
27+
import { z } from "zod";
2728

2829
import type { DeploymentJob } from "@/server/queues/queue-types";
2930
import { deploy } from "@/server/utils/deploy";
@@ -157,8 +158,8 @@ export const composeRouter = createTRPCRouter({
157158
4;
158159

159160
const result = await db
160-
.delete(compose)
161-
.where(eq(compose.composeId, input.composeId))
161+
.delete(composeTable)
162+
.where(eq(composeTable.composeId, input.composeId))
162163
.returning();
163164

164165
const cleanupOperations = [
@@ -501,4 +502,48 @@ export const composeRouter = createTRPCRouter({
501502
const uniqueTags = _.uniq(allTags);
502503
return uniqueTags;
503504
}),
505+
506+
move: protectedProcedure
507+
.input(
508+
z.object({
509+
composeId: z.string(),
510+
targetProjectId: z.string(),
511+
}),
512+
)
513+
.mutation(async ({ input, ctx }) => {
514+
const compose = await findComposeById(input.composeId);
515+
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
516+
throw new TRPCError({
517+
code: "UNAUTHORIZED",
518+
message: "You are not authorized to move this compose",
519+
});
520+
}
521+
522+
const targetProject = await findProjectById(input.targetProjectId);
523+
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
524+
throw new TRPCError({
525+
code: "UNAUTHORIZED",
526+
message: "You are not authorized to move to this project",
527+
});
528+
}
529+
530+
// Update the compose's projectId
531+
const updatedCompose = await db
532+
.update(composeTable)
533+
.set({
534+
projectId: input.targetProjectId,
535+
})
536+
.where(eq(composeTable.composeId, input.composeId))
537+
.returning()
538+
.then((res) => res[0]);
539+
540+
if (!updatedCompose) {
541+
throw new TRPCError({
542+
code: "INTERNAL_SERVER_ERROR",
543+
message: "Failed to move compose",
544+
});
545+
}
546+
547+
return updatedCompose;
548+
}),
504549
});

0 commit comments

Comments
 (0)