From 02760212f7d26be27ed08ecc7a9ba48bae1576f1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 1 Sep 2026 03:11:12 -0600 Subject: [PATCH] feat: list associated services in delete server modal Show the services attached to a remote server directly in its delete confirmation modal, with a link to each service and a per-service delete action, instead of only showing a generic 'has active services' blocker. --- .../settings/servers/delete-server-modal.tsx | 208 ++++++++++++++++++ .../settings/servers/show-servers.tsx | 60 +---- apps/dokploy/server/api/routers/server.ts | 37 ++++ packages/server/src/services/server.ts | 91 ++++++++ 4 files changed, 343 insertions(+), 53 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx diff --git a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx new file mode 100644 index 000000000..6f6e72b11 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx @@ -0,0 +1,208 @@ +import { ExternalLink, Loader2, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + serverId: string; + serverName: string; + children: React.ReactNode; +} + +const serviceTypeLabel: Record = { + application: "Application", + compose: "Compose", + postgres: "Postgres", + mysql: "MySQL", + mariadb: "MariaDB", + mongo: "MongoDB", + redis: "Redis", + libsql: "LibSQL", +}; + +export const DeleteServerModal = ({ + serverId, + serverName, + children, +}: Props) => { + const [open, setOpen] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const utils = api.useUtils(); + + const { + data: services, + isLoading, + refetch, + } = api.server.getServices.useQuery({ serverId }, { enabled: open }); + + const { mutateAsync: deleteApplication } = + api.application.delete.useMutation(); + const { mutateAsync: deleteCompose } = api.compose.delete.useMutation(); + const { mutateAsync: deletePostgres } = api.postgres.remove.useMutation(); + const { mutateAsync: deleteMysql } = api.mysql.remove.useMutation(); + const { mutateAsync: deleteMariadb } = api.mariadb.remove.useMutation(); + const { mutateAsync: deleteMongo } = api.mongo.remove.useMutation(); + const { mutateAsync: deleteRedis } = api.redis.remove.useMutation(); + const { mutateAsync: deleteLibsql } = api.libsql.remove.useMutation(); + const { mutateAsync: deleteServer, isPending: isDeletingServer } = + api.server.remove.useMutation(); + + const canDelete = (services?.length ?? 0) === 0; + + const handleDeleteService = async ( + service: NonNullable[number], + ) => { + setDeletingId(service.id); + try { + switch (service.type) { + case "application": + await deleteApplication({ applicationId: service.id }); + break; + case "compose": + await deleteCompose({ + composeId: service.id, + deleteVolumes: false, + }); + break; + case "postgres": + await deletePostgres({ postgresId: service.id }); + break; + case "mysql": + await deleteMysql({ mysqlId: service.id }); + break; + case "mariadb": + await deleteMariadb({ mariadbId: service.id }); + break; + case "mongo": + await deleteMongo({ mongoId: service.id }); + break; + case "redis": + await deleteRedis({ redisId: service.id }); + break; + case "libsql": + await deleteLibsql({ libsqlId: service.id }); + break; + } + toast.success(`${service.name} deleted successfully`); + await refetch(); + utils.server.all.invalidate(); + } catch (error: any) { + toast.error(error.message); + } finally { + setDeletingId(null); + } + }; + + const handleDeleteServer = async () => { + try { + await deleteServer({ serverId }); + toast.success(`Server ${serverName} deleted successfully`); + setOpen(false); + utils.server.all.invalidate(); + } catch (error: any) { + toast.error(error.message); + } + }; + + return ( + + {children} + + + Delete Server + + This will permanently delete "{serverName}" and all associated data. + + + + {isLoading ? ( +
+ +
+ ) : canDelete ? ( + + No services are associated with this server. You can delete it + safely. + + ) : ( +
+ + This server has {services?.length} service + {services?.length === 1 ? "" : "s"} associated. Delete them before + removing the server. + +
+ {services?.map((service) => ( +
+
+ + {service.name} + + + {serviceTypeLabel[service.type]} + +
+
+ + + + handleDeleteService(service)} + > + + +
+
+ ))} +
+
+ )} + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 112f4a18e..5febd865e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -12,9 +12,6 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { toast } from "sonner"; -import { AlertBlock } from "@/components/shared/alert-block"; -import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -33,6 +30,7 @@ import { import { api } from "@/utils/api"; import { TerminalModal } from "../web-server/terminal-modal"; import { ShowServerActions } from "./actions/show-server-actions"; +import { DeleteServerModal } from "./delete-server-modal"; import { HandleServers } from "./handle-servers"; import { SetupServer } from "./setup-server"; import { ShowHealthModal } from "./show-health-modal"; @@ -43,7 +41,6 @@ export const ShowServers = () => { const router = useRouter(); const query = router.query; const { data, refetch, isPending } = api.server.all.useQuery(); - const { mutateAsync } = api.server.remove.useMutation(); const { data: sshKeys } = api.sshKey.all.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: canCreateMoreServers } = @@ -111,7 +108,6 @@ export const ShowServers = () => {
{data?.map((server) => { - const canDelete = server.totalSum === 0; const isActive = server.serverStatus === "active"; const isBuildServer = server.serverType === "build"; return ( @@ -350,64 +346,22 @@ export const ShowServers = () => {
- - You can not delete this - server because it has - active services. - - You have active - services associated - with this server, - please delete them - first. - -
- ) - } - onClick={async () => { - await mutateAsync({ - serverId: server.serverId, - }) - .then(() => { - refetch(); - toast.success( - `Server ${server.name} deleted successfully`, - ); - }) - .catch((err) => { - toast.error( - err.message, - ); - }); - }} + - +
-

- {canDelete - ? "Delete Server" - : "Cannot delete - has active services"} -

+

Delete Server

)} diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index 1f3ed2c3b..427f4e195 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -7,6 +7,7 @@ import { findUserById, getAccessibleServerIds, getPublicIpWithFallback, + getServicesByServerId, haveActiveServices, IS_CLOUD, redactServerSshKey, @@ -18,6 +19,7 @@ import { updateServerById, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { findMemberByUserId } from "@dokploy/server/services/permission"; import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; @@ -115,6 +117,41 @@ export const serverRouter = createTRPCRouter({ const isBuildServer = server.serverType === "build"; return defaultCommand(isBuildServer); }), + getServices: withPermission("server", "read") + .input(apiFindOneServer) + .query(async ({ input, ctx }) => { + const currentServer = await findServerById(input.serverId); + if (currentServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } + + const accessibleIds = await getAccessibleServerIds(ctx.session); + if (!accessibleIds.has(input.serverId)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } + + const services = await getServicesByServerId(input.serverId); + + const isPrivileged = + ctx.user.role === "owner" || ctx.user.role === "admin"; + if (isPrivileged) { + return services; + } + + const { accessedServices } = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + return services.filter((service) => + accessedServices.includes(service.id), + ); + }), all: withPermission("server", "read").query(async ({ ctx }) => { const accessibleIds = await getAccessibleServerIds(ctx.session); diff --git a/packages/server/src/services/server.ts b/packages/server/src/services/server.ts index 1912a71b5..aee623483 100644 --- a/packages/server/src/services/server.ts +++ b/packages/server/src/services/server.ts @@ -134,6 +134,97 @@ export const haveActiveServices = async (serverId: string) => { return true; }; +export const SERVICE_TYPES_BY_SERVER = [ + { type: "application", relation: "applications", idColumn: "applicationId" }, + { type: "compose", relation: "compose", idColumn: "composeId" }, + { type: "postgres", relation: "postgres", idColumn: "postgresId" }, + { type: "mysql", relation: "mysql", idColumn: "mysqlId" }, + { type: "mariadb", relation: "mariadb", idColumn: "mariadbId" }, + { type: "mongo", relation: "mongo", idColumn: "mongoId" }, + { type: "redis", relation: "redis", idColumn: "redisId" }, + { type: "libsql", relation: "libsql", idColumn: "libsqlId" }, +] as const; + +export interface ServerService { + id: string; + type: (typeof SERVICE_TYPES_BY_SERVER)[number]["type"]; + name: string; + projectId: string; + environmentId: string; + url: string; +} + +export const getServicesByServerId = async ( + serverId: string, +): Promise => { + const currentServer = await db.query.server.findFirst({ + where: eq(server.serverId, serverId), + columns: { serverId: true }, + with: { + applications: { + columns: { applicationId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + compose: { + columns: { composeId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + postgres: { + columns: { postgresId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + mysql: { + columns: { mysqlId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + mariadb: { + columns: { mariadbId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + mongo: { + columns: { mongoId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + redis: { + columns: { redisId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + libsql: { + columns: { libsqlId: true, name: true }, + with: { environment: { with: { project: true } } }, + }, + }, + }); + + if (!currentServer) { + return []; + } + + const services: ServerService[] = []; + + for (const { type, relation, idColumn } of SERVICE_TYPES_BY_SERVER) { + const rows = currentServer[relation as keyof typeof currentServer] as Array< + Record + >; + + for (const row of rows) { + const projectId = row.environment.project.projectId as string; + const environmentId = row.environment.environmentId as string; + + services.push({ + id: row[idColumn], + type, + name: row.name, + projectId, + environmentId, + url: `/dashboard/project/${projectId}/environment/${environmentId}/services/${type}/${row[idColumn]}`, + }); + } + } + + return services; +}; + export const updateServerById = async ( serverId: string, serverData: Partial,