From 38d157adc0fd1102d54b75df7cd8b5b08f3c7b4e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 9 Sep 2026 17:47:16 -0600 Subject: [PATCH] fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation --- .../compose/advanced/fresh-volumes.tsx | 216 ++++++++++++++++++ .../dashboard/compose/general/actions.tsx | 54 +---- .../services/compose/[composeId].tsx | 2 + 3 files changed, 219 insertions(+), 53 deletions(-) create mode 100644 apps/dokploy/components/dashboard/compose/advanced/fresh-volumes.tsx diff --git a/apps/dokploy/components/dashboard/compose/advanced/fresh-volumes.tsx b/apps/dokploy/components/dashboard/compose/advanced/fresh-volumes.tsx new file mode 100644 index 000000000..360c7497e --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/advanced/fresh-volumes.tsx @@ -0,0 +1,216 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import copy from "copy-to-clipboard"; +import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const freshVolumesSchema = z.object({ + confirmName: z.string().min(1, { + message: "Compose name is required", + }), +}); + +type FreshVolumesForm = z.infer; + +interface Props { + composeId: string; +} + +export const FreshVolumes = ({ composeId }: Props) => { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const { data, refetch } = api.compose.one.useQuery( + { composeId }, + { enabled: !!composeId }, + ); + const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation(); + + const form = useForm({ + defaultValues: { confirmName: "" }, + resolver: zodResolver(freshVolumesSchema), + }); + + const expectedName = `${data?.name}/${data?.appName}`; + const isRunning = data?.composeStatus === "running"; + + const onSubmit = async (formData: FreshVolumesForm) => { + if (formData.confirmName !== expectedName) { + form.setError("confirmName", { + message: `Compose name must match "${expectedName}"`, + }); + return; + } + await deploy({ composeId, freshVolumes: true }) + .then(() => { + toast.success("Compose deployed with fresh volumes"); + setIsOpen(false); + form.reset(); + refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); + }) + .catch(() => { + toast.error("Error deploying compose"); + }); + }; + + if (!canDeploy || data?.composeType !== "docker-compose") return null; + + return ( + + + + + Danger Zone + + + +
+
+

+ Deploy with Fresh Volumes +

+

+ Permanently wipes every volume of this compose and redeploys it + from a clean state. All persistent data (databases, uploads, + caches) will be lost. +

+
+ { + setIsOpen(open); + if (!open) form.reset(); + }} + > + + + + + + + + Are you absolutely sure? + + +
+

This action will:

+
    +
  • Stop the current compose
  • +
  • Delete all volumes and their data
  • +
  • Redeploy the compose with empty volumes
  • +
+

+ This action cannot be undone. +

+
+
+
+
+ + ( + + + + To confirm, type{" "} + { + if (data?.name && data?.appName) { + copy(expectedName); + toast.success( + "Copied to clipboard. Be careful!", + ); + } + }} + > + {expectedName}  + + {" "} + in the box below: + + + + + + + + )} + /> + + + {isRunning && ( + + Cannot deploy with fresh volumes while a deployment is + running. Please wait for it to finish and then try again. + + )} + + + + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index af3e8aced..b64d1fed9 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -1,11 +1,4 @@ -import { - Ban, - CheckCircle2, - HardDriveDownload, - RefreshCcw, - Rocket, - Terminal, -} from "lucide-react"; +import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; import { useRouter } from "next/router"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { toast } from "sonner"; @@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => { )} - {canDeploy && data?.composeType === "docker-compose" && ( - { - await deploy({ - composeId: composeId, - freshVolumes: true, - }) - .then(() => { - toast.success("Compose deployed with fresh volumes"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, - ); - }) - .catch(() => { - toast.error("Error deploying compose"); - }); - }} - > - - - )} {canDeploy && ( + )}