diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx index 2bfd6bbc09..9d6093386a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx @@ -27,6 +27,13 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; @@ -41,6 +48,7 @@ interface Props { | "mysql" | "mariadb" | "compose"; + sourceType?: string; refetch: () => void; children?: React.ReactNode; } @@ -82,16 +90,25 @@ type AddMount = z.infer; export const AddVolumes = ({ serviceId, serviceType, + sourceType, refetch, children = , }: Props) => { const [isOpen, setIsOpen] = useState(false); + const [serviceName, setServiceName] = useState(""); const { mutateAsync } = api.mounts.create.useMutation(); + const { mutateAsync: addComposeVolume } = + api.compose.addComposeVolume.useMutation(); + const { data: services } = api.compose.loadServices.useQuery( + { composeId: serviceId, type: "cache" }, + { enabled: serviceType === "compose" && sourceType === "raw" }, + ); + const isRawCompose = serviceType === "compose" && sourceType === "raw"; const form = useForm({ defaultValues: { - type: serviceType === "compose" ? "file" : "bind", + type: serviceType === "compose" && !isRawCompose ? "file" : "bind", hostPath: "", - mountPath: serviceType === "compose" ? "/" : "", + mountPath: serviceType === "compose" && !isRawCompose ? "/" : "", }, resolver: zodResolver(mySchema), }); @@ -102,7 +119,21 @@ export const AddVolumes = ({ }, [form, form.reset, form.formState.isSubmitSuccessful]); const onSubmit = async (data: AddMount) => { - if (data.type === "bind") { + if (isRawCompose && data.type !== "file") { + if (!serviceName) { + toast.error("Please select a service"); + return; + } + const source = data.type === "bind" ? data.hostPath : data.volumeName; + await addComposeVolume({ composeId: serviceId, serviceName, source, target: data.mountPath }) + .then(() => { + toast.success("Volume created successfully"); + setIsOpen(false); + }) + .catch(() => { + toast.error("Error creating volume"); + }); + } else if (data.type === "bind") { await mutateAsync({ serviceId, hostPath: data.hostPath, @@ -111,11 +142,11 @@ export const AddVolumes = ({ serviceType, }) .then(() => { - toast.success("Mount Created"); + toast.success("Mount created successfully"); setIsOpen(false); }) .catch(() => { - toast.error("Error creating the Bind mount"); + toast.error("Error creating mount"); }); } else if (data.type === "volume") { await mutateAsync({ @@ -126,11 +157,11 @@ export const AddVolumes = ({ serviceType, }) .then(() => { - toast.success("Mount Created"); + toast.success("Mount created successfully"); setIsOpen(false); }) .catch(() => { - toast.error("Error creating the Volume mount"); + toast.error("Error creating mount"); }); } else if (data.type === "file") { await mutateAsync({ @@ -142,11 +173,11 @@ export const AddVolumes = ({ serviceType, }) .then(() => { - toast.success("Mount Created"); + toast.success("Mount created successfully"); setIsOpen(false); }) .catch(() => { - toast.error("Error creating the File mount"); + toast.error("Error creating mount"); }); } @@ -209,7 +240,7 @@ export const AddVolumes = ({ defaultValue={field.value} className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" > - {serviceType !== "compose" && ( + {(serviceType !== "compose" || isRawCompose) && (
@@ -229,7 +260,7 @@ export const AddVolumes = ({ )} - {serviceType !== "compose" && ( + {(serviceType !== "compose" || isRawCompose) && (
@@ -251,7 +282,9 @@ export const AddVolumes = ({ @@ -362,7 +395,7 @@ PORT=3000 /> )} - {serviceType !== "compose" && ( + {(serviceType !== "compose" || isRawCompose) && ( )} + {isRawCompose && type !== "file" && ( + + Service + + + )}
diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx index d3803c42ab..2316ab9cbb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -2,6 +2,7 @@ import { Package, Trash2 } from "lucide-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 { Card, @@ -13,6 +14,7 @@ import { import { api } from "@/utils/api"; import type { ServiceType } from "../show-resources"; import { AddVolumes } from "./add-volumes"; +import { UpdateComposeVolume } from "./update-compose-volume"; import { UpdateVolume } from "./update-volume"; interface Props { @@ -39,6 +41,20 @@ export const ShowVolumes = ({ id, type }: Props) => { : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const { mutateAsync: deleteVolume, isLoading: isRemoving } = api.mounts.remove.useMutation(); + const { data: composeVolumes, refetch: refetchComposeVolumes } = + api.compose.getComposeVolumes.useQuery( + { composeId: id }, + { enabled: !!id && type === "compose" }, + ); + const { mutateAsync: removeComposeVolume, isLoading: isRemovingCompose } = + api.compose.removeComposeVolume.useMutation(); + const sourceType = + data && "sourceType" in data ? data.sourceType : undefined; + const isRawCompose = type === "compose" && sourceType === "raw"; + const dbMounts = data?.mounts ?? []; + const yamlVolumes = composeVolumes ?? []; + const hasAny = dbMounts.length > 0 || yamlVolumes.length > 0; + return ( @@ -50,20 +66,30 @@ export const ShowVolumes = ({ id, type }: Props) => { - {data && data?.mounts.length > 0 && ( - + {hasAny && ( + Add Volume )} - {data?.mounts.length === 0 ? ( + {!hasAny ? (
No volumes/mounts configured - + Add Volume
@@ -73,63 +99,46 @@ export const ShowVolumes = ({ id, type }: Props) => { Please remember to click Redeploy after adding, editing, or deleting a mount to apply the changes. -
- {data?.mounts.map((mount) => ( -
+ + {/* DB Mounts (file mounts stored in database) */} + {dbMounts.length > 0 && ( +
+ {dbMounts.map((mount) => (
- {/* */} -
+
- Mount Type + Type {mount.type.toUpperCase()}
- {mount.type === "volume" && ( -
- Volume Name - - {mount.volumeName} - -
- )} - - {mount.type === "file" && ( -
- Content - - {mount.content} - -
- )} - {mount.type === "bind" && ( -
- Host Path - - {mount.hostPath} - -
- )} - {mount.type === "file" && ( -
- File Path - - {mount.filePath} - -
- )} - +
+ + {mount.type === "bind" + ? "Host Path" + : mount.type === "volume" + ? "Volume Name" + : "File Path"} + + + {mount.type === "bind" + ? mount.hostPath + : mount.type === "volume" + ? mount.volumeName + : mount.filePath} + +
Mount Path - - {mount.mountPath} + + {mount.mountPath || "/"}
-
+
{ serviceType={type} /> { - await deleteVolume({ - mountId: mount.mountId, - }) + await deleteVolume({ mountId: mount.mountId }) .then(() => { refetch(); - toast.success("Volume deleted successfully"); + toast.success("Mount deleted successfully"); }) .catch(() => { - toast.error("Error deleting volume"); + toast.error("Error deleting mount"); }); }} > @@ -164,9 +171,79 @@ export const ShowVolumes = ({ id, type }: Props) => {
-
- ))} -
+ ))} +
+ )} + + {/* YAML Volumes (from docker-compose.yml) */} + {yamlVolumes.length > 0 && ( +
+ {yamlVolumes.map((vol, i) => ( +
+
+
+
+ Source + + {vol.source} + +
+
+ Target + + {vol.target} + +
+
+
+ {vol.type} + {vol.serviceName} +
+
+ {isRawCompose && ( +
+ + { + await removeComposeVolume({ + composeId: id, + serviceName: vol.serviceName, + target: vol.target, + }) + .then(() => { + refetchComposeVolumes(); + toast.success("Volume deleted successfully"); + }) + .catch(() => { + toast.error("Error deleting volume"); + }); + }} + > + + +
+ )} +
+ ))} +
+ )}
)} diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-compose-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-compose-volume.tsx new file mode 100644 index 0000000000..65f2b7f229 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-compose-volume.tsx @@ -0,0 +1,97 @@ +import type { ServiceVolume } from "@dokploy/server"; +import { PenBoxIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +interface Props { + composeId: string; + volume: ServiceVolume; + refetch: () => void; +} + +export const UpdateComposeVolume = ({ composeId, volume, refetch }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [source, setSource] = useState(volume.source); + const [target, setTarget] = useState(volume.target); + + const { mutateAsync: removeVolume } = + api.compose.removeComposeVolume.useMutation(); + const { mutateAsync: addVolume, isLoading } = + api.compose.addComposeVolume.useMutation(); + + const onSubmit = async () => { + if (!source.trim() || !target.trim()) { + toast.error("Source and target are required"); + return; + } + const original = { source: volume.source, target: volume.target }; + try { + await removeVolume({ composeId, serviceName: volume.serviceName, target: volume.target }); + await addVolume({ composeId, serviceName: volume.serviceName, source, target }); + toast.success("Volume updated successfully"); + setIsOpen(false); + refetch(); + } catch { + // Attempt rollback if remove succeeded but add failed + try { + await addVolume({ composeId, serviceName: volume.serviceName, source: original.source, target: original.target }); + } catch {} + toast.error("Error updating volume"); + } + }; + + return ( + { + setIsOpen(open); + if (open) { + setSource(volume.source); + setTarget(volume.target); + } + }} + > + + + + + + Edit Volume — {volume.serviceName} + +
+
+ + setSource(e.target.value)} /> +
+
+ + setTarget(e.target.value)} /> +
+
+ + + +
+
+ ); +}; diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 3261f61fa8..92f57eedff 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1,8 +1,10 @@ import { addDomainToCompose, addNewService, + addVolumeToService, checkServiceAccess, cloneCompose, + type ComposeSpecification, createCommand, createCompose, createComposeByTemplate, @@ -11,7 +13,9 @@ import { deleteMount, execAsync, execAsyncRemote, + extractServiceVolumes, findComposeById, + removeVolumeFromService, findDomainsByComposeId, findEnvironmentById, findGitProviderById, @@ -20,6 +24,8 @@ import { findUserById, getComposeContainer, IS_CLOUD, + loadDockerCompose, + loadDockerComposeRemote, loadServices, randomizeComposeFile, randomizeIsolatedDeploymentComposeFile, @@ -43,7 +49,7 @@ import { eq } from "drizzle-orm"; import _ from "lodash"; import { nanoid } from "nanoid"; import { parse } from "toml"; -import { stringify } from "yaml"; +import { parse as parseYaml, stringify } from "yaml"; import { z } from "zod"; import { slugify } from "@/lib/slug"; import { db } from "@/server/db"; @@ -68,6 +74,17 @@ import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +const validateComposeAccess = async (composeId: string, activeOrgId: string) => { + const compose = await findComposeById(composeId); + if (compose.environment.project.organizationId !== activeOrgId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return compose; +}; + export const composeRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateCompose) @@ -182,16 +199,7 @@ export const composeRouter = createTRPCRouter({ update: protectedProcedure .input(apiUpdateCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to update this compose", - }); - } + await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); return updateCompose(input.composeId, input); }), delete: protectedProcedure @@ -239,73 +247,93 @@ export const composeRouter = createTRPCRouter({ cleanQueues: protectedProcedure .input(apiFindCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to clean this compose", - }); - } + await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); await cleanQueuesByCompose(input.composeId); return { success: true, message: "Queues cleaned successfully" }; }), killBuild: protectedProcedure .input(apiFindCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to kill this build", - }); - } + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); await killDockerBuild("compose", compose.serverId); }), loadServices: protectedProcedure .input(apiFetchServices) .query(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to load this compose", - }); + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + if (compose.sourceType === "raw" && compose.composeFile) { + const composeData = parseYaml(compose.composeFile) as ComposeSpecification; + if (!composeData?.services) { + throw new TRPCError({ code: "NOT_FOUND", message: "Services not found" }); + } + return Object.keys(composeData.services); } return await loadServices(input.composeId, input.type); }), loadMountsByService: protectedProcedure + .input(z.object({ composeId: z.string().min(1), serviceName: z.string().min(1) })) + .query(async ({ input, ctx }) => { + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + const container = await getComposeContainer(compose, input.serviceName); + return container?.Mounts.filter((mount) => mount.Type === "volume" && mount.Source !== ""); + }), + getComposeVolumes: protectedProcedure + .input(apiFindCompose) + .query(async ({ input, ctx }) => { + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + let composeData: ComposeSpecification | null; + + if (compose.sourceType === "raw") { + composeData = compose.composeFile + ? (parseYaml(compose.composeFile) as ComposeSpecification) + : null; + } else { + composeData = compose.serverId + ? await loadDockerComposeRemote(compose) + : await loadDockerCompose(compose); + } + + return composeData ? extractServiceVolumes(composeData) : []; + }), + addComposeVolume: protectedProcedure .input( - z.object({ - composeId: z.string().min(1), + apiFindCompose.extend({ serviceName: z.string().min(1), + source: z.string().min(1), + target: z.string().min(1), }), ) - .query(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { + .mutation(async ({ input, ctx }) => { + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + if (compose.sourceType !== "raw" || !compose.composeFile) { throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to load this compose", + code: "BAD_REQUEST", + message: "Cannot edit this compose", }); } - const container = await getComposeContainer(compose, input.serviceName); - const mounts = container?.Mounts.filter( - (mount) => mount.Type === "volume" && mount.Source !== "", - ); - return mounts; + const composeData = parseYaml(compose.composeFile) as ComposeSpecification; + const updated = addVolumeToService(composeData, input.serviceName, `${input.source}:${input.target}`); + await updateCompose(input.composeId, { composeFile: stringify(updated) }); + }), + removeComposeVolume: protectedProcedure + .input( + apiFindCompose.extend({ + serviceName: z.string().min(1), + target: z.string().min(1), + }), + ) + .mutation(async ({ input, ctx }) => { + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + if (compose.sourceType !== "raw" || !compose.composeFile) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot edit this compose", + }); + } + const composeData = parseYaml(compose.composeFile) as ComposeSpecification; + const updated = removeVolumeFromService(composeData, input.serviceName, input.target); + await updateCompose(input.composeId, { composeFile: stringify(updated) }); }), fetchSourceType: protectedProcedure .input(apiFindCompose) @@ -473,70 +501,28 @@ export const composeRouter = createTRPCRouter({ stop: protectedProcedure .input(apiFindCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to stop this compose", - }); - } + await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); await stopCompose(input.composeId); - return true; }), start: protectedProcedure .input(apiFindCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to stop this compose", - }); - } + await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); await startCompose(input.composeId); - return true; }), getDefaultCommand: protectedProcedure .input(apiFindCompose) .query(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to get this compose", - }); - } - const command = createCommand(compose); - return `docker ${command}`; + const compose = await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + return `docker ${createCommand(compose)}`; }), refreshToken: protectedProcedure .input(apiFindCompose) .mutation(async ({ input, ctx }) => { - const compose = await findComposeById(input.composeId); - if ( - compose.environment.project.organizationId !== - ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to refresh this compose", - }); - } - await updateCompose(input.composeId, { - refreshToken: nanoid(), - }); + await validateComposeAccess(input.composeId, ctx.session.activeOrganizationId); + await updateCompose(input.composeId, { refreshToken: nanoid() }); return true; }), deployTemplate: protectedProcedure diff --git a/packages/server/src/utils/docker/compose/volume.ts b/packages/server/src/utils/docker/compose/volume.ts index be4c7b2069..2633562ef8 100644 --- a/packages/server/src/utils/docker/compose/volume.ts +++ b/packages/server/src/utils/docker/compose/volume.ts @@ -5,6 +5,13 @@ import type { DefinitionsVolume, } from "../types"; +export type ServiceVolume = { + serviceName: string; + type: string; + source: string; + target: string; +}; + // Función para agregar prefijo a volúmenes export const addSuffixToVolumesRoot = ( volumes: { [key: string]: DefinitionsVolume }, @@ -64,6 +71,85 @@ export const addSuffixToVolumesInServices = ( return newServices; }; +export const extractServiceVolumes = ( + composeData: ComposeSpecification, +): ServiceVolume[] => { + if (!composeData.services) { + return []; + } + + const result: ServiceVolume[] = []; + + _.forEach(composeData.services, (serviceConfig, serviceName) => { + if (!serviceConfig.volumes) { + return; + } + for (const vol of serviceConfig.volumes) { + if (_.isString(vol)) { + const parts = vol.split(":"); + const source = parts[0] || ""; + const target = parts[1] || ""; + const isBind = + source.startsWith(".") || + source.startsWith("/") || + source.startsWith("$"); + result.push({ + serviceName, + type: isBind ? "bind" : "volume", + source, + target, + }); + } else { + result.push({ + serviceName, + type: vol.type, + source: vol.source || "", + target: vol.target || "", + }); + } + } + }); + + return result; +}; + +export const addVolumeToService = ( + composeData: ComposeSpecification, + serviceName: string, + volume: string, +): ComposeSpecification => { + const updated = _.cloneDeep(composeData); + if (!updated.services?.[serviceName]) { + return updated; + } + if (!updated.services[serviceName].volumes) { + updated.services[serviceName].volumes = []; + } + updated.services[serviceName].volumes.push(volume); + return updated; +}; + +export const removeVolumeFromService = ( + composeData: ComposeSpecification, + serviceName: string, + target: string, +): ComposeSpecification => { + const updated = _.cloneDeep(composeData); + if (!updated.services?.[serviceName]?.volumes) { + return updated; + } + updated.services[serviceName].volumes = updated.services[ + serviceName + ].volumes.filter((vol) => { + if (_.isString(vol)) { + const parts = vol.split(":"); + return parts[1] !== target; + } + return vol.target !== target; + }); + return updated; +}; + export const addSuffixToAllVolumes = ( composeData: ComposeSpecification, suffix: string,