From b34987530e02e52c80ea6020dfa149e76fb34edf Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:39:02 -0600 Subject: [PATCH] feat(services): add bulk service move functionality across projects - Implement service move feature for applications, compose, databases, and other services - Add move dialog with project selection for bulk service transfer - Create move mutation endpoints for each service type - Enhance project management with cross-project service relocation - Improve user experience with error handling and success notifications --- .../pages/dashboard/project/[projectId].tsx | 179 ++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 45 +++++ apps/dokploy/server/api/routers/compose.ts | 51 ++++- apps/dokploy/server/api/routers/mariadb.ts | 47 +++++ apps/dokploy/server/api/routers/mongo.ts | 47 +++++ apps/dokploy/server/api/routers/mysql.ts | 47 +++++ apps/dokploy/server/api/routers/postgres.ts | 49 +++++ apps/dokploy/server/api/routers/redis.ts | 47 +++++ 8 files changed, 509 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId].tsx b/apps/dokploy/pages/dashboard/project/[projectId].tsx index 605fe6e5..37a6b344 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId].tsx @@ -75,6 +75,22 @@ import { useRouter } from "next/router"; import { type ReactElement, useMemo, useState } from "react"; import { toast } from "sonner"; import superjson from "superjson"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; export type Services = { appName: string; @@ -205,8 +221,13 @@ const Project = ( const { data: auth } = api.user.get.useQuery(); const { data, isLoading, refetch } = api.project.one.useQuery({ projectId }); + const { data: allProjects } = api.project.all.useQuery(); const router = useRouter(); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); + const [selectedTargetProject, setSelectedTargetProject] = + useState(""); + const emptyServices = data?.mariadb?.length === 0 && data?.mongo?.length === 0 && @@ -254,6 +275,31 @@ const Project = ( const composeActions = { start: api.compose.start.useMutation(), stop: api.compose.stop.useMutation(), + move: api.compose.move.useMutation(), + }; + + const applicationActions = { + move: api.application.move.useMutation(), + }; + + const postgresActions = { + move: api.postgres.move.useMutation(), + }; + + const mysqlActions = { + move: api.mysql.move.useMutation(), + }; + + const mariadbActions = { + move: api.mariadb.move.useMutation(), + }; + + const redisActions = { + move: api.redis.move.useMutation(), + }; + + const mongoActions = { + move: api.mongo.move.useMutation(), }; const handleBulkStart = async () => { @@ -296,6 +342,80 @@ const Project = ( setIsBulkActionLoading(false); }; + const handleBulkMove = async () => { + if (!selectedTargetProject) { + toast.error("Please select a target project"); + return; + } + + let success = 0; + setIsBulkActionLoading(true); + for (const serviceId of selectedServices) { + try { + const service = filteredServices.find((s) => s.id === serviceId); + if (!service) continue; + + switch (service.type) { + case "application": + await applicationActions.move.mutateAsync({ + applicationId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "compose": + await composeActions.move.mutateAsync({ + composeId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "postgres": + await postgresActions.move.mutateAsync({ + postgresId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "mysql": + await mysqlActions.move.mutateAsync({ + mysqlId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "mariadb": + await mariadbActions.move.mutateAsync({ + mariadbId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "redis": + await redisActions.move.mutateAsync({ + redisId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + case "mongo": + await mongoActions.move.mutateAsync({ + mongoId: serviceId, + targetProjectId: selectedTargetProject, + }); + break; + } + success++; + } catch (error) { + toast.error( + `Error moving service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + if (success > 0) { + toast.success(`${success} services moved successfully`); + refetch(); + } + setSelectedServices([]); + setIsDropdownOpen(false); + setIsMoveDialogOpen(false); + setIsBulkActionLoading(false); + }; + const filteredServices = useMemo(() => { if (!applications) return []; return applications.filter( @@ -445,6 +565,65 @@ const Project = ( Stop + + + + + + + Move Services + + Select the target project to move{" "} + {selectedServices.length} services + + +
+ +
+ + + + +
+
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index e1629b4c..1909a02f 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -668,4 +668,49 @@ export const applicationRouter = createTRPCRouter({ return stats; }), + move: protectedProcedure + .input( + z.object({ + applicationId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if ( + application.project.organizationId !== ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this application", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the application's projectId + const updatedApplication = await db + .update(applications) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(applications.applicationId, input.applicationId)) + .returning() + .then((res) => res[0]); + + if (!updatedApplication) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move application", + }); + } + + return updatedApplication; + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index bae926d0..22692d50 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -8,7 +8,7 @@ import { apiFindCompose, apiRandomizeCompose, apiUpdateCompose, - compose, + compose as composeTable, } from "@/server/db/schema"; import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; import { templates } from "@/templates/templates"; @@ -24,6 +24,7 @@ import { dump } from "js-yaml"; import _ from "lodash"; import { nanoid } from "nanoid"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { z } from "zod"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { deploy } from "@/server/utils/deploy"; @@ -157,8 +158,8 @@ export const composeRouter = createTRPCRouter({ 4; const result = await db - .delete(compose) - .where(eq(compose.composeId, input.composeId)) + .delete(composeTable) + .where(eq(composeTable.composeId, input.composeId)) .returning(); const cleanupOperations = [ @@ -501,4 +502,48 @@ export const composeRouter = createTRPCRouter({ const uniqueTags = _.uniq(allTags); return uniqueTags; }), + + move: protectedProcedure + .input( + z.object({ + composeId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this compose", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the compose's projectId + const updatedCompose = await db + .update(composeTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(composeTable.composeId, input.composeId)) + .returning() + .then((res) => res[0]); + + if (!updatedCompose) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move compose", + }); + } + + return updatedCompose; + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index be0ffd39..21794bc7 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -8,6 +8,7 @@ import { apiSaveEnvironmentVariablesMariaDB, apiSaveExternalPortMariaDB, apiUpdateMariaDB, + mariadb as mariadbTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; import { @@ -30,6 +31,9 @@ import { } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/server/db"; export const mariadbRouter = createTRPCRouter({ create: protectedProcedure @@ -322,4 +326,47 @@ export const mariadbRouter = createTRPCRouter({ return true; }), + move: protectedProcedure + .input( + z.object({ + mariadbId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + if (mariadb.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mariadb", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the mariadb's projectId + const updatedMariadb = await db + .update(mariadbTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(mariadbTable.mariadbId, input.mariadbId)) + .returning() + .then((res) => res[0]); + + if (!updatedMariadb) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move mariadb", + }); + } + + return updatedMariadb; + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 1c3ba6bb..4301e407 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -8,6 +8,7 @@ import { apiSaveEnvironmentVariablesMongo, apiSaveExternalPortMongo, apiUpdateMongo, + mongo as mongoTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; import { @@ -30,6 +31,9 @@ import { } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/server/db"; export const mongoRouter = createTRPCRouter({ create: protectedProcedure @@ -336,4 +340,47 @@ export const mongoRouter = createTRPCRouter({ return true; }), + move: protectedProcedure + .input( + z.object({ + mongoId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mongo", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the mongo's projectId + const updatedMongo = await db + .update(mongoTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(mongoTable.mongoId, input.mongoId)) + .returning() + .then((res) => res[0]); + + if (!updatedMongo) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move mongo", + }); + } + + return updatedMongo; + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 594403f2..3fa1eb92 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -8,6 +8,7 @@ import { apiSaveEnvironmentVariablesMySql, apiSaveExternalPortMySql, apiUpdateMySql, + mysql as mysqlTable, } from "@/server/db/schema"; import { TRPCError } from "@trpc/server"; @@ -32,6 +33,9 @@ import { updateMySqlById, } from "@dokploy/server"; import { observable } from "@trpc/server/observable"; +import { eq } from "drizzle-orm"; +import { db } from "@/server/db"; +import { z } from "zod"; export const mysqlRouter = createTRPCRouter({ create: protectedProcedure @@ -332,4 +336,47 @@ export const mysqlRouter = createTRPCRouter({ return true; }), + move: protectedProcedure + .input( + z.object({ + mysqlId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + if (mysql.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mysql", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the mysql's projectId + const updatedMysql = await db + .update(mysqlTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(mysqlTable.mysqlId, input.mysqlId)) + .returning() + .then((res) => res[0]); + + if (!updatedMysql) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move mysql", + }); + } + + return updatedMysql; + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index cf3221b4..b3cd5b82 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -8,6 +8,7 @@ import { apiSaveEnvironmentVariablesPostgres, apiSaveExternalPortPostgres, apiUpdatePostgres, + postgres as postgresTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; import { @@ -30,6 +31,9 @@ import { } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/server/db"; export const postgresRouter = createTRPCRouter({ create: protectedProcedure @@ -352,4 +356,49 @@ export const postgresRouter = createTRPCRouter({ return true; }), + move: protectedProcedure + .input( + z.object({ + postgresId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if ( + postgres.project.organizationId !== ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this postgres", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the postgres's projectId + const updatedPostgres = await db + .update(postgresTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(postgresTable.postgresId, input.postgresId)) + .returning() + .then((res) => res[0]); + + if (!updatedPostgres) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move postgres", + }); + } + + return updatedPostgres; + }), }); diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index a80660bf..d7d9d78d 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -8,6 +8,7 @@ import { apiSaveEnvironmentVariablesRedis, apiSaveExternalPortRedis, apiUpdateRedis, + redis as redisTable, } from "@/server/db/schema"; import { TRPCError } from "@trpc/server"; @@ -30,6 +31,9 @@ import { updateRedisById, } from "@dokploy/server"; import { observable } from "@trpc/server/observable"; +import { eq } from "drizzle-orm"; +import { db } from "@/server/db"; +import { z } from "zod"; export const redisRouter = createTRPCRouter({ create: protectedProcedure @@ -316,4 +320,47 @@ export const redisRouter = createTRPCRouter({ return true; }), + move: protectedProcedure + .input( + z.object({ + redisId: z.string(), + targetProjectId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this redis", + }); + } + + const targetProject = await findProjectById(input.targetProjectId); + if (targetProject.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this project", + }); + } + + // Update the redis's projectId + const updatedRedis = await db + .update(redisTable) + .set({ + projectId: input.targetProjectId, + }) + .where(eq(redisTable.redisId, input.redisId)) + .returning() + .then((res) => res[0]); + + if (!updatedRedis) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to move redis", + }); + } + + return updatedRedis; + }), });