mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
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
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:
commit
e1a432d5c8
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user