diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/compose-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/compose-volumes.tsx new file mode 100644 index 000000000..9f9a9df0f --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/compose-volumes.tsx @@ -0,0 +1,129 @@ +interface ComposeVolumesProps { + composeVolumes: Record< + string, + { + config: any; + usage: Array<{ service: string; mountPath: string }>; + hostPath?: string; + isBindMount?: boolean; + } + >; +} + +/** + * Generates a display string for the mount path of a volume. + */ +const getMountPathDisplay = (volumeName: string, volumeData: any): string => { + const hasUsage = volumeData?.usage && volumeData.usage.length > 0; + + if (!hasUsage) { + return volumeData?.isBindMount ? volumeData.hostPath : volumeName; + } + + return volumeData.usage + .map((usage: { service: string; mountPath: string }) => { + const source = volumeData?.isBindMount ? volumeData.hostPath : volumeName; + return `${source}:${usage.mountPath}`; + }) + .join(", "); +}; + +/** + * Retrieves the driver value from the volume configuration. + */ +const getDriverValue = (volumeData: any): string => { + const hasValidConfig = + typeof volumeData?.config === "object" && volumeData?.config !== null; + return hasValidConfig ? volumeData.config.driver || "default" : "default"; +}; + +/** + * Retrieves the external value from the volume configuration. + */ +const getExternalValue = (volumeData: any): string => { + const hasValidConfig = + typeof volumeData?.config === "object" && volumeData?.config !== null; + return hasValidConfig && volumeData.config.external ? "Yes" : "No"; +}; + +/** + * Component to display individual volume fields. + */ +const VolumeField = ({ + label, + value, + breakText = false, +}: { + label: string; + value: string; + breakText?: boolean; +}) => ( +
+ {label} + + {value} + +
+); + +/** + * Component to display compose volumes information. + */ +export const ComposeVolumes = ({ composeVolumes }: ComposeVolumesProps) => { + if (!composeVolumes || Object.keys(composeVolumes).length === 0) { + return null; + } + + return ( +
+
+

Compose Volumes

+

+ Volumes defined in the docker-compose.yml file of the service +

+
+
+ {Object.entries(composeVolumes).map( + ([volumeName, volumeData]: [string, any]) => { + const isBindMount = volumeData?.isBindMount; + const mountPath = getMountPathDisplay(volumeName, volumeData); + const type = isBindMount ? "Bind Mount" : "Volume"; + + return ( +
+
+ + + + {isBindMount ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); + }, + )} +
+
+ ); +}; 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 d3803c42a..c9dcd56ee 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -13,6 +13,7 @@ import { import { api } from "@/utils/api"; import type { ServiceType } from "../show-resources"; import { AddVolumes } from "./add-volumes"; +import { ComposeVolumes } from "./compose-volumes"; import { UpdateVolume } from "./update-volume"; interface Props { @@ -20,7 +21,43 @@ interface Props { type: ServiceType | "compose"; } +/** + * Check if the service is a compose service with defined volumes in docker-compose.yml + */ +const isComposeWithVolumes = (data: any, type: string) => { + return type === "compose" && data && "definedVolumesInComposeFile" in data; +}; + +/** + * Get the count of defined volumes in docker-compose.yml + */ +const getComposeVolumesCount = (data: any, type: string) => { + if (!isComposeWithVolumes(data, type)) return 0; + return Object.keys(data.definedVolumesInComposeFile || {}).length; +}; + +/** + * Check if the service has any volumes/mounts configured + */ +const hasAnyVolumes = (data: any, type: string) => { + const mountsCount = data?.mounts?.length ?? 0; + const composeVolumesCount = getComposeVolumesCount(data, type); + return mountsCount > 0 || composeVolumesCount > 0; +}; + +/** + * Get the defined volumes in docker-compose.yml + */ +const getComposeVolumes = (data: any, type: string) => { + if (!isComposeWithVolumes(data, type)) return null; + return data.definedVolumesInComposeFile; +}; + +/** + * Show Volumes component + */ export const ShowVolumes = ({ id, type }: Props) => { + console.log("Rendering ShowVolumes with id:", id, "and type:", type); const queryMap = { postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), @@ -37,6 +74,7 @@ export const ShowVolumes = ({ id, type }: Props) => { const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + const { mutateAsync: deleteVolume, isLoading: isRemoving } = api.mounts.remove.useMutation(); return ( @@ -50,37 +88,46 @@ export const ShowVolumes = ({ id, type }: Props) => { - {data && data?.mounts.length > 0 && ( - - Add Volume - - )} + + Add Volume + - {data?.mounts.length === 0 ? ( + {!hasAnyVolumes(data, type) && (
No volumes/mounts configured - - Add Volume -
- ) : ( + )} + {hasAnyVolumes(data, type) && (
- - Please remember to click Redeploy after adding, editing, or - deleting a mount to apply the changes. - + {(data?.mounts?.length ?? 0) > 0 && ( + + Please remember to click Redeploy after adding, editing, or + deleting a mount to apply the changes. + + )} + {(data?.mounts?.length ?? 0) > 0 && + isComposeWithVolumes(data, type) && + getComposeVolumesCount(data, type) > 0 && ( +
+
+

File Mounts

+

+ File mounts configured through Dokploy interface +

+
+
+ )}
- {data?.mounts.map((mount) => ( + {data?.mounts?.map((mount) => (
- {/* */}
Mount Type @@ -169,6 +216,13 @@ export const ShowVolumes = ({ id, type }: Props) => {
)} + {/* Show defined volumes from docker-compose.yml for compose services */} + {(() => { + const composeVolumes = getComposeVolumes(data, type); + return ( + composeVolumes && + ); + })()} ); diff --git a/apps/dokploy/server/api/models/compose.models.ts b/apps/dokploy/server/api/models/compose.models.ts new file mode 100644 index 000000000..3f5ef1017 --- /dev/null +++ b/apps/dokploy/server/api/models/compose.models.ts @@ -0,0 +1,123 @@ +/** + * Compose model + */ +export interface Compose { + composeId: string; + name: string; + appName: string; + description: string | null; + env: string | null; + composeFile: string; + refreshToken: string | null; + sourceType: "git" | "github" | "gitlab" | "bitbucket" | "gitea" | "raw"; + composeType: "docker-compose" | "stack"; + repository: string | null; + owner: string | null; + branch: string | null; + autoDeploy: boolean | null; + gitlabProjectId: number | null; + gitlabRepository: string | null; + gitlabOwner: string | null; + gitlabBranch: string | null; + gitlabPathNamespace: string | null; + bitbucketRepository: string | null; + bitbucketOwner: string | null; + bitbucketBranch: string | null; + giteaRepository: string | null; + giteaOwner: string | null; + giteaBranch: string | null; + customGitUrl: string | null; + customGitBranch: string | null; + customGitSSHKeyId: string | null; + command: string; + enableSubmodules: boolean; + composePath: string; + suffix: string; + randomize: boolean; + isolatedDeployment: boolean; + isolatedDeploymentsVolume: boolean; + triggerType: string | null; + composeStatus: string; + environmentId: string; + createdAt: string; + watchPaths: string[] | null; + githubId: string | null; + gitlabId: string | null; + bitbucketId: string | null; + giteaId: string | null; + serverId: string | null; + environment: { + environmentId: string; + name: string; + projectId: string; + project: { + projectId: string; + name: string; + description: string | null; + organizationId: string; + createdAt: string; + }; + }; + deployments: Array<{ + deploymentId: string; + status: string | null; + composeId: string | null; + createdAt: string; + }>; + mounts: Array<{ + mountId: string; + type: "bind" | "volume" | "file"; + hostPath: string | null; + volumeName: string | null; + filePath: string | null; + content: string | null; + serviceType: + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "compose"; + mountPath: string; + applicationId: string | null; + postgresId: string | null; + mariadbId: string | null; + mongoId: string | null; + mysqlId: string | null; + redisId: string | null; + composeId: string | null; + }>; + domains: Array<{ + domainId: string; + host: string; + path: string | null; + port: number | null; + https: boolean; + certificateType: string; + composeId: string | null; + createdAt: string; + }>; + github: any; + gitlab: any; + bitbucket: any; + gitea: any; + server: any; + backups: Array<{ + backupId: string; + composeId: string | null; + destination: any; + deployments: any[]; + }>; + hasGitProviderAccess: boolean; + unauthorizedProvider: string | null; + definedVolumesInComposeFile?: Record< + string, + { + config: any; + usage: Array<{ service: string; mountPath: string }>; + hostPath?: string; + isBindMount?: boolean; + } + >; +} diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 9354988a8..515502dc0 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -20,6 +20,9 @@ import { getComposeContainer, getWebServerSettings, IS_CLOUD, + loadDefinedVolumesInComposeFile, + loadDockerCompose, + loadDockerComposeRemote, loadServices, randomizeComposeFile, randomizeIsolatedDeploymentComposeFile, @@ -58,6 +61,7 @@ import { apiUpdateCompose, compose as composeTable, } from "@/server/db/schema"; +import type { Compose } from "@/server/api/models/compose.models"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { cleanQueuesByCompose, @@ -116,9 +120,12 @@ export const composeRouter = createTRPCRouter({ } }), + /** + * Get a compose by ID + */ one: protectedProcedure .input(apiFindCompose) - .query(async ({ input, ctx }) => { + .query(async ({ input, ctx }): Promise => { if (ctx.user.role === "member") { await checkServiceAccess( ctx.user.id, @@ -172,10 +179,16 @@ export const composeRouter = createTRPCRouter({ } } + // Load volumes defined in docker-compose.yml if exists + const definedVolumesInComposeFile = await loadDefinedVolumesInComposeFile( + input.composeId, + ); + return { ...compose, hasGitProviderAccess, unauthorizedProvider, + definedVolumesInComposeFile, }; }), diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 89a12a156..33c4ac0b8 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -108,6 +108,11 @@ export const createComposeByTemplate = async ( return newDestination; }; +/** + * Find compose by ID + * + * @param composeId ID of the compose service + */ export const findComposeById = async (composeId: string) => { const result = await db.query.compose.findFirst({ where: eq(compose.composeId, composeId), @@ -185,6 +190,214 @@ export const loadServices = async ( return [...services]; }; +/** + * Load defined volumes from a docker-compose.yml file + * + * @param composeId ID of the compose service + */ +export const loadDefinedVolumesInComposeFile = async (composeId: string) => { + const compose = await findComposeById(composeId); + + try { + // For raw compose type, we don't need to clone anything + if (compose.sourceType === "raw") { + let composeData: ComposeSpecification | null; + if (compose.serverId) { + composeData = await loadDockerComposeRemote(compose); + } else { + composeData = await loadDockerCompose(compose); + } + + if (!composeData) { + return {}; + } + + return extractVolumesFromComposeData(composeData); + } + + // Validate that we have the necessary provider configuration + const hasValidProvider = validateComposeProvider(compose); + if (!hasValidProvider) { + console.warn( + `No valid provider configuration for compose ${composeId}, returning empty volumes`, + ); + return {}; + } + + // Clone and load the docker-compose file + const command = await cloneCompose(compose); + if (compose.serverId) { + await execAsyncRemote(compose.serverId, command); + } else { + await execAsync(command); + } + + // Load and parse the docker-compose.yml file + let composeData: ComposeSpecification | null; + if (compose.serverId) { + composeData = await loadDockerComposeRemote(compose); + } else { + composeData = await loadDockerCompose(compose); + } + + if (!composeData) { + return {}; + } + + return extractVolumesFromComposeData(composeData); + } catch (err) { + console.error("Error loading defined volumes:", err); + return {}; + } +}; + +/** + * Validate that the compose has a valid provider configuration + */ +const validateComposeProvider = (compose: any): boolean => { + switch (compose.sourceType) { + case "github": + return !!compose.repository && !!compose.owner && !!compose.githubId; + case "gitlab": + return ( + !!compose.gitlabRepository && + !!compose.gitlabOwner && + !!compose.gitlabId + ); + case "bitbucket": + return ( + !!compose.bitbucketRepository && + !!compose.bitbucketOwner && + !!compose.bitbucketId + ); + case "gitea": + return ( + !!compose.giteaRepository && !!compose.giteaOwner && !!compose.giteaId + ); + case "git": + return !!compose.customGitUrl; + case "raw": + return true; // Raw always valid + default: + return false; + } +}; + +/** + * Extract volumes information from compose data + */ +const extractVolumesFromComposeData = (composeData: ComposeSpecification) => { + const volumesDefinition = composeData?.volumes || {}; + const services = composeData?.services || {}; + + // Build a map of volume usage across services + const volumeUsage: Record< + string, + Array<{ service: string; mountPath: string }> + > = {}; + + // Track bind mounts (paths starting with / or .) that are not in volumes section + const bindMounts: Record< + string, + { + hostPath: string; + usage: Array<{ service: string; mountPath: string }>; + } + > = {}; + + // Iterate through all services to find volume usage + for (const [serviceName, serviceConfig] of Object.entries(services)) { + const serviceVolumes = (serviceConfig as any)?.volumes || []; + + for (const volumeEntry of serviceVolumes) { + let volumeName: string | undefined; + let mountPath: string | undefined; + let hostPath: string | undefined; + + if (typeof volumeEntry === "string") { + // Format: "volume_name:/path" or "/host/path:/container/path" + const parts = volumeEntry.split(":"); + if (parts.length >= 2 && parts[0]) { + // Check if it's a named volume (not a path starting with / or .) + if (!parts[0].startsWith("/") && !parts[0].startsWith(".")) { + volumeName = parts[0]; + mountPath = parts[1]; + } else { + // It's a bind mount (path starting with / or .) + hostPath = parts[0]; + mountPath = parts[1]; + } + } + } else if (typeof volumeEntry === "object" && volumeEntry !== null) { + // Long syntax: { type: 'volume', source: 'volume_name', target: '/path' } + if ( + (volumeEntry as any).type === "volume" || + !(volumeEntry as any).type + ) { + volumeName = (volumeEntry as any).source; + mountPath = (volumeEntry as any).target; + } else if ((volumeEntry as any).type === "bind") { + hostPath = (volumeEntry as any).source; + mountPath = (volumeEntry as any).target; + } + } + + if (volumeName && mountPath) { + if (!volumeUsage[volumeName]) { + volumeUsage[volumeName] = []; + } + volumeUsage[volumeName]!.push({ + service: serviceName, + mountPath: mountPath, + }); + } else if (hostPath && mountPath) { + // Track bind mount + const bindKey = `${hostPath}:${mountPath}`; + if (!bindMounts[bindKey]) { + bindMounts[bindKey] = { + hostPath: hostPath, + usage: [], + }; + } + bindMounts[bindKey]!.usage.push({ + service: serviceName, + mountPath: mountPath, + }); + } + } + } + + // Combine volume definitions with usage information + const result: Record< + string, + { + config: any; + usage: Array<{ service: string; mountPath: string }>; + hostPath?: string; + isBindMount?: boolean; + } + > = {}; + + for (const [volumeName, volumeConfig] of Object.entries(volumesDefinition)) { + result[volumeName] = { + config: volumeConfig, + usage: volumeUsage[volumeName] || [], + }; + } + + // Add bind mounts to the result + for (const [bindKey, bindData] of Object.entries(bindMounts)) { + result[bindKey] = { + config: null, + usage: bindData.usage, + hostPath: bindData.hostPath, + isBindMount: true, + }; + } + + return result; +}; + export const updateCompose = async ( composeId: string, composeData: Partial,