diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index a9bc178a2..b597b3aa4 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -56,13 +56,21 @@ const baseApp: ApplicationNested = { previewPort: 3000, previewLimit: 0, previewWildcard: "", - project: { + environment: { env: "", - organizationId: "", + environmentId: "", name: "", - description: "", createdAt: "", + description: "", projectId: "", + project: { + env: "", + organizationId: "", + name: "", + description: "", + createdAt: "", + projectId: "", + }, }, buildArgs: null, buildPath: "/", @@ -92,6 +100,7 @@ const baseApp: ApplicationNested = { dockerfile: null, dockerImage: null, dropBuildPath: null, + environmentId: "", enabled: null, env: null, healthCheckSwarm: null, @@ -106,7 +115,6 @@ const baseApp: ApplicationNested = { password: null, placementSwarm: null, ports: [], - projectId: "", publishDirectory: null, isStaticSpa: null, redirects: [], diff --git a/apps/dokploy/__test__/env/environment.test.ts b/apps/dokploy/__test__/env/environment.test.ts new file mode 100644 index 000000000..95d46dcc0 --- /dev/null +++ b/apps/dokploy/__test__/env/environment.test.ts @@ -0,0 +1,335 @@ +import { prepareEnvironmentVariables } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +const projectEnv = ` +ENVIRONMENT=staging +DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db +PORT=3000 +`; + +const environmentEnv = ` +NODE_ENV=development +API_URL=https://api.dev.example.com +REDIS_URL=redis://localhost:6379 +DATABASE_NAME=dev_database +SECRET_KEY=env-secret-123 +`; + +describe("prepareEnvironmentVariables (environment variables)", () => { + it("resolves environment variables correctly", () => { + const serviceWithEnvVars = ` +NODE_ENV=\${{environment.NODE_ENV}} +API_URL=\${{environment.API_URL}} +SERVICE_PORT=4000 +`; + + const resolved = prepareEnvironmentVariables( + serviceWithEnvVars, + "", + environmentEnv, + ); + + expect(resolved).toEqual([ + "NODE_ENV=development", + "API_URL=https://api.dev.example.com", + "SERVICE_PORT=4000", + ]); + }); + + it("resolves both project and environment variables", () => { + const serviceWithBoth = ` +ENVIRONMENT=\${{project.ENVIRONMENT}} +NODE_ENV=\${{environment.NODE_ENV}} +API_URL=\${{environment.API_URL}} +DATABASE_URL=\${{project.DATABASE_URL}} +SERVICE_PORT=4000 +`; + + const resolved = prepareEnvironmentVariables( + serviceWithBoth, + projectEnv, + environmentEnv, + ); + + expect(resolved).toEqual([ + "ENVIRONMENT=staging", + "NODE_ENV=development", + "API_URL=https://api.dev.example.com", + "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", + "SERVICE_PORT=4000", + ]); + }); + + it("handles undefined environment variables", () => { + const serviceWithUndefined = ` +UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}} +`; + + expect(() => + prepareEnvironmentVariables(serviceWithUndefined, "", environmentEnv), + ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); + }); + + it("allows service variables to override environment variables", () => { + const serviceOverrideEnv = ` +NODE_ENV=production +API_URL=\${{environment.API_URL}} +`; + + const resolved = prepareEnvironmentVariables( + serviceOverrideEnv, + "", + environmentEnv, + ); + + expect(resolved).toEqual([ + "NODE_ENV=production", // Overrides environment variable + "API_URL=https://api.dev.example.com", + ]); + }); + + it("resolves complex references with project, environment, and service variables", () => { + const complexServiceEnv = ` +FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}} +API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api +SERVICE_NAME=my-service +COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} +`; + + const resolved = prepareEnvironmentVariables( + complexServiceEnv, + projectEnv, + environmentEnv, + ); + + expect(resolved).toEqual([ + "FULL_DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db/dev_database", + "API_ENDPOINT=https://api.dev.example.com/staging/api", + "SERVICE_NAME=my-service", + "COMPLEX_VAR=my-service-development-staging", + ]); + }); + + it("handles environment variables with special characters", () => { + const specialEnvVars = ` +SPECIAL_URL=https://special.com +COMPLEX_KEY="key-with-@#$%^&*()" +JWT_SECRET="secret-with-spaces and symbols!@#" +`; + + const serviceWithSpecial = ` +FULL_URL=\${{environment.SPECIAL_URL}}/path?key=\${{environment.COMPLEX_KEY}} +AUTH_SECRET=\${{environment.JWT_SECRET}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithSpecial, + "", + specialEnvVars, + ); + + expect(resolved).toEqual([ + "FULL_URL=https://special.com/path?key=key-with-@#$%^&*()", + "AUTH_SECRET=secret-with-spaces and symbols!@#", + ]); + }); + + it("maintains precedence: service > environment > project", () => { + const conflictingProjectEnv = ` +NODE_ENV=production-project +API_URL=https://project.api.com +DATABASE_NAME=project_db +`; + + const conflictingEnvironmentEnv = ` +NODE_ENV=development-environment +API_URL=https://environment.api.com +DATABASE_NAME=env_db +`; + + const serviceWithConflicts = ` +NODE_ENV=service-override +PROJECT_ENV=\${{project.NODE_ENV}} +ENV_VAR=\${{environment.API_URL}} +DB_NAME=\${{environment.DATABASE_NAME}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithConflicts, + conflictingProjectEnv, + conflictingEnvironmentEnv, + ); + + expect(resolved).toEqual([ + "NODE_ENV=service-override", // Service wins + "PROJECT_ENV=production-project", // Project reference + "ENV_VAR=https://environment.api.com", // Environment reference + "DB_NAME=env_db", // Environment reference + ]); + }); + + it("handles empty environment variables", () => { + const serviceWithEmpty = ` +SERVICE_VAR=test +PROJECT_VAR=\${{project.ENVIRONMENT}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithEmpty, + projectEnv, + "", + ); + + expect(resolved).toEqual(["SERVICE_VAR=test", "PROJECT_VAR=staging"]); + }); + + it("handles mixed quotes and environment variables", () => { + const envWithQuotes = ` +QUOTED_VAR="development" +SINGLE_QUOTED='https://api.dev.example.com' +MIXED_VAR="value with 'single' quotes" +`; + + const serviceWithQuotes = ` +NODE_ENV=\${{environment.QUOTED_VAR}} +API_URL=\${{environment.SINGLE_QUOTED}} +COMPLEX="Prefix-\${{environment.MIXED_VAR}}-Suffix" +`; + + const resolved = prepareEnvironmentVariables( + serviceWithQuotes, + "", + envWithQuotes, + ); + + expect(resolved).toEqual([ + "NODE_ENV=development", + "API_URL=https://api.dev.example.com", + "COMPLEX=Prefix-value with 'single' quotes-Suffix", + ]); + }); + + it("resolves multiple environment references in single value", () => { + const multiRefEnv = ` +HOST=localhost +PORT=5432 +USERNAME=postgres +PASSWORD=secret123 +`; + + const serviceWithMultiRefs = ` +DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb +CONNECTION_STRING=\${{environment.HOST}}:\${{environment.PORT}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithMultiRefs, + "", + multiRefEnv, + ); + + expect(resolved).toEqual([ + "DATABASE_URL=postgresql://postgres:secret123@localhost:5432/mydb", + "CONNECTION_STRING=localhost:5432", + ]); + }); + + it("handles nested references with environment and project variables", () => { + const nestedProjectEnv = ` +BASE_DOMAIN=example.com +PROTOCOL=https +`; + + const nestedEnvironmentEnv = ` +SUBDOMAIN=api.dev +PATH_PREFIX=/v1 +`; + + const serviceWithNested = ` +FULL_URL=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}\${{environment.PATH_PREFIX}}/endpoint +API_BASE=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithNested, + nestedProjectEnv, + nestedEnvironmentEnv, + ); + + expect(resolved).toEqual([ + "FULL_URL=https://api.dev.example.com/v1/endpoint", + "API_BASE=https://api.dev.example.com", + ]); + }); + + it("throws error for malformed environment variable references", () => { + const serviceWithMalformed = ` +MALFORMED1=\${{environment.}} +MALFORMED2=\${{environment}} +VALID=\${{environment.NODE_ENV}} +`; + + // Should throw error for empty variable name after environment. + expect(() => + prepareEnvironmentVariables(serviceWithMalformed, "", environmentEnv), + ).toThrow("Invalid environment variable: environment."); + }); + + it("handles environment variables with numeric values", () => { + const numericEnv = ` +PORT=8080 +TIMEOUT=30 +RETRY_COUNT=3 +PERCENTAGE=99.5 +`; + + const serviceWithNumeric = ` +SERVER_PORT=\${{environment.PORT}} +REQUEST_TIMEOUT=\${{environment.TIMEOUT}} +MAX_RETRIES=\${{environment.RETRY_COUNT}} +SUCCESS_RATE=\${{environment.PERCENTAGE}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithNumeric, + "", + numericEnv, + ); + + expect(resolved).toEqual([ + "SERVER_PORT=8080", + "REQUEST_TIMEOUT=30", + "MAX_RETRIES=3", + "SUCCESS_RATE=99.5", + ]); + }); + + it("handles boolean-like environment variables", () => { + const booleanEnv = ` +DEBUG=true +ENABLED=false +PRODUCTION=1 +DEVELOPMENT=0 +`; + + const serviceWithBoolean = ` +DEBUG_MODE=\${{environment.DEBUG}} +FEATURE_ENABLED=\${{environment.ENABLED}} +IS_PROD=\${{environment.PRODUCTION}} +IS_DEV=\${{environment.DEVELOPMENT}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithBoolean, + "", + booleanEnv, + ); + + expect(resolved).toEqual([ + "DEBUG_MODE=true", + "FEATURE_ENABLED=false", + "IS_PROD=1", + "IS_DEV=0", + ]); + }); +}); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index ff8a99620..5be96e473 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -36,13 +36,22 @@ const baseApp: ApplicationNested = { previewLimit: 0, previewCustomCertResolver: null, previewWildcard: "", - project: { + environmentId: "", + environment: { env: "", - organizationId: "", + environmentId: "", name: "", - description: "", createdAt: "", + description: "", projectId: "", + project: { + env: "", + organizationId: "", + name: "", + description: "", + createdAt: "", + projectId: "", + }, }, buildPath: "/", gitlabPathNamespace: "", @@ -85,7 +94,6 @@ const baseApp: ApplicationNested = { password: null, placementSwarm: null, ports: [], - projectId: "", publishDirectory: null, isStaticSpa: null, redirects: [], diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index a8fef349b..5387659ad 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -69,7 +69,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { toast.success("Application deployed successfully"); refetch(); router.push( - `/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`, + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`, ); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index 438af954a..e75aad5e5 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -101,7 +101,9 @@ export const DeleteService = ({ id, type }: Props) => { deleteVolumes, }) .then((result) => { - push(`/dashboard/project/${result?.projectId}`); + push( + `/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`, + ); toast.success("deleted successfully"); setIsOpen(false); }) diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index 29a9f9be3..870444be7 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -47,7 +47,7 @@ export const ComposeActions = ({ composeId }: Props) => { toast.success("Compose deployed successfully"); refetch(); router.push( - `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`, + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, ); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx b/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx index 2bb47618e..88fd1d111 100644 --- a/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx +++ b/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx @@ -1,10 +1,10 @@ import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator"; interface Props { - projectId: string; + environmentId: string; projectName?: string; } -export const AddAiAssistant = ({ projectId }: Props) => { - return ; +export const AddAiAssistant = ({ environmentId }: Props) => { + return ; }; diff --git a/apps/dokploy/components/dashboard/project/add-application.tsx b/apps/dokploy/components/dashboard/project/add-application.tsx index 137f75a51..dd4effb0f 100644 --- a/apps/dokploy/components/dashboard/project/add-application.tsx +++ b/apps/dokploy/components/dashboard/project/add-application.tsx @@ -64,11 +64,11 @@ const AddTemplateSchema = z.object({ type AddTemplate = z.infer; interface Props { - projectId: string; + environmentId: string; projectName?: string; } -export const AddApplication = ({ projectId, projectName }: Props) => { +export const AddApplication = ({ environmentId, projectName }: Props) => { const utils = api.useUtils(); const { data: isCloud } = api.settings.isCloud.useQuery(); const [visible, setVisible] = useState(false); @@ -94,15 +94,15 @@ export const AddApplication = ({ projectId, projectName }: Props) => { name: data.name, appName: data.appName, description: data.description, - projectId, + environmentId, serverId: data.serverId, }) .then(async () => { toast.success("Service Created"); form.reset(); setVisible(false); - await utils.project.one.invalidate({ - projectId, + await utils.environment.one.invalidate({ + environmentId, }); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx index c32e55c16..d565b5bfd 100644 --- a/apps/dokploy/components/dashboard/project/add-compose.tsx +++ b/apps/dokploy/components/dashboard/project/add-compose.tsx @@ -65,11 +65,11 @@ const AddComposeSchema = z.object({ type AddCompose = z.infer; interface Props { - projectId: string; + environmentId: string; projectName?: string; } -export const AddCompose = ({ projectId, projectName }: Props) => { +export const AddCompose = ({ environmentId, projectName }: Props) => { const utils = api.useUtils(); const [visible, setVisible] = useState(false); const slug = slugify(projectName); @@ -78,6 +78,9 @@ export const AddCompose = ({ projectId, projectName }: Props) => { const { mutateAsync, isLoading, error, isError } = api.compose.create.useMutation(); + // Get environment data to extract projectId + const { data: environment } = api.environment.one.useQuery({ environmentId }); + const hasServers = servers && servers.length > 0; const form = useForm({ @@ -98,7 +101,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => { await mutateAsync({ name: data.name, description: data.description, - projectId, + environmentId, composeType: data.composeType, appName: data.appName, serverId: data.serverId, @@ -106,8 +109,9 @@ export const AddCompose = ({ projectId, projectName }: Props) => { .then(async () => { toast.success("Compose Created"); setVisible(false); - await utils.project.one.invalidate({ - projectId, + // Invalidate the project query to refresh the environment data + await utils.environment.one.invalidate({ + environmentId, }); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx index 104413908..bda918998 100644 --- a/apps/dokploy/components/dashboard/project/add-database.tsx +++ b/apps/dokploy/components/dashboard/project/add-database.tsx @@ -170,11 +170,11 @@ const databasesMap = { type AddDatabase = z.infer; interface Props { - projectId: string; + environmentId: string; projectName?: string; } -export const AddDatabase = ({ projectId, projectName }: Props) => { +export const AddDatabase = ({ environmentId, projectName }: Props) => { const utils = api.useUtils(); const [visible, setVisible] = useState(false); const slug = slugify(projectName); @@ -185,6 +185,9 @@ export const AddDatabase = ({ projectId, projectName }: Props) => { const mariadbMutation = api.mariadb.create.useMutation(); const mysqlMutation = api.mysql.create.useMutation(); + // Get environment data to extract projectId + const { data: environment } = api.environment.one.useQuery({ environmentId }); + const hasServers = servers && servers.length > 0; const form = useForm({ @@ -219,7 +222,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => { name: data.name, appName: data.appName, dockerImage: defaultDockerImage, - projectId, + projectId: environment?.projectId || "", + environmentId, serverId: data.serverId, description: data.description, }; @@ -248,7 +252,6 @@ export const AddDatabase = ({ projectId, projectName }: Props) => { ...commonParams, databasePassword: data.databasePassword, serverId: data.serverId, - projectId, }); } else if (data.type === "mariadb") { promise = mariadbMutation.mutateAsync({ @@ -287,8 +290,9 @@ export const AddDatabase = ({ projectId, projectName }: Props) => { databaseUser: "", }); setVisible(false); - await utils.project.one.invalidate({ - projectId, + // Invalidate the project query to refresh the environment data + await utils.environment.one.invalidate({ + environmentId, }); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index b42806e52..06d0adf39 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -73,11 +73,11 @@ import { api } from "@/utils/api"; const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url"; interface Props { - projectId: string; + environmentId: string; baseUrl?: string; } -export const AddTemplate = ({ projectId, baseUrl }: Props) => { +export const AddTemplate = ({ environmentId, baseUrl }: Props) => { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed"); @@ -91,6 +91,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { return undefined; }); + // Get environment data to extract projectId + const { data: environment } = api.environment.one.useQuery({ environmentId }); + // Save to localStorage when customBaseUrl changes useEffect(() => { if (customBaseUrl) { @@ -490,7 +493,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { disabled={isLoading} onClick={async () => { const promise = mutateAsync({ - projectId, + environmentId, serverId: serverId || undefined, id: template.id, baseUrl: customBaseUrl, @@ -498,8 +501,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { toast.promise(promise, { loading: "Setting up...", success: () => { - utils.project.one.invalidate({ - projectId, + // Invalidate the project query to refresh the environment data + utils.environment.one.invalidate({ + environmentId, }); setOpen(false); return `${template.name} template created successfully`; diff --git a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx new file mode 100644 index 000000000..d6497fd0f --- /dev/null +++ b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx @@ -0,0 +1,446 @@ +import type { findEnvironmentsByProjectId } from "@dokploy/server"; +import { + ChevronDownIcon, + PencilIcon, + PlusIcon, + Terminal, + TrashIcon, +} from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +type Environment = Awaited< + ReturnType +>[number]; +interface AdvancedEnvironmentSelectorProps { + projectId: string; + currentEnvironmentId?: string; +} + +export const AdvancedEnvironmentSelector = ({ + projectId, + currentEnvironmentId, +}: AdvancedEnvironmentSelectorProps) => { + const router = useRouter(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedEnvironment, setSelectedEnvironment] = + useState(null); + + const { data: environments } = api.environment.byProjectId.useQuery( + { projectId: projectId }, + { + enabled: !!projectId, + }, + ); + + // Form states + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + // API mutations + const { data: environment } = api.environment.one.useQuery( + { environmentId: currentEnvironmentId || "" }, + { + enabled: !!currentEnvironmentId, + }, + ); + + const haveServices = + selectedEnvironment && + ((selectedEnvironment?.mariadb?.length || 0) > 0 || + (selectedEnvironment?.mongo?.length || 0) > 0 || + (selectedEnvironment?.mysql?.length || 0) > 0 || + (selectedEnvironment?.postgres?.length || 0) > 0 || + (selectedEnvironment?.redis?.length || 0) > 0 || + (selectedEnvironment?.applications?.length || 0) > 0 || + (selectedEnvironment?.compose?.length || 0) > 0); + const createEnvironment = api.environment.create.useMutation(); + const updateEnvironment = api.environment.update.useMutation(); + const deleteEnvironment = api.environment.remove.useMutation(); + const duplicateEnvironment = api.environment.duplicate.useMutation(); + + // Refetch project data + const utils = api.useUtils(); + + const handleCreateEnvironment = async () => { + try { + await createEnvironment.mutateAsync({ + projectId, + name: name.trim(), + description: description.trim() || null, + }); + + toast.success("Environment created successfully"); + utils.environment.byProjectId.invalidate({ projectId }); + setIsCreateDialogOpen(false); + setName(""); + setDescription(""); + } catch (error) { + toast.error("Failed to create environment"); + } + }; + + const handleUpdateEnvironment = async () => { + if (!selectedEnvironment) return; + + try { + await updateEnvironment.mutateAsync({ + environmentId: selectedEnvironment.environmentId, + name: name.trim(), + description: description.trim() || null, + }); + + toast.success("Environment updated successfully"); + utils.environment.byProjectId.invalidate({ projectId }); + setIsEditDialogOpen(false); + setSelectedEnvironment(null); + setName(""); + setDescription(""); + } catch (error) { + toast.error("Failed to update environment"); + } + }; + + const handleDeleteEnvironment = async () => { + if (!selectedEnvironment) return; + + try { + await deleteEnvironment.mutateAsync({ + environmentId: selectedEnvironment.environmentId, + }); + + toast.success("Environment deleted successfully"); + utils.environment.byProjectId.invalidate({ projectId }); + setIsDeleteDialogOpen(false); + setSelectedEnvironment(null); + + // Redirect to production if we deleted the current environment + if (selectedEnvironment.environmentId === currentEnvironmentId) { + const productionEnv = environments?.find( + (env) => env.name === "production", + ); + if (productionEnv) { + router.push( + `/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`, + ); + } + } + } catch (error) { + toast.error("Failed to delete environment"); + } + }; + + const handleDuplicateEnvironment = async (environment: Environment) => { + try { + const result = await duplicateEnvironment.mutateAsync({ + environmentId: environment.environmentId, + name: `${environment.name}-copy`, + description: environment.description, + }); + + toast.success("Environment duplicated successfully"); + utils.project.one.invalidate({ projectId }); + + // Navigate to the new duplicated environment + router.push( + `/dashboard/project/${projectId}/environment/${result.environmentId}`, + ); + } catch (error) { + toast.error("Failed to duplicate environment"); + } + }; + + const openEditDialog = (environment: Environment) => { + setSelectedEnvironment(environment); + setName(environment.name); + setDescription(environment.description || ""); + setIsEditDialogOpen(true); + }; + + const openDeleteDialog = (environment: Environment) => { + setSelectedEnvironment(environment); + setIsDeleteDialogOpen(true); + }; + + const currentEnv = environments?.find( + (env) => env.environmentId === currentEnvironmentId, + ); + + return ( + <> + + + + + + Environments + + + {environments?.map((environment) => { + const servicesCount = + environment.mariadb.length + + environment.mongo.length + + environment.mysql.length + + environment.postgres.length + + environment.redis.length + + environment.applications.length + + environment.compose.length; + return ( +
+ { + router.push( + `/dashboard/project/${projectId}/environment/${environment.environmentId}`, + ); + }} + > +
+ + {environment.name} ({servicesCount}) + + {environment.environmentId === currentEnvironmentId && ( +
+ )} +
+ + + {/* Action buttons for non-production environments */} + + + + {environment.name !== "production" && ( +
+ + + +
+ )} +
+ ); + })} + + + setIsCreateDialogOpen(true)} + > + + Create Environment + + + + + + + + Create Environment + + Create a new environment for your project. + + + +
+
+ + setName(e.target.value)} + placeholder="Environment name" + /> +
+
+ +