Merge pull request #5251 from Dokploy/feat/delete-server-services
Some checks are pending
Auto PR to main when version changes / create-pr (push) Waiting to run
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Docker Build / sync-version (push) Blocked by required conditions
autofix.ci / format (push) Waiting to run
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Waiting to run

feat: list associated services in delete server modal
This commit is contained in:
Mauricio Siu 2026-09-01 03:12:42 -06:00 committed by GitHub
commit e1a432d5c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 343 additions and 53 deletions

View File

@ -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<string, string> = {
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<string | null>(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<typeof services>[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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
This will permanently delete "{serverName}" and all associated data.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : canDelete ? (
<AlertBlock type="info">
No services are associated with this server. You can delete it
safely.
</AlertBlock>
) : (
<div className="flex flex-col gap-3">
<AlertBlock type="warning">
This server has {services?.length} service
{services?.length === 1 ? "" : "s"} associated. Delete them before
removing the server.
</AlertBlock>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
{services?.map((service) => (
<div
key={`${service.type}-${service.id}`}
className="flex items-center justify-between gap-2 rounded-lg border p-2"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-sm font-medium">
{service.name}
</span>
<Badge variant="outline" className="w-fit text-xs">
{serviceTypeLabel[service.type]}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<Link href={service.url} target="_blank">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<DialogAction
title="Delete Service"
description={`This will permanently delete "${service.name}" and all its associated data.`}
onClick={() => handleDeleteService(service)}
>
<Button
variant="ghost"
size="icon"
disabled={deletingId === service.id}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{deletingId === service.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</DialogAction>
</div>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!canDelete || isDeletingServer}
onClick={handleDeleteServer}
>
Delete Server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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 = () => {
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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 = () => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active
services associated
with this server,
please delete them
first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(
err.message,
);
});
}}
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</DeleteServerModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
<p>Delete Server</p>
</TooltipContent>
</Tooltip>
)}

View File

@ -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);

View File

@ -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<ServerService[]> => {
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<string, any>
>;
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<Server>,