Parcourir la source

Merge branch 'master' into nafees/workers

Mohammed Nafees il y a 3 ans
Parent
commit
58e16940c4
66 fichiers modifiés avec 3341 ajouts et 1576 suppressions
  1. 21 0
      api/server/handlers/cluster/update.go
  2. 14 0
      api/server/handlers/namespace/create_env_group.go
  3. 22 1
      api/server/handlers/namespace/get_env_group.go
  4. 25 0
      api/server/handlers/release/get.go
  5. 11 1
      api/server/handlers/release/update_git_action_config.go
  6. 121 43
      api/server/handlers/stack/create.go
  7. 13 0
      api/server/handlers/stack/delete.go
  8. 36 0
      api/server/handlers/stack/list_revisions.go
  9. 12 1
      api/server/handlers/stack/rollback.go
  10. 4 1
      api/server/handlers/stack/update_source_put.go
  11. 58 6
      api/server/router/v1/stack.go
  12. 5 0
      api/types/namespace.go
  13. 2 2
      api/types/release.go
  14. 54 1
      api/types/stacks.go
  15. 17 0
      cmd/app/main.go
  16. 6 0
      dashboard/src/components/porter-form/PorterForm.tsx
  17. 4 1
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  18. 24 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  19. 11 0
      dashboard/src/components/porter-form/types.ts
  20. 60 11
      dashboard/src/components/repo-selector/BranchList.tsx
  21. 1 0
      dashboard/src/main/home/Home.tsx
  22. 54 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  23. 13 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  24. 83 38
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  25. 42 15
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  26. 0 994
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  27. 72 87
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  28. 15 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  29. 3 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  30. 22 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  31. 486 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx
  32. 550 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx
  33. 29 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/types.ts
  34. 79 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/useStackEnvGroups.ts
  35. 61 5
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  36. 150 63
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  37. 8 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  38. 3 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  39. 112 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  40. 5 10
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  41. 79 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx
  42. 37 6
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  43. 84 73
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  44. 156 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  45. 91 7
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  46. 3 11
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  47. 103 9
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  48. 0 54
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx
  49. 130 45
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  50. 29 14
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  51. 30 1
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  52. 2 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  53. 8 0
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  54. 3 4
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  55. 13 46
      dashboard/src/main/home/sidebar/Sidebar.tsx
  56. 45 0
      dashboard/src/main/home/sidebar/SidebarLink.tsx
  57. 14 4
      dashboard/src/shared/api.tsx
  58. 1 1
      dashboard/src/shared/types.tsx
  59. 0 1
      internal/models/release.go
  60. 39 0
      internal/models/stack.go
  61. 1 0
      internal/repository/gorm/migrate.go
  62. 15 3
      internal/repository/gorm/stack.go
  63. 2 0
      internal/repository/stack.go
  64. 4 0
      internal/repository/test/stack.go
  65. 23 0
      internal/stacks/helpers.go
  66. 121 0
      internal/stacks/hooks.go

+ 21 - 0
api/server/handlers/cluster/update.go

@@ -37,6 +37,27 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// if the cluster has an AWS integration, make sure that the old cluster name is set
+	if cluster.AWSIntegrationID != 0 {
+		awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(cluster.ProjectID, cluster.AWSIntegrationID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if string(awsInt.AWSClusterID) == "" {
+			awsInt.AWSClusterID = []byte(cluster.Name)
+
+			awsInt, err = c.Repo().AWSIntegration().OverwriteAWSIntegration(awsInt)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+
 	cluster.Name = request.Name
 
 	cluster, err := c.Repo().Cluster().UpdateCluster(cluster)

+ 14 - 0
api/server/handlers/namespace/create_env_group.go

@@ -20,6 +20,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
 )
 
 type CreateEnvGroupHandler struct {
@@ -115,6 +116,13 @@ func (c *CreateEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
 		return
 	}
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }
 
 func rolloutApplications(
@@ -367,3 +375,9 @@ func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]inte
 
 	return res, nil
 }
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, envGroup *types.EnvGroup) error {
+	// update the relevant env group version number if tied to a stack resource
+	return stacks.UpdateEnvGroupVersion(config, projectID, clusterID, envGroup)
+}

+ 22 - 1
api/server/handlers/namespace/get_env_group.go

@@ -1,6 +1,7 @@
 package namespace
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -13,6 +14,8 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	"gorm.io/gorm"
 )
 
 type GetEnvGroupHandler struct {
@@ -62,5 +65,23 @@ func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	c.WriteResult(w, r, envGroup)
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.WriteResult(w, r, &types.GetEnvGroupResponse{EnvGroup: envGroup})
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.GetEnvGroupResponse{
+		EnvGroup: envGroup,
+		StackID:  stackId,
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 25 - 0
api/server/handlers/release/get.go

@@ -63,6 +63,31 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 			res.BuildConfig = bc.ToBuildConfigType()
 		}
+
+		if release.StackResourceID != 0 {
+			stackResource, err := c.Repo().Stack().ReadStackResource(release.StackResourceID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			stackRevision, err := c.Repo().Stack().ReadStackRevision(stackResource.StackRevisionID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			stack, err := c.Repo().Stack().ReadStackByID(cluster.ProjectID, stackRevision.StackID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			res.StackID = stack.UID
+		}
 	} else if err != gorm.ErrRecordNotFound {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return

+ 11 - 1
api/server/handlers/release/update_git_action_config.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"gorm.io/gorm"
+	"helm.sh/helm/v3/pkg/release"
 )
 
 type UpdateGitActionConfigHandler struct {
@@ -27,7 +28,7 @@ func NewUpdateGitActionConfigHandler(
 }
 
 func (c *UpdateGitActionConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	release, _ := r.Context().Value(types.ReleaseScope).(*models.Release)
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 
 	request := &types.UpdateGitActionConfigRequest{}
 
@@ -35,6 +36,15 @@ func (c *UpdateGitActionConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
+	// look up the release in the database; if not found, do not populate Porter fields
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	actionConfig, err := c.Repo().GitActionConfig().ReadGitActionConfig(release.GitActionConfig.ID)
 
 	if err != nil {

+ 121 - 43
api/server/handlers/stack/create.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 
 	helmrelease "helm.sh/helm/v3/pkg/release"
@@ -73,6 +74,13 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	envGroups, err := getEnvGroupModels(req.EnvGroups, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// write stack to the database with creating status
 	stack := &models.Stack{
 		ProjectID: proj.ID,
@@ -86,6 +94,7 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				Status:         string(types.StackRevisionStatusDeploying),
 				SourceConfigs:  sourceConfigs,
 				Resources:      resources,
+				EnvGroups:      envGroups,
 			},
 		},
 	}
@@ -97,76 +106,98 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// apply all app resources
-	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	// apply all env groups
+	k8sAgent, err := p.GetAgent(r, cluster, "")
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+	envGroupDeployErrors := make([]string, 0)
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+	for _, envGroup := range req.EnvGroups {
+		cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+			Name:            envGroup.Name,
+			Namespace:       namespace,
+			Variables:       envGroup.Variables,
+			SecretVariables: envGroup.SecretVariables,
+		})
 
-	helmReleaseMap := make(map[string]*helmrelease.Release)
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+		}
 
-	deployErrs := make([]string, 0)
+		// add each of the linked applications to the env group
+		for _, appName := range envGroup.LinkedApplications {
 
-	for _, appResource := range req.AppResources {
-		rel, err := applyAppResource(&applyAppResourceOpts{
-			config:     p.Config(),
-			projectID:  proj.ID,
-			namespace:  namespace,
-			cluster:    cluster,
-			registries: registries,
-			helmAgent:  helmAgent,
-			request:    appResource,
-		})
+			cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
 
-		if err != nil {
-			deployErrs = append(deployErrs, err.Error())
-		} else {
-			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+			if err != nil {
+				envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+			}
 		}
 	}
 
-	// update stack revision status
 	revision := &stack.Revisions[0]
 
-	if len(deployErrs) > 0 {
+	if len(envGroupDeployErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
-		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	} else {
-		revision.Status = string(types.StackRevisionStatusDeployed)
-	}
+		// apply all app resources
+		registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 
-	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+		helmAgent, err := p.GetHelmAgent(r, cluster, "")
 
-	saveErrs := make([]string, 0)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		helmReleaseMap := make(map[string]*helmrelease.Release)
 
-	for _, resource := range revision.Resources {
-		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
-			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+		deployErrs := make([]string, 0)
+
+		for _, appResource := range req.AppResources {
+			rel, err := applyAppResource(&applyAppResourceOpts{
+				config:     p.Config(),
+				projectID:  proj.ID,
+				namespace:  namespace,
+				cluster:    cluster,
+				registries: registries,
+				helmAgent:  helmAgent,
+				request:    appResource,
+			})
 
 			if err != nil {
-				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+				deployErrs = append(deployErrs, err.Error())
+			} else {
+				helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
 			}
 		}
-	}
 
-	if len(saveErrs) > 0 {
-		revision.Reason = "SaveError"
-		revision.Message = strings.Join(saveErrs, " , ")
+		// update stack revision status
+		if len(deployErrs) > 0 {
+			revision.Status = string(types.StackRevisionStatusFailed)
+			revision.Reason = "DeployError"
+			revision.Message = strings.Join(deployErrs, " , ")
+		} else {
+			revision.Status = string(types.StackRevisionStatusDeployed)
+		}
 
 		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
 
@@ -174,6 +205,30 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
+
+		saveErrs := make([]string, 0)
+
+		for _, resource := range revision.Resources {
+			if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+				_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+				if err != nil {
+					saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+				}
+			}
+		}
+
+		if len(saveErrs) > 0 {
+			revision.Reason = "SaveError"
+			revision.Message = strings.Join(saveErrs, " , ")
+
+			revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+			if err != nil {
+				p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
 	}
 
 	// read the stack again to get the latest revision info
@@ -248,3 +303,26 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 
 	return res, nil
 }
+
+func getEnvGroupModels(envGroups []*types.CreateStackEnvGroupRequest, projID, clusterID uint, namespace string) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			Name:            envGroup.Name,
+			UID:             uid,
+			EnvGroupVersion: 1,
+			ProjectID:       projID,
+			ClusterID:       clusterID,
+			Namespace:       namespace,
+		})
+	}
+
+	return res, nil
+}

+ 13 - 0
api/server/handlers/stack/delete.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -40,6 +41,13 @@ func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
+		k8sAgent, err := p.GetAgent(r, cluster, "")
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
 		helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
 
 		if err != nil {
@@ -54,6 +62,11 @@ func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				name:      appResource.Name,
 			})
 		}
+
+		// delete all env groups in stack
+		for _, envGroup := range revision.EnvGroups {
+			envgroup.DeleteEnvGroup(k8sAgent, envGroup.Name, envGroup.Namespace)
+		}
 	}
 
 	stack, err := p.Repo().Stack().DeleteStack(stack)

+ 36 - 0
api/server/handlers/stack/list_revisions.go

@@ -0,0 +1,36 @@
+package stack
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackListRevisionsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackListRevisionsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackListRevisionsHandler {
+	return &StackListRevisionsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackListRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	res := make([]types.StackRevision, 0)
+
+	for _, stackRev := range stack.Revisions {
+		res = append(res, *stackRev.ToStackRevisionType(stack.UID))
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 12 - 1
api/server/handlers/stack/rollback.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -86,8 +87,16 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	revision.SourceConfigs = newSourceConfigs
 	revision.Resources = appResources
+	revision.EnvGroups = envGroups
 
 	revision, err = p.Repo().Stack().AppendNewRevision(revision)
 
@@ -114,9 +123,11 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	if len(rollbackErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "RollbackError"
-		revision.Message = strings.Join(rollbackErrors, " , ")
+		revision.Message = fmt.Sprintf("Error while rolling back to version %d: %s", req.TargetRevision, strings.Join(rollbackErrors, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "Rollback"
+		revision.Message = fmt.Sprintf("The stack was rolled back to version %d", req.TargetRevision)
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 4 - 1
api/server/handlers/stack/update_source_put.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -130,9 +131,11 @@ func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	if len(deployErrs) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Message = fmt.Sprintf("Error while updating source configuration: %s", strings.Join(deployErrs, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "SourceConfigUpdate"
+		revision.Message = fmt.Sprintf("The source configuration was updated")
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 58 - 6
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -58,11 +58,11 @@ type stackRevisionPathParams struct {
 	// required: true
 	StackID string `json:"stack_id"`
 
-	// The stack revision number
+	// The stack revision id
 	// in: path
 	// required: true
 	// minimum: 1
-	StackRevisionNumber string `json:"stack_revision_number"`
+	RevisionID string `json:"revision_id"`
 }
 
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
@@ -267,8 +267,60 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
-	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions -> stack.NewStackListRevisionsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions listStackRevisions
+	//
+	// Lists revisions in a stack. A max of 100 revisions will be returned, sorted from most recent to least recent.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stack revisions
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed stack revisions
+	//     schema:
+	//       $ref: '#/definitions/ListStackRevisionsResponse'
+	//   '403':
+	//     description: Forbidden
+	listRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/revisions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	listRevisionsHandler := stack.NewStackListRevisionsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listRevisionsEndpoint,
+		Handler:  listRevisionsHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} getStackRevision
 	//
 	// Gets a stack revision
 	//
@@ -283,7 +335,7 @@ func getV1StackRoutes(
 	//   - name: cluster_id
 	//   - name: namespace
 	//   - name: stack_id
-	//   - name: stack_revision_number
+	//   - name: revision_id
 	// responses:
 	//   '200':
 	//     description: Successfully got the stack revision

+ 5 - 0
api/types/namespace.go

@@ -210,3 +210,8 @@ type GetJobRunsRequest struct {
 type StreamJobRunsRequest struct {
 	Name string `schema:"name"`
 }
+
+type GetEnvGroupResponse struct {
+	*EnvGroup
+	StackID string `json:"stack_id,omitempty"`
+}

+ 2 - 2
api/types/release.go

@@ -37,7 +37,7 @@ type PorterRelease struct {
 	Tags []string `json:"tags,omitempty"`
 
 	// Whether this release is tied to a stack or not
-	IsStack bool `json:"is_stack"`
+	StackID string `json:"stack_id"`
 }
 
 // swagger:model
@@ -197,7 +197,7 @@ type PatchUpdateReleaseTags struct {
 type PartialGitActionConfig struct {
 	// The branch to use for the git repository
 	// required: true
-	GitBranch string `json:"branch" form:"required"`
+	GitBranch string `json:"git_branch" form:"required"`
 }
 
 type UpdateGitActionConfigRequest struct {

+ 54 - 1
api/types/stacks.go

@@ -17,6 +17,9 @@ type CreateStackRequest struct {
 	// registry or linked to a remote Git repository.
 	// required: true
 	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+
+	// A list of env groups which can be synced to an application
+	EnvGroups []*CreateStackEnvGroupRequest `json:"env_groups,omitempty" form:"required,dive,required"`
 }
 
 // swagger:model
@@ -84,6 +87,9 @@ type Stack struct {
 	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
 }
 
+// swagger:model
+type ListStackRevisionsResponse []StackRevision
+
 // swagger:model
 type StackListResponse []Stack
 
@@ -109,7 +115,7 @@ type StackResource struct {
 	// If this is an app resource, app-specific information for the resource
 	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
 
-	// The source configuration for this stack
+	// The source configuration that this resource uses, if this is an application resource
 	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
 }
 
@@ -158,7 +164,34 @@ type StackRevision struct {
 	// The list of resources deployed in this revision
 	Resources []StackResource `json:"resources"`
 
+	// The list of source configs deployed in this revision
 	SourceConfigs []StackSourceConfig `json:"source_configs"`
+
+	// The list of env groups scoped to this stack
+	EnvGroups []StackEnvGroup `json:"env_groups"`
+}
+
+type StackEnvGroup struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// The version of the env group which is being used
+	EnvGroupVersion uint `json:"env_group_version"`
 }
 
 type StackSourceConfig struct {
@@ -190,6 +223,26 @@ type StackSourceConfig struct {
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 }
 
+// swagger:model
+type CreateStackEnvGroupRequest struct {
+	// The name of the env group
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// The non-secret variables to set in the env group
+	// required: true
+	Variables map[string]string `json:"variables,required" form:"required"`
+
+	// The secret variables to set in the env group
+	// required: true
+	SecretVariables map[string]string `json:"secret_variables,required" form:"required"`
+
+	// The list of applications that this env group should be synced to. These applications **must** be present
+	// in the stack - if an env group is created from a stack, syncing to applications which are not in the stack
+	// is not supported
+	LinkedApplications []string `json:"linked_applications"`
+}
+
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 	// required: true

+ 17 - 0
cmd/app/main.go

@@ -93,6 +93,23 @@ func initData(conf *config.Config) error {
 			return err
 		}
 
+		// determine if there are any clusters in the project already
+		clusters, err := conf.Repo.Cluster().ListClustersByProjectID(1)
+
+		if err != nil {
+			return err
+		}
+
+		// if there are already clusters in the project, determine if any of the clusters are using an
+		// in-cluster auth mechanism
+		if len(clusters) > 0 {
+			for _, cluster := range clusters {
+				if cluster.AuthMechanism == models.InCluster {
+					return nil
+				}
+			}
+		}
+
 		_, err = conf.Repo.Cluster().ReadCluster(1, 1)
 
 		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {

+ 6 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -4,6 +4,7 @@ import {
   CheckboxField,
   CronField,
   FormField,
+  InjectedProps,
   InputField,
   KeyValueArrayField,
   ResourceListField,
@@ -49,6 +50,7 @@ interface Props {
   hideSpacer?: boolean;
   // The tab to redirect to after saving the form
   redirectTabAfterSave?: string;
+  injectedProps?: InjectedProps;
 }
 
 const PorterForm: React.FC<Props> = (props) => {
@@ -63,10 +65,14 @@ const PorterForm: React.FC<Props> = (props) => {
   const { currentTab, setCurrentTab } = props;
 
   const renderSectionField = (field: FormField): JSX.Element => {
+    const injected = props.injectedProps?.[field.type];
+
     const bundledProps = {
       ...field,
       isReadOnly,
+      injectedProps: injected ?? {},
     };
+
     switch (field.type) {
       case "heading":
         return <Heading>{field.label}</Heading>;

+ 4 - 1
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 
 import PorterForm from "./PorterForm";
-import { PorterFormData } from "./types";
+import { InjectedProps, PorterFormData } from "./types";
 import { PorterFormContextProvider } from "./PorterFormContextProvider";
 
 type PropsType = {
@@ -23,6 +23,7 @@ type PropsType = {
   hideBottomSpacer?: boolean;
   redirectTabAfterSave?: string;
   includeMetadata?: boolean;
+  injectedProps?: InjectedProps;
 };
 
 const PorterFormWrapper: React.FC<PropsType> = ({
@@ -44,6 +45,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   hideBottomSpacer,
   redirectTabAfterSave,
   includeMetadata,
+  injectedProps,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -99,6 +101,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
           redirectTabAfterSave={redirectTabAfterSave}
+          injectedProps={injectedProps}
         />
       </PorterFormContextProvider>
     </React.Fragment>

+ 24 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -4,6 +4,7 @@ import {
   GetMetadataFunction,
   KeyValueArrayField,
   KeyValueArrayFieldState,
+  PartialEnvGroup,
   PopulatedEnvGroup,
 } from "../types";
 import sliders from "../../../assets/sliders.svg";
@@ -63,7 +64,27 @@ const KeyValueArray: React.FC<Props> = (props) => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
       // console.log(values);
-      const envGroups = values?.synced || [];
+      const envGroups: PartialEnvGroup[] = values?.synced || [];
+
+      if (Array.isArray(props.injectedProps?.availableSyncEnvGroups)) {
+        const availableEnvGroups = props.injectedProps.availableSyncEnvGroups;
+
+        const populatedEnvGroups = envGroups
+          .map((envGroup) => {
+            return availableEnvGroups.find(
+              (availableEnvGroup) => availableEnvGroup.name === envGroup.name
+            );
+          })
+          .filter(Boolean);
+
+        setState(() => ({
+          synced_env_groups: Array.isArray(populatedEnvGroups)
+            ? populatedEnvGroups
+            : [],
+        }));
+        return;
+      }
+
       const promises = Promise.all(
         envGroups.map(async (envGroup: any) => {
           const res = await api.getEnvGroup(
@@ -90,6 +111,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       });
     }
   }, [
+    props.injectedProps,
     props.value[0],
     variables?.clusterId,
     variables?.namespace,
@@ -180,6 +202,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
             existingValues={getProcessedValues(state.values)}
             enableSyncedEnvGroups={enableSyncedEnvGroups}
             syncedEnvGroups={state.synced_env_groups}
+            availableEnvGroups={props.injectedProps?.availableSyncEnvGroups}
             namespace={variables.namespace}
             clusterId={variables.clusterId}
             closeModal={() =>

+ 11 - 0
dashboard/src/components/porter-form/types.ts

@@ -9,6 +9,7 @@ import { ContextProps } from "../../shared/types";
 
 export interface GenericField {
   id: string;
+  injectedProps: unknown;
 }
 
 export interface GenericInputField extends GenericField {
@@ -88,6 +89,9 @@ export interface KeyValueArrayField extends GenericInputField {
     };
     type: "env" | "normal";
   };
+  injectedProps: {
+    availableSyncEnvGroups: PopulatedEnvGroup[];
+  };
 }
 
 export interface ArrayInputField extends GenericInputField {
@@ -218,6 +222,7 @@ export type PopulatedEnvGroup = {
   };
   applications: any[];
   meta_version: number;
+  stack_id?: string;
 };
 export interface KeyValueArrayFieldState {
   values: {
@@ -308,3 +313,9 @@ export type GetMetadataFunction<T = unknown> = (
   state: PorterFormFieldFieldState,
   context: Partial<ContextProps>
 ) => T;
+
+export type InjectedProps = Partial<
+  {
+    [K in FormField["type"]]: Extract<FormField, { type: K }>["injectedProps"];
+  }
+>;

+ 60 - 11
dashboard/src/components/repo-selector/BranchList.tsx

@@ -113,9 +113,12 @@ const BranchList: React.FC<Props> = ({
         >
           <img src={branch_icon} alt={"branch icon"} />
           {branch}
-          {currentBranch === branch && (
-            <i className="material-icons-outlined">check</i>
-          )}
+          <div>
+            <span>{actionConfig.git_branch === branch ? "Current" : ""}</span>
+            {currentBranch === branch && (
+              <i className="material-icons-outlined">check</i>
+            )}
+          </div>
         </BranchName>
       );
     });
@@ -129,6 +132,19 @@ const BranchList: React.FC<Props> = ({
         prompt={"Search branches..."}
       />
       <BranchListWrapper>
+        {actionConfig.git_branch && actionConfig.git_branch !== currentBranch && (
+          <WarningRow lastItem={false} disabled>
+            <i className="material-icons-round">warning</i>
+            <span>
+              You have unsaved changes. Please click save to commit your
+              changes.
+              <p>
+                Current Branch: <b>{actionConfig.git_branch}</b>. New branch:{" "}
+                <b>{currentBranch}</b>
+              </p>
+            </span>
+          </WarningRow>
+        )}
         <ExpandedWrapper>{renderBranchList()}</ExpandedWrapper>
       </BranchListWrapper>
     </>
@@ -137,21 +153,20 @@ const BranchList: React.FC<Props> = ({
 
 export default BranchList;
 
-const BranchName = styled.div`
+const BranchName = styled.div<{ lastItem: boolean; disabled?: boolean }>`
   display: flex;
   width: 100%;
   font-size: 13px;
   border-bottom: 1px solid
-    ${(props: { lastItem: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
-  cursor: pointer;
+  cursor: ${(props) => (props.disabled ? "default" : "pointer")};
   background: #ffffff11;
   :hover {
-    background: #ffffff22;
+    background: ${(props) => (props.disabled ? "#ffffff11" : "#ffffff22")};
   }
 
   > img {
@@ -160,12 +175,45 @@ const BranchName = styled.div`
     margin-left: 12px;
     margin-right: 12px;
   }
+  > div {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+
+    > span {
+      text-transform: capitalize;
+
+      :last-child {
+        margin-right: 15px;
+      }
+    }
+
+    > i {
+      margin-left: 10px;
+      margin-right: 15px;
+      font-size: 18px;
+      color: #03b503;
+    }
+  }
+`;
+
+const WarningRow = styled(BranchName)`
+  background: #3d3f43;
+  color: #f4ca42;
+  position: sticky;
+  top: 0;
+  animation: fadeIn 0.5s ease-in-out;
 
   > i {
-    margin-left: auto;
-    margin-right: 15px;
     font-size: 18px;
-    color: #03b503;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+
+  p {
+    margin: 0px;
+    margin-top: 5px;
+    color: #ffffff;
   }
 `;
 
@@ -183,6 +231,7 @@ const BranchListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
   overflow-y: auto;
+  position: relative;
 `;
 
 const ExpandedWrapper = styled.div`

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -541,6 +541,7 @@ const ViewWrapper = styled.div`
 const DashboardWrapper = styled.div`
   width: calc(85%);
   min-width: 300px;
+  height: fit-content;
 `;
 
 const StyledHome = styled.div`

+ 54 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -8,6 +8,11 @@ import api from "shared/api";
 
 const ClusterSettings: React.FC = () => {
   const context = useContext(Context);
+  const [newClusterName, setNewClusterName] = useState<string>(
+    context.currentCluster.name
+  );
+  const [successfulRename, setSuccessfulRename] = useState<boolean>(false);
+
   const [accessKeyId, setAccessKeyId] = useState<string>("");
   const [secretKey, setSecretKey] = useState<string>("");
   const [startRotateCreds, setStartRotateCreds] = useState<boolean>(false);
@@ -35,6 +40,26 @@ const ClusterSettings: React.FC = () => {
       });
   };
 
+  let updateClusterName = () => {
+    api
+      .updateClusterName(
+        "<token>",
+        {
+          name: newClusterName,
+        },
+        {
+          project_id: context.currentProject.id,
+          cluster_id: context.currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setSuccessfulRename(true);
+      })
+      .catch(() => {
+        setSuccessfulRename(false);
+      });
+  };
+
   let helperText = (
     <Helper>
       Delete this cluster and underlying infrastructure. To ensure that
@@ -118,11 +143,40 @@ const ClusterSettings: React.FC = () => {
     }
   }
 
+  let renameClusterSection = (
+    <div>
+      <Heading>Rename Cluster</Heading>
+      <InputRow
+        type="text"
+        value={newClusterName}
+        setValue={(x: string) => setNewClusterName(x)}
+        label="Cluster Name"
+        placeholder="ex: my-awesome-cluster"
+        width="100%"
+        isRequired={true}
+      />
+      <Button color="#616FEEcc" onClick={updateClusterName}>
+        Submit
+      </Button>
+    </div>
+  );
+
+  if (successfulRename) {
+    renameClusterSection = (
+      <div>
+        <Heading>Credential Rotation</Heading>
+        <Helper>Successfully renamed the cluster! Reload the page.</Helper>
+      </div>
+    );
+  }
+
   return (
     <div>
       <StyledSettingsSection>
         {keyRotationSection}
         <DarkMatter />
+        {renameClusterSection}
+        <DarkMatter />
         <Heading>Delete Cluster</Heading>
         {helperText}
         <Button

+ 13 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -13,7 +13,7 @@ import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
-import { pushQueryParams } from "shared/routing";
+import { getQueryParam, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = RouteComponentProps &
@@ -79,6 +79,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
                 namespace={this.state.namespace}
               />
               <SortSelector
+                currentView="env-groups"
                 setSortType={(sortType) => this.setState({ sortType })}
                 sortType={this.state.sortType}
               />
@@ -98,6 +99,16 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
     }
   };
 
+  closeExpanded = () => {
+    pushQueryParams(this.props, {}, ["selected_env_group"]);
+    const redirectUrlOnClose = getQueryParam(this.props, "redirect_url");
+    if (redirectUrlOnClose) {
+      this.props.history.push(redirectUrlOnClose);
+      return;
+    }
+    this.setState({ expandedEnvGroup: null });
+  };
+
   renderContents = () => {
     if (this.state.expandedEnvGroup) {
       return (
@@ -108,7 +119,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
           }
           currentCluster={this.props.currentCluster}
           envGroup={this.state.expandedEnvGroup}
-          closeExpanded={() => this.setState({ expandedEnvGroup: null })}
+          closeExpanded={() => this.closeExpanded()}
         />
       );
     } else {

+ 83 - 38
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -7,8 +7,10 @@ import { ClusterType } from "shared/types";
 
 import EnvGroup from "./EnvGroup";
 import Loading from "components/Loading";
+import { getQueryParam, pushQueryParams } from "shared/routing";
+import { RouteComponentProps, withRouter } from "react-router";
 
-type PropsType = {
+type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   namespace: string;
   sortType: string;
@@ -27,51 +29,68 @@ const dummyEnvGroups = [
   { name: "backend-production", last_updated: "7", namespace: "default" },
 ];
 
-export default class EnvGroupList extends Component<PropsType, StateType> {
+class EnvGroupList extends Component<PropsType, StateType> {
   state = {
     envGroups: [] as any[],
     loading: false,
     error: false,
   };
 
-  updateEnvGroups = () => {
-    api
-      .listEnvGroups(
-        "<token>",
-        {},
-        {
-          id: this.context.currentProject.id,
-          namespace: this.props.namespace,
-          cluster_id: this.props.currentCluster.id,
-        }
-      )
-      .then((res) => {
-        let sortedGroups = res?.data;
-        switch (this.props.sortType) {
-          case "Oldest":
-            sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
-            );
-            break;
-          case "Alphabetical":
-            sortedGroups.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-            break;
-          default:
-            sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.created_at) > Date.parse(b.created_at) ? -1 : 1
-            );
-        }
-        this.setState({ envGroups: sortedGroups, loading: false });
-      })
-      .catch((err) => {
-        console.log(err);
-        this.setState({ loading: false, error: true });
-      });
+  updateEnvGroups = async () => {
+    const { currentCluster, namespace, sortType } = this.props;
+    try {
+      const envGroups = await api
+        .listEnvGroups(
+          "<token>",
+          {},
+          {
+            id: this.context.currentProject.id,
+            namespace: this.props.namespace,
+            cluster_id: this.props.currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
+
+      let sortedGroups = envGroups;
+      switch (this.props.sortType) {
+        case "Oldest":
+          sortedGroups.sort((a: any, b: any) =>
+            Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
+          );
+          break;
+        case "Alphabetical":
+          sortedGroups.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+          break;
+        default:
+          sortedGroups.sort((a: any, b: any) =>
+            Date.parse(a.created_at) > Date.parse(b.created_at) ? -1 : 1
+          );
+      }
+
+      return sortedGroups;
+    } catch (error) {
+      console.log(error);
+      this.setState({ loading: false, error: true });
+    }
   };
 
   componentDidMount() {
     this.setState({ loading: true });
-    this.updateEnvGroups();
+    this.updateEnvGroups().then((envGroups) => {
+      const selectedEnvGroup = getQueryParam(this.props, "selected_env_group");
+
+      if (selectedEnvGroup) {
+        // find env group by selectedEnvGroup
+        const envGroup = envGroups.find(
+          (envGroup: any) => envGroup.name === selectedEnvGroup
+        );
+        if (envGroup) {
+          this.props.setExpandedEnvGroup(envGroup);
+          return;
+        }
+      }
+      this.setState({ envGroups, loading: false });
+    });
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -82,10 +101,34 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
       prevProps.sortType !== this.props.sortType
     ) {
       (this.props.namespace || this.props.namespace === "") &&
-        this.updateEnvGroups();
+        this.updateEnvGroups().then((envGroups) => {
+          const selectedEnvGroup = getQueryParam(
+            this.props,
+            "selected_env_group"
+          );
+
+          this.setState({ envGroups, loading: false });
+
+          if (selectedEnvGroup) {
+            // find env group by selectedEnvGroup
+            const envGroup = envGroups.find(
+              (envGroup: any) => envGroup.name === selectedEnvGroup
+            );
+            if (envGroup) {
+              this.props.setExpandedEnvGroup(envGroup);
+            } else {
+              pushQueryParams(this.props, {}, ["selected_env_group"]);
+            }
+          }
+        });
     }
   }
 
+  handleExpand = (envGroup: any) => {
+    pushQueryParams(this.props, { selected_env_group: envGroup.name }, []);
+    this.props.setExpandedEnvGroup(envGroup);
+  };
+
   renderEnvGroupList = () => {
     let { loading, error, envGroups } = this.state;
 
@@ -115,7 +158,7 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
         <EnvGroup
           key={i}
           envGroup={envGroup}
-          setExpanded={() => this.props.setExpandedEnvGroup(envGroup)}
+          setExpanded={() => this.handleExpand(envGroup)}
         />
       );
     });
@@ -128,6 +171,8 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
 
 EnvGroupList.contextType = Context;
 
+export default withRouter(EnvGroupList);
+
 const Placeholder = styled.div`
   width: 100%;
   display: flex;

+ 42 - 15
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -546,23 +546,39 @@ const EnvGroupSettings = ({
           </Helper>
           {!canDelete && (
             <Helper color="#f5cb42">
-              Looks like you still have applications syncedto this env group.
-              Please remove this env group from those applications to delete
+              Applications are still synced to this env group. Navigate to
+              "Linked Applications" and remove this env group from all
+              applications to delete.
             </Helper>
           )}
-          <Button
-            color="#b91133"
-            onClick={() => {
-              setCurrentOverlay({
-                message: `Are you sure you want to delete ${name}?`,
-                onYes: handleDeleteEnvGroup,
-                onNo: () => setCurrentOverlay(null),
-              });
-            }}
-            disabled={!canDelete}
-          >
-            Delete {envGroup.name}
-          </Button>
+          {envGroup.stack_id?.length ? (
+            <>
+              <Helper color="#f5cb42">
+                You have to delete the stack to remove this env group.
+              </Helper>
+              <CloneButton
+                as={DynamicLink}
+                color="#5561C0"
+                to={`/stacks/${envGroup.namespace}/${envGroup.stack_id}`}
+              >
+                Go to the stack
+              </CloneButton>
+            </>
+          ) : (
+            <Button
+              color="#b91133"
+              onClick={() => {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete ${envGroup.name}?`,
+                  onYes: handleDeleteEnvGroup,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              }}
+              disabled={!canDelete}
+            >
+              Delete {envGroup.name}
+            </Button>
+          )}
         </InnerWrapper>
       )}
     </TabWrapper>
@@ -705,6 +721,17 @@ const Button = styled.button`
   }
 `;
 
+const CloneButton = styled(Button)`
+  display: flex;
+  width: fit-content;
+  align-items: center;
+  justify-content: center;
+  background-color: #ffffff11;
+  :hover {
+    background-color: #ffffff18;
+  }
+`;
+
 const InnerWrapper = styled.div<{ full?: boolean }>`
   width: 100%;
   height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};

+ 0 - 994
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -1,994 +0,0 @@
-import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
-import KeyValueArray from "components/form-components/KeyValueArray";
-import SelectRow from "components/form-components/SelectRow";
-import Loading from "components/Loading";
-import MultiSaveButton from "components/MultiSaveButton";
-import _, { differenceBy, unionBy } from "lodash";
-import React, {
-  forwardRef,
-  useContext,
-  useEffect,
-  useImperativeHandle,
-  useMemo,
-  useRef,
-  useState,
-} from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import {
-  BuildConfig,
-  ChartTypeWithExtendedConfig,
-  FullActionConfigType,
-} from "shared/types";
-import styled, { keyframes } from "styled-components";
-import yaml from "js-yaml";
-import { AxiosError } from "axios";
-import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
-import { DeviconsNameList } from "assets/devicons-name-list";
-import Selector from "components/Selector";
-import BranchList from "components/repo-selector/BranchList";
-import Banner from "components/Banner";
-
-type Buildpack = {
-  name: string;
-  buildpack: string;
-  config: {
-    [key: string]: string;
-  };
-};
-
-type DetectedBuildpack = {
-  name: string;
-  builders: string[];
-  detected: Buildpack[];
-  others: Buildpack[];
-};
-
-type DetectBuildpackResponse = DetectedBuildpack[];
-
-type UpdateBuildconfigResponse = {
-  CreatedAt: string;
-  DeletedAt: { Time: string; Valid: boolean };
-  Time: string;
-  Valid: boolean;
-  ID: number;
-  UpdatedAt: string;
-  builder: string;
-  buildpacks: string;
-  config: string;
-  name: string;
-};
-
-type Props = {
-  chart: ChartTypeWithExtendedConfig;
-  isPreviousVersion: boolean;
-};
-
-const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
-
-  const [envVariables, setEnvVariables] = useState(
-    chart.config?.container?.env?.build || null
-  );
-  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
-  const [reRunError, setReRunError] = useState<{
-    title: string;
-    description: string;
-  }>(null);
-  const [buttonStatus, setButtonStatus] = useState<
-    "loading" | "successful" | string
-  >("");
-
-  const [currentBranch, setCurrentBranch] = useState(
-    () => chart?.git_action_config?.git_branch
-  );
-
-  const buildpackConfigRef = useRef<{
-    isLoading: boolean;
-    getBuildConfig: () => BuildConfig;
-  }>(null);
-
-  const saveNewBranch = async (newBranch: string) => {
-    if (!newBranch?.length) {
-      return;
-    }
-
-    if (newBranch === chart?.git_action_config?.git_branch) {
-      return;
-    }
-
-    const newGitActionConfig: FullActionConfigType = {
-      ...chart.git_action_config,
-      git_branch: newBranch,
-    };
-
-    try {
-      api.updateGitActionConfig(
-        "<token>",
-        {
-          git_action_config: newGitActionConfig,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          release_name: chart.name,
-          namespace: chart.namespace,
-        }
-      );
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const saveBuildConfig = async (config: BuildConfig) => {
-    console.log({ config });
-    if (config === null) {
-      return;
-    }
-
-    try {
-      await api.updateBuildConfig<UpdateBuildconfigResponse>(
-        "<token>",
-        { ...config },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: chart.namespace,
-          release_name: chart.name,
-        }
-      );
-    } catch (err) {
-      throw err;
-    }
-  };
-
-  const saveEnvVariables = async (envs: { [key: string]: string }) => {
-    let values = { ...chart.config };
-    if (envs === null) {
-      return;
-    }
-
-    values.container.env.build = { ...envs };
-    const valuesYaml = yaml.dump({ ...values });
-    try {
-      await api.upgradeChartValues(
-        "<token>",
-        {
-          values: valuesYaml,
-        },
-        {
-          id: currentProject.id,
-          namespace: chart.namespace,
-          name: chart.name,
-          cluster_id: currentCluster.id,
-        }
-      );
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const triggerWorkflow = async () => {
-    try {
-      await api.reRunGHWorkflow(
-        "",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          git_installation_id: chart.git_action_config?.git_repo_id,
-          owner: chart.git_action_config?.git_repo?.split("/")[0],
-          name: chart.git_action_config?.git_repo?.split("/")[1],
-          branch: chart.git_action_config?.git_branch,
-          release_name: chart.name,
-        }
-      );
-    } catch (error) {
-      if (!error?.response) {
-        throw error;
-      }
-
-      let tmpError: AxiosError = error;
-
-      /**
-       * @smell
-       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
-       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
-       */
-
-      if (tmpError.response.status === 400) {
-        // setReRunError({
-        //   title: "No previous run found",
-        //   description:
-        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
-        // });
-        setCurrentError(
-          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 409) {
-        // setReRunError({
-        //   title: "The workflow is still running",
-        //   description:
-        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
-        // });
-
-        if (typeof tmpError.response.data === "string") {
-          setRunningWorkflowURL(tmpError.response.data);
-        }
-        setCurrentError(
-          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
-            tmpError.response.data
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 404) {
-        let description = "No action file matching this deployment was found.";
-        if (typeof tmpError.response.data === "string") {
-          const filename = tmpError.response.data;
-          description = description.concat(
-            `Please check that the file "${filename}" exists in your repository.`
-          );
-        }
-        // setReRunError({
-        //   title: "The action doesn't seem to exist",
-        //   description,
-        // });
-
-        setCurrentError(description);
-        return;
-      }
-      throw error;
-    }
-  };
-
-  const clearButtonStatus = (time: number = 800) => {
-    setTimeout(() => {
-      setButtonStatus("");
-    }, time);
-  };
-
-  const getBuildConfig = () => {
-    if (buildpackConfigRef.current?.isLoading) {
-      return null;
-    }
-    return buildpackConfigRef.current?.getBuildConfig() || null;
-  };
-
-  const handleSave = async () => {
-    setButtonStatus("loading");
-
-    const buildConfig = getBuildConfig();
-
-    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
-      setButtonStatus("Can't save until buildpack config is loaded.");
-      clearButtonStatus(1500);
-      return;
-    }
-
-    try {
-      await saveBuildConfig(buildConfig);
-      await saveNewBranch(currentBranch);
-      await saveEnvVariables(envVariables);
-      setButtonStatus("successful");
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      setCurrentError(error);
-    } finally {
-      clearButtonStatus();
-    }
-  };
-
-  const handleSaveAndReDeploy = async () => {
-    setButtonStatus("loading");
-
-    const buildConfig = getBuildConfig();
-
-    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
-      setButtonStatus("Can't save until buildpack config is loaded.");
-      clearButtonStatus();
-      return;
-    }
-
-    try {
-      await saveBuildConfig(buildConfig);
-      await saveNewBranch(currentBranch);
-      await saveEnvVariables(envVariables);
-      await triggerWorkflow();
-      setButtonStatus("successful");
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      setCurrentError(error);
-    } finally {
-      clearButtonStatus();
-    }
-  };
-
-  const currentActionConfig = useMemo(() => {
-    const actionConf = chart.git_action_config;
-    if (actionConf && actionConf.gitlab_integration_id) {
-      return {
-        kind: "gitlab",
-        ...actionConf,
-      } as FullActionConfigType;
-    }
-
-    return {
-      kind: "github",
-      ...actionConf,
-    } as FullActionConfigType;
-  }, [chart]);
-
-  return (
-    <Wrapper>
-      {isPreviousVersion ? (
-        <DisabledOverlay>
-          Build config is disabled when reviewing past versions. Please go to
-          the current revision to update your app build configuration.
-        </DisabledOverlay>
-      ) : null}
-      <StyledSettingsSection blurContent={isPreviousVersion}>
-        {/* {reRunError !== null ? (
-        <AlertCard>
-          <AlertCardIcon className="material-icons">error</AlertCardIcon>
-          <AlertCardContent className="content">
-            <AlertCardTitle className="title">
-              {reRunError.title}
-            </AlertCardTitle>
-            {reRunError.description}
-            {runningWorkflowURL.length ? (
-              <>
-                {" "}
-                To go to the workflow{" "}
-                <DynamicLink to={runningWorkflowURL} target="_blank">
-                  click here
-                </DynamicLink>
-              </>
-            ) : null}
-          </AlertCardContent>
-          <AlertCardAction
-            onClick={() => {
-              setReRunError(null);
-              setRunningWorkflowURL("");
-            }}
-          >
-            <span className="material-icons">close</span>
-          </AlertCardAction>
-        </AlertCard>
-      ) : null} */}
-        <Heading isAtTop>Build Environment Variables</Heading>
-        <KeyValueArray
-          values={envVariables}
-          envLoader
-          externalValues={{
-            namespace: chart.namespace,
-            clusterId: currentCluster.id,
-          }}
-          setValues={(values) => {
-            setEnvVariables(values);
-          }}
-        ></KeyValueArray>
-
-        <Heading>Select Default Branch</Heading>
-        <Helper>
-          Change the default branch the deployments will be made from.
-        </Helper>
-        <Banner type="warning">
-          You must also update the deploy branch in your GitHub Action file.
-        </Banner>
-        <BranchList
-          actionConfig={currentActionConfig}
-          setBranch={setCurrentBranch}
-          currentBranch={currentBranch}
-        />
-
-        {!chart.git_action_config.dockerfile_path ? (
-          <>
-            <Heading>Buildpack Settings</Heading>
-            <BuildpackConfigSection
-              ref={buildpackConfigRef}
-              currentChart={chart}
-              actionConfig={currentActionConfig}
-            />
-          </>
-        ) : null}
-        <SaveButtonWrapper>
-          <MultiSaveButton
-            options={[
-              {
-                text: "Save",
-                onClick: handleSave,
-                description:
-                  "Save the build settings to be used in the next workflow run",
-              },
-              {
-                text: "Save and Redeploy",
-                onClick: handleSaveAndReDeploy,
-                description:
-                  "Immediately trigger a workflow run with updated build settings",
-              },
-            ]}
-            disabled={false}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="left"
-            expandTo="left"
-            saveText=""
-            status={buttonStatus}
-          ></MultiSaveButton>
-        </SaveButtonWrapper>
-      </StyledSettingsSection>
-    </Wrapper>
-  );
-};
-
-export default BuildSettingsTab;
-
-const BuildpackConfigSection = forwardRef<
-  {
-    isLoading: boolean;
-    getBuildConfig: () => BuildConfig;
-  },
-  {
-    actionConfig: FullActionConfigType;
-    currentChart: ChartTypeWithExtendedConfig;
-  }
->(({ actionConfig, currentChart }, ref) => {
-  const { currentProject } = useContext(Context);
-
-  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
-  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
-
-  const [stacks, setStacks] = useState<string[]>(null);
-  const [selectedStack, setSelectedStack] = useState<string>(null);
-
-  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
-  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
-    []
-  );
-
-  const state = useRef<null | {
-    [builder: string]: {
-      stack: string;
-      selectedBuildpacks: Buildpack[];
-      availableBuildpacks: Buildpack[];
-    };
-  }>(null);
-
-  const populateState = (
-    builder: string,
-    stack: string,
-    availableBuildpacks: Buildpack[] = [],
-    selectedBuildpacks: Buildpack[] = []
-  ) => {
-    state.current = {
-      ...state.current,
-      [builder]: {
-        stack: stack,
-        availableBuildpacks: availableBuildpacks,
-        selectedBuildpacks: selectedBuildpacks,
-      },
-    };
-  };
-
-  const populateBuildpacks = (
-    userBuildpacks: string[],
-    detectedBuildpacks: Buildpack[]
-  ) => {
-    const customBuildpackFactory = (name: string): Buildpack => ({
-      name: name,
-      buildpack: name,
-      config: null,
-    });
-
-    return userBuildpacks.map(
-      (ub) =>
-        detectedBuildpacks.find((db) => db.buildpack === ub) ||
-        customBuildpackFactory(ub)
-    );
-  };
-
-  const detectBuildpack = () => {
-    if (actionConfig.kind === "gitlab") {
-      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
-        "<token>",
-        { dir: actionConfig.folder_path || "." },
-        {
-          project_id: currentProject.id,
-          integration_id: actionConfig.gitlab_integration_id,
-
-          repo_owner: actionConfig.git_repo.split("/")[0],
-          repo_name: actionConfig.git_repo.split("/")[1],
-          branch: actionConfig.git_branch,
-        }
-      );
-    }
-
-    return api.detectBuildpack<DetectBuildpackResponse>(
-      "<token>",
-      {
-        dir: actionConfig.folder_path || ".",
-      },
-      {
-        project_id: currentProject.id,
-        git_repo_id: actionConfig.git_repo_id,
-        kind: "github",
-        owner: actionConfig.git_repo.split("/")[0],
-        name: actionConfig.git_repo.split("/")[1],
-        branch: actionConfig.git_branch,
-      }
-    );
-  };
-
-  useEffect(() => {
-    const currentBuildConfig = currentChart?.build_config;
-
-    if (!currentBuildConfig) {
-      return;
-    }
-    detectBuildpack()
-      .then(({ data }) => {
-        const builders = data;
-
-        const defaultBuilder = builders.find((builder) =>
-          builder.builders.find((stack) => stack === currentBuildConfig.builder)
-        );
-
-        const nonSelectedBuilder = builders.find(
-          (builder) =>
-            !builder.builders.find(
-              (stack) => stack === currentBuildConfig.builder
-            )
-        );
-
-        const fullDetectedBuildpacks = [
-          ...defaultBuilder.detected,
-          ...defaultBuilder.others,
-        ];
-
-        const userSelectedBuildpacks = populateBuildpacks(
-          currentBuildConfig.buildpacks,
-          fullDetectedBuildpacks
-        ).filter((b) => b.buildpack);
-
-        const availableBuildpacks = differenceBy(
-          fullDetectedBuildpacks,
-          userSelectedBuildpacks,
-          "buildpack"
-        );
-
-        const defaultStack = defaultBuilder.builders.find((stack) => {
-          return stack === currentBuildConfig.builder;
-        });
-
-        populateState(
-          defaultBuilder.name.toLowerCase(),
-          defaultStack,
-          userSelectedBuildpacks,
-          availableBuildpacks
-        );
-
-        populateState(
-          nonSelectedBuilder.name.toLowerCase(),
-          nonSelectedBuilder.builders[0],
-          nonSelectedBuilder.others,
-          nonSelectedBuilder.detected
-        );
-
-        setBuilders(builders);
-        setSelectedBuilder(defaultBuilder.name.toLowerCase());
-
-        setStacks(defaultBuilder.builders);
-        setSelectedStack(defaultStack);
-        if (!Array.isArray(userSelectedBuildpacks)) {
-          setSelectedBuildpacks([]);
-        } else {
-          setSelectedBuildpacks(userSelectedBuildpacks);
-        }
-        if (!Array.isArray(availableBuildpacks)) {
-          setAvailableBuildpacks([]);
-        } else {
-          setAvailableBuildpacks(availableBuildpacks);
-        }
-      })
-      .catch((err) => {
-        console.error(err);
-      });
-  }, [currentProject, actionConfig, currentChart]);
-
-  useImperativeHandle(
-    ref,
-    () => {
-      const isLoading = !stackOptions?.length || !builderOptions?.length;
-      return {
-        isLoading,
-        getBuildConfig: () => {
-          let buildConfig: BuildConfig = {} as BuildConfig;
-
-          buildConfig.builder = selectedStack;
-          buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
-            return buildpack.buildpack;
-          });
-          return buildConfig;
-        },
-      };
-    },
-    [selectedBuilder, selectedBuildpacks, selectedStack]
-  );
-
-  useEffect(() => {
-    populateState(
-      selectedBuilder,
-      selectedStack,
-      availableBuildpacks,
-      selectedBuildpacks
-    );
-  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
-
-  const builderOptions = useMemo(() => {
-    if (!Array.isArray(builders)) {
-      return;
-    }
-
-    return builders.map((builder) => ({
-      label: builder.name,
-      value: builder.name.toLowerCase(),
-    }));
-  }, [builders]);
-
-  const stackOptions = useMemo(() => {
-    if (!Array.isArray(stacks)) {
-      return;
-    }
-
-    return stacks.map((stack) => ({
-      label: stack,
-      value: stack.toLowerCase(),
-    }));
-  }, [stacks]);
-
-  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
-    setSelectedBuildpacks((selectedBuildpacks) => [
-      ...selectedBuildpacks,
-      buildpack,
-    ]);
-  };
-
-  const handleSelectBuilder = (builderName: string) => {
-    const builder = builders.find(
-      (b) => b.name.toLowerCase() === builderName.toLowerCase()
-    );
-
-    setBuilders(builders);
-    setStacks(builder.builders);
-
-    const currState = state.current;
-    if (currState[builderName]) {
-      const stateBuilder = currState[builderName];
-      setSelectedBuilder(builderName);
-      setSelectedStack(stateBuilder.stack);
-      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
-      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
-      return;
-    }
-  };
-
-  const renderBuildpacksList = (
-    buildpacks: Buildpack[],
-    action: "remove" | "add"
-  ) => {
-    if (!buildpacks.length && action === "remove") {
-      return (
-        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
-      );
-    }
-
-    if (!buildpacks.length && action === "add") {
-      return (
-        <StyledCard>
-          No additional buildpacks are available. You can add a custom buildpack
-          below.
-        </StyledCard>
-      );
-    }
-
-    return buildpacks?.map((buildpack, i) => {
-      const [languageName] = buildpack.name?.split("/").reverse();
-
-      const devicon = DeviconsNameList.find(
-        (devicon) => languageName.toLowerCase() === devicon.name
-      );
-
-      const icon = `devicon-${devicon?.name}-plain colored`;
-
-      let disableIcon = false;
-      if (!devicon) {
-        disableIcon = true;
-      }
-
-      return (
-        <StyledCard key={i}>
-          <ContentContainer>
-            <Icon disableMarginRight={disableIcon} className={icon} />
-            <EventInformation>
-              <EventName>{buildpack?.name}</EventName>
-            </EventInformation>
-          </ContentContainer>
-          <ActionContainer>
-            {action === "add" && (
-              <DeleteButton
-                onClick={() => handleAddBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons-outlined">add</span>
-              </DeleteButton>
-            )}
-            {action === "remove" && (
-              <DeleteButton
-                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons">delete</span>
-              </DeleteButton>
-            )}
-          </ActionContainer>
-        </StyledCard>
-      );
-    });
-  };
-
-  const handleRemoveBuildpack = (buildpackToRemove: string) => {
-    setSelectedBuildpacks((selBuildpacks) => {
-      const tmpSelectedBuildpacks = [...selBuildpacks];
-
-      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToRemove
-      );
-      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
-
-      setAvailableBuildpacks((availableBuildpacks) => [
-        ...availableBuildpacks,
-        buildpack,
-      ]);
-
-      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
-
-      return [...tmpSelectedBuildpacks];
-    });
-  };
-
-  const handleAddBuildpack = (buildpackToAdd: string) => {
-    setAvailableBuildpacks((avBuildpacks) => {
-      const tmpAvailableBuildpacks = [...avBuildpacks];
-      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToAdd
-      );
-      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
-
-      setSelectedBuildpacks((selectedBuildpacks) => [
-        ...selectedBuildpacks,
-        buildpack,
-      ]);
-
-      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
-      return [...tmpAvailableBuildpacks];
-    });
-  };
-
-  if (!stackOptions?.length || !builderOptions?.length) {
-    return <Loading />;
-  }
-
-  return (
-    <BuildpackConfigurationContainer>
-      <>
-        <SelectRow
-          value={selectedBuilder}
-          width="100%"
-          options={builderOptions}
-          setActiveValue={(option) => handleSelectBuilder(option)}
-          label="Select a builder"
-        />
-        <SelectRow
-          value={selectedStack}
-          width="100%"
-          options={stackOptions}
-          setActiveValue={(option) => setSelectedStack(option)}
-          label="Select your stack"
-        />
-        <Helper>
-          The following buildpacks were automatically detected. You can also
-          manually add/remove buildpacks.
-        </Helper>
-        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
-        <Helper>Available buildpacks:</Helper>
-        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
-        <Helper>
-          You may also add buildpacks by directly providing their GitHub links
-          or links to ZIP files that contain the buildpack source code.
-        </Helper>
-        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
-      </>
-    </BuildpackConfigurationContainer>
-  );
-});
-
-const DisabledOverlay = styled.div`
-  position: absolute;
-  width: 100%;
-  height: inherit;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #00000099;
-  z-index: 1000;
-  border-radius: 8px;
-  padding: 0 35px;
-  text-align: center;
-`;
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const SaveButtonWrapper = styled.div`
-  width: 100%;
-  margin-top: 30px;
-  display: flex;
-  justify-content: flex-end;
-`;
-
-const BuildpackConfigurationContainer = styled.div`
-  animation: ${fadeIn} 0.75s;
-`;
-
-const Wrapper = styled.div`
-  position: relative;
-  width: 100%;
-  margin-bottom: 65px;
-  height: 100%;
-`;
-
-const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
-  width: 100%;
-  background: #ffffff11;
-  padding: 0 35px;
-  padding-top: 35px;
-  padding-bottom: 15px;
-  position: relative;
-  border-radius: 8px;
-  height: calc(100% - 55px);
-  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
-`;
-
-const StyledCard = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #ffffff00;
-  background: #ffffff08;
-  margin-bottom: 5px;
-  border-radius: 8px;
-  padding: 14px;
-  overflow: hidden;
-  height: 60px;
-  font-size: 13px;
-  animation: ${fadeIn} 0.5s;
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 100%;
-  width: 100%;
-  align-items: center;
-`;
-
-const Icon = styled.span<{ disableMarginRight: boolean }>`
-  font-size: 20px;
-  margin-left: 10px;
-  ${(props) => {
-    if (!props.disableMarginRight) {
-      return "margin-right: 20px";
-    }
-  }}
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const EventName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const DeleteButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  cursor: pointer;
-  color: #aaaabb;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff44;
-  }
-
-  > span {
-    font-size: 20px;
-  }
-`;
-
-const AlertCard = styled.div`
-  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
-  border-radius: 4px;
-  box-shadow: none;
-  font-weight: 400;
-  font-size: 0.875rem;
-  line-height: 1.43;
-  letter-spacing: 0.01071em;
-  border: 1px solid rgb(229, 115, 115);
-  display: flex;
-  padding: 6px 16px;
-  color: rgb(244, 199, 199);
-  margin-top: 20px;
-  position: relative;
-`;
-
-const AlertCardIcon = styled.span`
-  color: rgb(239, 83, 80);
-  margin-right: 12px;
-  padding: 7px 0px;
-  display: flex;
-  font-size: 22px;
-  opacity: 0.9;
-`;
-
-const AlertCardTitle = styled.div`
-  margin: -2px 0px 0.35em;
-  font-size: 1rem;
-  line-height: 1.5;
-  letter-spacing: 0.00938em;
-  font-weight: 500;
-`;
-
-const AlertCardContent = styled.div`
-  padding: 8px 0px;
-`;
-
-const AlertCardAction = styled.button`
-  position: absolute;
-  right: 5px;
-  top: 5px;
-  border: none;
-  background-color: unset;
-  color: white;
-  :hover {
-    cursor: pointer;
-  }
-`;

+ 72 - 87
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -23,8 +23,9 @@ import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
 import IncidentsTab from "./incidents/IncidentsTab";
-import BuildSettingsTab from "./BuildSettingsTab";
+import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
+import { useStackEnvGroups } from "./useStackEnvGroups";
 
 type Props = {
   namespace: string;
@@ -74,6 +75,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [isAuthorized] = useAuth();
   const [fullScreenLogs, setFullScreenLogs] = useState<boolean>(false);
 
+  const {
+    isStack,
+    stackEnvGroups,
+    isLoadingStackEnvGroups,
+  } = useStackEnvGroups(currentChart);
+
   const {
     newWebsocket,
     openWebsocket,
@@ -500,7 +507,15 @@ const ExpandedChart: React.FC<Props> = (props) => {
           />
         );
       case "build-settings":
-        return <BuildSettingsTab chart={chart} isPreviousVersion={isPreview} />;
+        return (
+          <BuildSettingsTab
+            chart={chart}
+            isPreviousVersion={isPreview}
+            onSave={() => {
+              getChartData(currentChart);
+            }}
+          />
+        );
       default:
     }
   };
@@ -528,7 +543,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo && !currentChart.is_stack) {
+    if (currentChart?.git_action_config?.git_repo && !isStack) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",
@@ -565,59 +580,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setDevOpsMode(!devOpsMode);
   };
 
-  const renderIcon = () => {
-    if (
-      currentChart.chart.metadata.icon &&
-      currentChart.chart.metadata.icon !== ""
-    ) {
-      return <Icon src={currentChart.chart.metadata.icon} />;
-    } else {
-      return <i className="material-icons">tonality</i>;
-    }
-  };
-
-  // const chartStatus = useMemo(() => {
-  //   const getAvailability = (kind: string, c: any) => {
-  //     switch (kind?.toLowerCase()) {
-  //       case "deployment":
-  //       case "replicaset":
-  //         return c.status.availableReplicas == c.status.replicas;
-  //       case "statefulset":
-  //         return c.status.readyReplicas == c.status.replicas;
-  //       case "daemonset":
-  //         return c.status.numberAvailable == c.status.desiredNumberScheduled;
-  //     }
-  //   };
-
-  //   const chartStatus = currentChart.info.status;
-
-  //   if (chartStatus === "deployed") {
-  //     for (var uid in controllers) {
-  //       let value = controllers[uid];
-  //       let available = getAvailability(value.metadata.kind, value);
-  //       let progressing = true;
-
-  //       controllers[uid]?.status?.conditions?.forEach((condition: any) => {
-  //         if (
-  //           condition.type == "Progressing" &&
-  //           condition.status == "False" &&
-  //           condition.reason == "ProgressDeadlineExceeded"
-  //         ) {
-  //           progressing = false;
-  //         }
-  //       });
-
-  //       if (!available && progressing) {
-  //         return "loading";
-  //       } else if (!available && !progressing) {
-  //         return "failed";
-  //       }
-  //     }
-  //     return "deployed";
-  //   }
-  //   return chartStatus;
-  // }, [currentChart, controllers]);
-
   const renderUrl = () => {
     if (url) {
       return (
@@ -840,37 +802,60 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 latestVersion={currentChart.latest_version}
                 upgradeVersion={handleUpgradeVersion}
               />
-              {(isPreview || leftTabOptions.length > 0) && (
-                <BodyWrapper>
-                  <PorterFormWrapper
-                    formData={cloneDeep(currentChart.form)}
-                    valuesToOverride={{
-                      namespace: props.namespace,
-                      clusterId: currentCluster.id,
-                    }}
-                    renderTabContents={renderTabContents}
-                    isReadOnly={
-                      isPreview ||
-                      imageIsPlaceholder ||
-                      !isAuthorized("application", "", ["get", "update"])
-                    }
-                    onSubmit={onSubmit}
-                    includeMetadata
-                    rightTabOptions={rightTabOptions}
-                    leftTabOptions={leftTabOptions}
-                    color={isPreview ? "#f5cb42" : null}
-                    addendum={
-                      <TabButton
-                        onClick={toggleDevOpsMode}
-                        devOpsMode={devOpsMode}
-                      >
-                        <i className="material-icons">offline_bolt</i> DevOps
-                        Mode
-                      </TabButton>
-                    }
-                    saveValuesStatus={saveValuesStatus}
-                  />
-                </BodyWrapper>
+              {isStack && isLoadingStackEnvGroups ? (
+                <>
+                  <LineBreak />
+                  <Placeholder>
+                    <TextWrap>
+                      <Header>
+                        <Spinner src={loadingSrc} />
+                      </Header>
+                    </TextWrap>
+                  </Placeholder>
+                </>
+              ) : (
+                <>
+                  {(isPreview || leftTabOptions.length > 0) && (
+                    <BodyWrapper>
+                      <PorterFormWrapper
+                        formData={cloneDeep(currentChart.form)}
+                        valuesToOverride={{
+                          namespace: props.namespace,
+                          clusterId: currentCluster.id,
+                        }}
+                        renderTabContents={renderTabContents}
+                        isReadOnly={
+                          isPreview ||
+                          imageIsPlaceholder ||
+                          !isAuthorized("application", "", ["get", "update"])
+                        }
+                        onSubmit={onSubmit}
+                        includeMetadata
+                        rightTabOptions={rightTabOptions}
+                        leftTabOptions={leftTabOptions}
+                        color={isPreview ? "#f5cb42" : null}
+                        addendum={
+                          <TabButton
+                            onClick={toggleDevOpsMode}
+                            devOpsMode={devOpsMode}
+                          >
+                            <i className="material-icons">offline_bolt</i>{" "}
+                            DevOps Mode
+                          </TabButton>
+                        }
+                        saveValuesStatus={saveValuesStatus}
+                        injectedProps={{
+                          "key-value-array": {
+                            availableSyncEnvGroups:
+                              isStack && !isPreview
+                                ? stackEnvGroups
+                                : undefined,
+                          },
+                        }}
+                      />
+                    </BodyWrapper>
+                  )}
+                </>
               )}
             </>
           )}

+ 15 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -27,7 +27,8 @@ import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal"
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
-import BuildSettingsTab from "./BuildSettingsTab";
+import BuildSettingsTab from "./build-settings/BuildSettingsTab";
+import { useStackEnvGroups } from "./useStackEnvGroups";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -69,6 +70,12 @@ export const ExpandedJobChartFC: React.FC<{
     setSelectedJob,
   } = useJobs(chart);
 
+  const {
+    isStack,
+    stackEnvGroups,
+    isLoadingStackEnvGroups,
+  } = useStackEnvGroups(chart);
+
   const [devOpsMode, setDevOpsMode] = useState(
     () => localStorage.getItem("devOpsMode") === "true"
   );
@@ -283,7 +290,7 @@ export const ExpandedJobChartFC: React.FC<{
 
   const formData = useMemo(() => cloneDeep(chart?.form || {}), [chart]);
 
-  if (status === "loading") {
+  if (status === "loading" || isLoadingStackEnvGroups) {
     return <Loading />;
   }
 
@@ -378,6 +385,12 @@ export const ExpandedJobChartFC: React.FC<{
                   <i className="material-icons">offline_bolt</i> DevOps Mode
                 </TabButton>
               }
+              injectedProps={{
+                "key-value-array": {
+                  availableSyncEnvGroups:
+                    isStack && !disableForm ? stackEnvGroups : undefined,
+                },
+              }}
             />
           )}
         </BodyWrapper>

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -202,6 +202,8 @@ class RevisionSection extends Component<PropsType, StateType> {
         ? String(imageTag).slice(0, 7)
         : imageTag;
 
+      const isStack = !!this.props.chart.stack_id;
+
       return (
         <Tr
           key={i}
@@ -236,7 +238,7 @@ class RevisionSection extends Component<PropsType, StateType> {
                   "get",
                   "update",
                 ]) ||
-                this.props.chart.is_stack
+                isStack
               }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })

+ 22 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
 import { isDeployedFromGithub } from "shared/release/utils";
 import TagSelector from "./TagSelector";
 import { PORTER_IMAGE_TEMPLATES } from "shared/common";
+import DynamicLink from "components/DynamicLink";
 
 type PropsType = {
   currentChart: ChartType;
@@ -206,10 +207,10 @@ const SettingsSection: React.FC<PropsType> = ({
     if (!isAuthorizedToCreateWebhook) {
       buttonStatus = "Unauthorized to create webhook token";
     }
-    console.log(PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl));
+
     return (
       <>
-        {!currentChart.is_stack &&
+        {!currentChart.stack_id?.length &&
         !PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl) ? (
           <>
             <Heading>Source Settings</Heading>
@@ -316,10 +317,24 @@ const SettingsSection: React.FC<PropsType> = ({
           )}
 
           <Heading>Additional Settings</Heading>
-
-          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
-            Delete {currentChart.name}
-          </Button>
+          {currentChart.stack_id?.length ? (
+            <>
+              <Helper>
+                You have to delete the stack to remove this application.
+              </Helper>
+              <CloneButton
+                as={DynamicLink}
+                color="#5561C0"
+                to={`/stacks/${currentChart.namespace}/${currentChart.stack_id}`}
+              >
+                Go to the stack
+              </CloneButton>
+            </>
+          ) : (
+            <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+              Delete {currentChart.name}
+            </Button>
+          )}
         </StyledSettingsSection>
       ) : (
         <Loading />
@@ -368,7 +383,7 @@ const Button = styled.button`
 
 const CloneButton = styled(Button)`
   display: flex;
-  width: min-content;
+  width: fit-content;
   align-items: center;
   justify-content: center;
   background-color: #ffffff11;

+ 486 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx

@@ -0,0 +1,486 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import MultiSaveButton from "components/MultiSaveButton";
+import _ from "lodash";
+import React, { useContext, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled from "styled-components";
+import yaml from "js-yaml";
+import { AxiosError } from "axios";
+import BranchList from "components/repo-selector/BranchList";
+import Banner from "components/Banner";
+import { UpdateBuildconfigResponse } from "./types";
+import BuildpackConfigSection from "./_BuildpackConfigSection";
+
+type Props = {
+  chart: ChartTypeWithExtendedConfig;
+  isPreviousVersion: boolean;
+  onSave: () => void;
+};
+
+const BuildSettingsTab: React.FC<Props> = ({
+  chart,
+  isPreviousVersion,
+  onSave,
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [envVariables, setEnvVariables] = useState(
+    chart.config?.container?.env?.build || null
+  );
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+  const [reRunError, setReRunError] = useState<{
+    title: string;
+    description: string;
+  }>(null);
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const [currentBranch, setCurrentBranch] = useState(
+    () => chart?.git_action_config?.git_branch
+  );
+
+  const buildpackConfigRef = useRef<{
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  }>(null);
+
+  const saveNewBranch = async (newBranch: string) => {
+    if (!newBranch?.length) {
+      return;
+    }
+
+    if (newBranch === chart?.git_action_config?.git_branch) {
+      return;
+    }
+
+    const newGitActionConfig: FullActionConfigType = {
+      ...chart.git_action_config,
+      git_branch: newBranch,
+    };
+
+    try {
+      await api.updateGitActionConfig(
+        "<token>",
+        {
+          git_action_config: newGitActionConfig,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          release_name: chart.name,
+          namespace: chart.namespace,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const saveBuildConfig = async (config: BuildConfig) => {
+    console.log({ config });
+    if (config === null) {
+      return;
+    }
+
+    try {
+      await api.updateBuildConfig<UpdateBuildconfigResponse>(
+        "<token>",
+        { ...config },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      );
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  const saveEnvVariables = async (envs: { [key: string]: string }) => {
+    let values = { ...chart.config };
+    if (envs === null) {
+      return;
+    }
+
+    values.container.env.build = { ...envs };
+    const valuesYaml = yaml.dump({ ...values });
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+        },
+        {
+          id: currentProject.id,
+          namespace: chart.namespace,
+          name: chart.name,
+          cluster_id: currentCluster.id,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: chart.git_action_config?.git_repo_id,
+          owner: chart.git_action_config?.git_repo?.split("/")[0],
+          name: chart.git_action_config?.git_repo?.split("/")[1],
+          branch: chart.git_action_config?.git_branch,
+          release_name: chart.name,
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+
+  const clearButtonStatus = (time: number = 800) => {
+    setTimeout(() => {
+      setButtonStatus("");
+    }, time);
+  };
+
+  const getBuildConfig = () => {
+    if (buildpackConfigRef.current?.isLoading) {
+      return null;
+    }
+    return buildpackConfigRef.current?.getBuildConfig() || null;
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus(1500);
+      return;
+    }
+
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
+      await saveEnvVariables(envVariables);
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+      onSave();
+    }
+  };
+
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus();
+      return;
+    }
+
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
+      await saveEnvVariables(envVariables);
+      await triggerWorkflow();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+      onSave();
+    }
+  };
+
+  const currentActionConfig = useMemo(() => {
+    const actionConf = chart.git_action_config;
+    if (actionConf && actionConf.gitlab_integration_id) {
+      return {
+        kind: "gitlab",
+        ...actionConf,
+      } as FullActionConfigType;
+    }
+
+    return {
+      kind: "github",
+      ...actionConf,
+    } as FullActionConfigType;
+  }, [chart]);
+
+  return (
+    <Wrapper>
+      {isPreviousVersion ? (
+        <DisabledOverlay>
+          Build config is disabled when reviewing past versions. Please go to
+          the current revision to update your app build configuration.
+        </DisabledOverlay>
+      ) : null}
+      <StyledSettingsSection blurContent={isPreviousVersion}>
+        {/* {reRunError !== null ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              {reRunError.title}
+            </AlertCardTitle>
+            {reRunError.description}
+            {runningWorkflowURL.length ? (
+              <>
+                {" "}
+                To go to the workflow{" "}
+                <DynamicLink to={runningWorkflowURL} target="_blank">
+                  click here
+                </DynamicLink>
+              </>
+            ) : null}
+          </AlertCardContent>
+          <AlertCardAction
+            onClick={() => {
+              setReRunError(null);
+              setRunningWorkflowURL("");
+            }}
+          >
+            <span className="material-icons">close</span>
+          </AlertCardAction>
+        </AlertCard>
+      ) : null} */}
+        <Heading isAtTop>Build Environment Variables</Heading>
+        <KeyValueArray
+          values={envVariables}
+          envLoader
+          externalValues={{
+            namespace: chart.namespace,
+            clusterId: currentCluster.id,
+          }}
+          setValues={(values) => {
+            setEnvVariables(values);
+          }}
+        ></KeyValueArray>
+
+        <Heading>Select Default Branch</Heading>
+        <Helper>
+          Change the default branch the deployments will be made from.
+        </Helper>
+        <Banner>
+          You must also update the deploy branch in your GitHub Action file.
+        </Banner>
+        <BranchList
+          actionConfig={currentActionConfig}
+          setBranch={setCurrentBranch}
+          currentBranch={currentBranch}
+        />
+
+        {!chart.git_action_config.dockerfile_path ? (
+          <>
+            <Heading>Buildpack Settings</Heading>
+            <BuildpackConfigSection
+              ref={buildpackConfigRef}
+              currentChart={chart}
+              actionConfig={currentActionConfig}
+            />
+          </>
+        ) : null}
+        <SaveButtonWrapper>
+          <MultiSaveButton
+            options={[
+              {
+                text: "Save",
+                onClick: handleSave,
+                description:
+                  "Save the build settings to be used in the next workflow run",
+              },
+              {
+                text: "Save and Redeploy",
+                onClick: handleSaveAndReDeploy,
+                description:
+                  "Immediately trigger a workflow run with updated build settings",
+              },
+            ]}
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="left"
+            expandTo="left"
+            saveText=""
+            status={buttonStatus}
+          ></MultiSaveButton>
+        </SaveButtonWrapper>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default BuildSettingsTab;
+
+const DisabledOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: inherit;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #00000099;
+  z-index: 1000;
+  border-radius: 8px;
+  padding: 0 35px;
+  text-align: center;
+`;
+
+const SaveButtonWrapper = styled.div`
+  width: 100%;
+  margin-top: 30px;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-top: 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  height: calc(100% - 55px);
+  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
+`;
+
+const AlertCard = styled.div`
+  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+  border-radius: 4px;
+  box-shadow: none;
+  font-weight: 400;
+  font-size: 0.875rem;
+  line-height: 1.43;
+  letter-spacing: 0.01071em;
+  border: 1px solid rgb(229, 115, 115);
+  display: flex;
+  padding: 6px 16px;
+  color: rgb(244, 199, 199);
+  margin-top: 20px;
+  position: relative;
+`;
+
+const AlertCardIcon = styled.span`
+  color: rgb(239, 83, 80);
+  margin-right: 12px;
+  padding: 7px 0px;
+  display: flex;
+  font-size: 22px;
+  opacity: 0.9;
+`;
+
+const AlertCardTitle = styled.div`
+  margin: -2px 0px 0.35em;
+  font-size: 1rem;
+  line-height: 1.5;
+  letter-spacing: 0.00938em;
+  font-weight: 500;
+`;
+
+const AlertCardContent = styled.div`
+  padding: 8px 0px;
+`;
+
+const AlertCardAction = styled.button`
+  position: absolute;
+  right: 5px;
+  top: 5px;
+  border: none;
+  background-color: unset;
+  color: white;
+  :hover {
+    cursor: pointer;
+  }
+`;

+ 550 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -0,0 +1,550 @@
+import { DeviconsNameList } from "assets/devicons-name-list";
+import Helper from "components/form-components/Helper";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
+import { differenceBy } from "lodash";
+import React, {
+  forwardRef,
+  useContext,
+  useEffect,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import { Buildpack, DetectBuildpackResponse, DetectedBuildpack } from "./types";
+
+const BuildpackConfigSection = forwardRef<
+  {
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  },
+  {
+    actionConfig: FullActionConfigType;
+    currentChart: ChartTypeWithExtendedConfig;
+  }
+>(({ actionConfig, currentChart }, ref) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState(false);
+
+  const state = useRef<null | {
+    [builder: string]: {
+      stack: string;
+      selectedBuildpacks: Buildpack[];
+      availableBuildpacks: Buildpack[];
+    };
+  }>(null);
+
+  const populateState = (
+    builder: string,
+    stack: string,
+    availableBuildpacks: Buildpack[] = [],
+    selectedBuildpacks: Buildpack[] = []
+  ) => {
+    state.current = {
+      ...state.current,
+      [builder]: {
+        stack: stack,
+        availableBuildpacks: availableBuildpacks,
+        selectedBuildpacks: selectedBuildpacks,
+      },
+    };
+  };
+
+  const populateBuildpacks = (
+    userBuildpacks: string[],
+    detectedBuildpacks: Buildpack[]
+  ) => {
+    const customBuildpackFactory = (name: string): Buildpack => ({
+      name: name,
+      buildpack: name,
+      config: null,
+    });
+
+    return userBuildpacks.map(
+      (ub) =>
+        detectedBuildpacks.find((db) => db.buildpack === ub) ||
+        customBuildpackFactory(ub)
+    );
+  };
+
+  const detectBuildpack = () => {
+    if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        { dir: actionConfig.folder_path || "." },
+        {
+          project_id: currentProject.id,
+          integration_id: actionConfig.gitlab_integration_id,
+
+          repo_owner: actionConfig.git_repo.split("/")[0],
+          repo_name: actionConfig.git_repo.split("/")[1],
+          branch: actionConfig.git_branch,
+        }
+      );
+    }
+
+    return api.detectBuildpack<DetectBuildpackResponse>(
+      "<token>",
+      {
+        dir: actionConfig.folder_path || ".",
+      },
+      {
+        project_id: currentProject.id,
+        git_repo_id: actionConfig.git_repo_id,
+        kind: "github",
+        owner: actionConfig.git_repo.split("/")[0],
+        name: actionConfig.git_repo.split("/")[1],
+        branch: actionConfig.git_branch,
+      }
+    );
+  };
+
+  useEffect(() => {
+    const currentBuildConfig = currentChart?.build_config;
+
+    if (!currentBuildConfig) {
+      return;
+    }
+
+    setIsLoading(true);
+
+    detectBuildpack()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find((builder) =>
+          builder.builders.find((stack) => stack === currentBuildConfig.builder)
+        );
+
+        const nonSelectedBuilder = builders.find(
+          (builder) =>
+            !builder.builders.find(
+              (stack) => stack === currentBuildConfig.builder
+            )
+        );
+
+        const fullDetectedBuildpacks = [
+          ...defaultBuilder.detected,
+          ...defaultBuilder.others,
+        ];
+
+        const userSelectedBuildpacks = populateBuildpacks(
+          currentBuildConfig.buildpacks,
+          fullDetectedBuildpacks
+        ).filter((b) => b.buildpack);
+
+        const availableBuildpacks = differenceBy(
+          fullDetectedBuildpacks,
+          userSelectedBuildpacks,
+          "buildpack"
+        );
+
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return stack === currentBuildConfig.builder;
+        });
+
+        populateState(
+          defaultBuilder.name.toLowerCase(),
+          defaultStack,
+          userSelectedBuildpacks,
+          availableBuildpacks
+        );
+
+        populateState(
+          nonSelectedBuilder.name.toLowerCase(),
+          nonSelectedBuilder.builders[0],
+          nonSelectedBuilder.others,
+          nonSelectedBuilder.detected
+        );
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(userSelectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(userSelectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+        setError(true);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, [currentProject, actionConfig, currentChart]);
+
+  useImperativeHandle(
+    ref,
+    () => {
+      return {
+        isLoading: isLoading,
+        getBuildConfig: () => {
+          const currentBuildConfig = currentChart?.build_config;
+
+          if (error) {
+            if (typeof currentBuildConfig.config === "string") {
+              return {
+                ...currentBuildConfig,
+                config: JSON.parse(atob(currentBuildConfig.config)) as Record<
+                  string,
+                  unknown
+                >,
+              } as BuildConfig;
+            } else {
+              return currentBuildConfig;
+            }
+          }
+
+          let buildConfig: BuildConfig = {} as BuildConfig;
+
+          buildConfig.builder = selectedStack;
+          buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+            return buildpack.buildpack;
+          });
+
+          return buildConfig;
+        },
+      };
+    },
+    [selectedBuilder, selectedBuildpacks, selectedStack, isLoading, error]
+  );
+
+  useEffect(() => {
+    populateState(
+      selectedBuilder,
+      selectedStack,
+      availableBuildpacks,
+      selectedBuildpacks
+    );
+  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+
+    setBuilders(builders);
+    setStacks(builder.builders);
+
+    const currState = state.current;
+    if (currState[builderName]) {
+      const stateBuilder = currState[builderName];
+      setSelectedBuilder(builderName);
+      setSelectedStack(stateBuilder.stack);
+      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
+      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
+      return;
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    if (!buildpacks.length && action === "remove") {
+      return (
+        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
+      );
+    }
+
+    if (!buildpacks.length && action === "add") {
+      return (
+        <StyledCard>
+          No additional buildpacks are available. You can add a custom buildpack
+          below.
+        </StyledCard>
+      );
+    }
+
+    return buildpacks?.map((buildpack, i) => {
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
+
+      let disableIcon = false;
+      if (!devicon) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={i}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (isLoading) {
+    return (
+      <div style={{ marginTop: "20px" }}>
+        <Loading />
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div style={{ marginTop: "20px" }}>
+        <Placeholder>
+          <div>
+            <h2>Couldn't retrieve buildpacks.</h2>
+            <p>
+              Check if the branch exists and the Porter App has enough
+              permissions on the repository.
+            </p>
+          </div>
+        </Placeholder>
+      </div>
+    );
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
+        <Helper>Available buildpacks:</Helper>
+        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+      </>
+    </BuildpackConfigurationContainer>
+  );
+});
+
+BuildpackConfigSection.displayName = "BuildpackConfigSection";
+
+export default BuildpackConfigSection;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;

+ 29 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/types.ts

@@ -0,0 +1,29 @@
+export type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+export type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+export type DetectBuildpackResponse = DetectedBuildpack[];
+
+export type UpdateBuildconfigResponse = {
+  CreatedAt: string;
+  DeletedAt: { Time: string; Valid: boolean };
+  Time: string;
+  Valid: boolean;
+  ID: number;
+  UpdatedAt: string;
+  builder: string;
+  buildpacks: string;
+  config: string;
+  name: string;
+};

+ 79 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/useStackEnvGroups.ts

@@ -0,0 +1,79 @@
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import { Stack } from "../stacks/types";
+
+export const useStackEnvGroups = (chart: ChartType) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [stackEnvGroups, setStackEnvGroups] = useState([]);
+  const [loading, setLoading] = useState(true);
+
+  const getEnvGroups = async (stack: Stack) => {
+    const envGroups = stack.latest_revision.env_groups;
+
+    const envGroupsWithValues = envGroups.map((envGroup) => {
+      return api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            version: envGroup.env_group_version,
+          }
+        )
+        .then((res) => res.data);
+    });
+
+    return Promise.all(envGroupsWithValues);
+  };
+
+  const getStack = (stack_id: string) =>
+    api
+      .getStack<Stack>(
+        "token",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          stack_id,
+          namespace: chart.namespace,
+        }
+      )
+      .then((res) => res.data);
+
+  useEffect(() => {
+    const stack_id = chart?.stack_id;
+    if (!stack_id) {
+      // if the chart has been loaded and the chart doesn't have a stack id, set loading to false
+      if (loading && chart) {
+        setLoading(false);
+      }
+
+      return;
+    }
+    setLoading(true);
+    getStack(stack_id)
+      .then((stack) => getEnvGroups(stack))
+      .then((populatedEnvGroups) => {
+        setStackEnvGroups(populatedEnvGroups);
+
+        setLoading(false);
+      })
+      .catch((error) => {
+        setCurrentError(error);
+      });
+  }, [chart?.stack_id]);
+
+  return {
+    isStack: chart?.stack_id?.length ? true : false,
+    stackEnvGroups,
+    isLoadingStackEnvGroups: loading,
+  };
+};

+ 61 - 5
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -1,13 +1,18 @@
 import DynamicLink from "components/DynamicLink";
+import Selector from "components/Selector";
 import React, { useEffect, useState } from "react";
 import { useHistory, useLocation } from "react-router";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
 import NamespaceSelector from "../NamespaceSelector";
+import SortSelector from "../SortSelector";
 import StackList from "./_StackList";
 const Dashboard = () => {
   const [currentNamespace, setCurrentNamespace] = useState("default");
+  const [currentSort, setCurrentSort] = useState<
+    "created_at" | "updated_at" | "alphabetical"
+  >("created_at");
 
   const location = useLocation();
   const history = useHistory();
@@ -38,18 +43,65 @@ const Dashboard = () => {
           <i className="material-icons">add</i>
           Create Stack
         </Button>
-        <NamespaceSelector
-          namespace={currentNamespace}
-          setNamespace={handleNamespaceChange}
-        />
+        <FilterWrapper>
+          <StyledSortSelector>
+            <Label>
+              <i className="material-icons">sort</i> Sort
+            </Label>
+            <Selector
+              activeValue={currentSort}
+              setActiveValue={(sortType) => setCurrentSort(sortType as any)}
+              options={[
+                {
+                  value: "created_at",
+                  label: "Created At",
+                },
+                {
+                  value: "updated_at",
+                  label: "Last Updated",
+                },
+                {
+                  value: "alphabetical",
+                  label: "Alphabetical",
+                },
+              ]}
+              dropdownLabel="Sort By"
+              width="150px"
+              dropdownWidth="230px"
+              closeOverlay={true}
+            />
+          </StyledSortSelector>
+          <NamespaceSelector
+            namespace={currentNamespace}
+            setNamespace={handleNamespaceChange}
+          />
+        </FilterWrapper>
       </ActionRow>
-      <StackList namespace={currentNamespace} />
+      <StackList namespace={currentNamespace} sortBy={currentSort} />
     </>
   );
 };
 
 export default Dashboard;
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledSortSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  margin-right: 30px;
+`;
+
 const Button = styled(DynamicLink)`
   display: flex;
   flex-direction: row;
@@ -101,3 +153,7 @@ const ActionRow = styled.div`
   align-items: center;
   margin-bottom: 35px;
 `;
+
+const FilterWrapper = styled.div`
+  display: flex;
+`;

+ 150 - 63
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -3,6 +3,7 @@ import Placeholder from "components/Placeholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
 import React, { useContext, useEffect, useState } from "react";
+import backArrow from "assets/back_arrow.png";
 import { useParams } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -23,8 +24,11 @@ import {
 } from "../components/styles";
 import { getStackStatus, getStackStatusMessage } from "../shared";
 import { FullStackRevision, Stack, StackRevision } from "../types";
+import EnvGroups from "./components/EnvGroups";
 import RevisionList from "./_RevisionList";
 import SourceConfig from "./_SourceConfig";
+import { NavLink } from "react-router-dom";
+import Settings from "./components/Settings";
 
 const ExpandedStack = () => {
   const { namespace, stack_id } = useParams<{
@@ -39,8 +43,8 @@ const ExpandedStack = () => {
   );
 
   const [stack, setStack] = useState<Stack>();
-  const [sortType, setSortType] = useState("Alphabetical");
   const [isLoading, setIsLoading] = useState(true);
+  const [isDeleting, setIsDeleting] = useState(false);
   const [currentTab, setCurrentTab] = useState("apps");
 
   const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
@@ -70,6 +74,28 @@ const ExpandedStack = () => {
     }
   };
 
+  const handleDelete = () => {
+    setIsDeleting(true);
+    api
+      .deleteStack(
+        "<token>",
+        {},
+        {
+          namespace,
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+        }
+      )
+      .then(() => {
+        pushFiltered("/stacks", []);
+      })
+      .catch((err) => {
+        setCurrentError(err);
+        setIsDeleting(false);
+      });
+  };
+
   useEffect(() => {
     getStack();
   }, [stack_id]);
@@ -78,14 +104,25 @@ const ExpandedStack = () => {
     return <Loading />;
   }
 
+  if (isDeleting) {
+    return (
+      <Placeholder height="400px">
+        <div>
+          <h1>Deleting Stack</h1>
+          <p>This may take some time...</p>
+          <Loading />
+        </div>
+      </Placeholder>
+    );
+  }
+
   return (
     <div>
       <StackTitleWrapper>
-        <TitleSection
-          materialIconClass="material-icons-outlined"
-          icon={"lan"}
-          capitalize
-        >
+        <BackButton to="/stacks">
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection materialIconClass="material-icons-outlined" icon={"lan"}>
           {stack.name}
         </TitleSection>
         <NamespaceTag.Wrapper>
@@ -93,16 +130,21 @@ const ExpandedStack = () => {
           <NamespaceTag.Tag>{stack.namespace}</NamespaceTag.Tag>
         </NamespaceTag.Wrapper>
       </StackTitleWrapper>
-      <RevisionList
-        revisions={stack.revisions}
-        currentRevision={currentRevision}
-        latestRevision={stack.latest_revision}
-        stackId={stack.id}
-        stackNamespace={namespace}
-        onRevisionClick={(revision) => setCurrentRevision(revision)}
-        onRollback={() => getStack()}
-      ></RevisionList>
-      <Br />
+
+      {/* Stack error message */}
+      {currentRevision &&
+      currentRevision?.reason &&
+      currentRevision?.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <i className="material-icons">history</i>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {currentRevision?.status === "failed" ? "Error: " : ""}
+            {currentRevision?.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <Break />
       <InfoWrapper>
         <LastDeployed>
           <Status
@@ -110,30 +152,20 @@ const ExpandedStack = () => {
             message={getStackStatusMessage(stack)}
           />
           <SepDot>•</SepDot>
-          <Text color="#aaaabb">
-            {!stack.latest_revision?.id
-              ? `No version found`
-              : `v${stack.latest_revision.id}`}
-          </Text>
-          <SepDot>•</SepDot>
           Last updated {readableDate(stack.updated_at)}
         </LastDeployed>
       </InfoWrapper>
 
-      {/* Stack error message */}
-      {stack.latest_revision &&
-      stack.latest_revision.status === "failed" &&
-      stack.latest_revision.message?.length > 0 ? (
-        <StackErrorMessageStyles.Wrapper>
-          <StackErrorMessageStyles.Title color="#b7b7c9">
-            Error reason:
-          </StackErrorMessageStyles.Title>
-          <StackErrorMessageStyles.Text color="#aaaabb">
-            {stack.latest_revision.message}
-          </StackErrorMessageStyles.Text>
-        </StackErrorMessageStyles.Wrapper>
-      ) : null}
-
+      <RevisionList
+        revisions={stack.revisions}
+        currentRevision={currentRevision}
+        latestRevision={stack.latest_revision}
+        stackId={stack.id}
+        stackNamespace={namespace}
+        onRevisionClick={(revision) => setCurrentRevision(revision)}
+        onRollback={() => getStack()}
+      ></RevisionList>
+      <Br />
       <TabSelector
         currentTab={currentTab}
         options={[
@@ -146,32 +178,24 @@ const ExpandedStack = () => {
                 {currentRevision.id !== stack.latest_revision.id ? (
                   <ChartListWrapper>
                     <Placeholder>
-                      Not available when previewing versions
+                      Not available when previewing revisions
                     </Placeholder>
                   </ChartListWrapper>
                 ) : (
-                  <>
-                    <SortSelector
-                      setSortType={setSortType}
-                      sortType={sortType}
+                  <ChartListWrapper>
+                    <ChartList
+                      currentCluster={currentCluster}
                       currentView="stacks"
+                      namespace={namespace}
+                      sortType="Alphabetical"
+                      appFilters={
+                        stack?.latest_revision?.resources?.map(
+                          (res) => res.name
+                        ) || []
+                      }
+                      closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
                     />
-
-                    <ChartListWrapper>
-                      <ChartList
-                        currentCluster={currentCluster}
-                        currentView="stacks"
-                        namespace={namespace}
-                        sortType="Alphabetical"
-                        appFilters={
-                          stack?.latest_revision?.resources?.map(
-                            (res) => res.name
-                          ) || []
-                        }
-                        closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-                      />
-                    </ChartListWrapper>
-                  </>
+                  </ChartListWrapper>
                 )}
               </>
             ),
@@ -190,21 +214,78 @@ const ExpandedStack = () => {
               </>
             ),
           },
+          {
+            label: "Env Groups",
+            value: "env_groups",
+            component: (
+              <>
+                <Gap></Gap>
+                <EnvGroups stack={stack} />
+              </>
+            ),
+          },
+          {
+            label: "Settings",
+            value: "settings",
+            component: (
+              <>
+                <Gap></Gap>
+                <Settings stackName={stack.name} onDelete={handleDelete} />
+              </>
+            ),
+          },
         ]}
         setCurrentTab={(tab) => {
           setCurrentTab(tab);
         }}
       ></TabSelector>
+      <PaddingBottom />
     </div>
   );
 };
 
 export default ExpandedStack;
 
+const PaddingBottom = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
+const Break = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const BackButton = styled(NavLink)`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const ChartListWrapper = styled.div`
   width: 100%;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
 `;
 
@@ -216,13 +297,18 @@ const Gap = styled.div`
 
 const StackErrorMessageStyles = {
   Text: styled(Text)`
-    font-size: 14px;
-    margin-bottom: 10px;
+    font-size: 13px;
   `,
   Wrapper: styled.div`
     display: flex;
-    flex-direction: column;
+    align-items: center;
+
     margin-top: 5px;
+    > i {
+      color: #ffffff44;
+      margin-right: 8px;
+      font-size: 20px;
+    }
   `,
   Title: styled(Text)`
     font-size: 16px;
@@ -233,11 +319,12 @@ const StackErrorMessageStyles = {
 const StackTitleWrapper = styled.div`
   width: 100%;
   display: flex;
-  justify-content: space-between;
+  position: relative;
   align-items: center;
 
   // Hotfix to make sure the title section and the namespace tag are aligned
   ${NamespaceTag.Wrapper} {
-    margin-bottom: 15px;
+    margin-left: 17px;
+    margin-bottom: 13px;
   }
 `;

+ 8 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -143,8 +143,9 @@ const _RevisionList = ({
         >
           <RevisionPreview>
             {currentRevision.id === latestRevision.id
-              ? `Current Revision v${currentRevision.id}`
-              : `Previewing Revision (Not Deployed) v${currentRevision.id}`}
+              ? `Current Revision`
+              : `Previewing Revision (Not Deployed)`}{" "}
+              - <Revision>No. {currentRevision.id}</Revision>
             <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
         </RevisionHeader>
@@ -167,6 +168,11 @@ const _RevisionList = ({
 
 export default _RevisionList;
 
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
 const StyledRevisionSection = styled.div`
   display: flex;
   flex-direction: column;

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -159,12 +159,13 @@ const SourceConfigStyles = {
   `,
   ItemContainer: styled.div`
     background: #ffffff11;
-    border-radius: 15px;
-    padding: 20px 15px;
+    border-radius: 8px;
+    padding: 30px 35px 35px;
   `,
   ItemTitle: styled.div`
     font-size: 16px;
     width: fit-content;
+    font-weight: 500;
   `,
   TooltipItem: styled.div`
     font-size: 14px;

+ 112 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -0,0 +1,112 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { Card } from "../../launch/components/styles";
+import { Stack } from "../../types";
+import sliders from "assets/sliders.svg";
+import DynamicLink from "components/DynamicLink";
+import Placeholder from "components/Placeholder";
+import Loading from "components/Loading";
+
+type PopulatedEnvGroup = {
+  applications: string[];
+  created_at: string;
+  meta_version: number;
+  name: string;
+  namespace: string;
+  variables: Record<string, string>;
+  version: number;
+};
+
+const EnvGroups = ({ stack }: { stack: Stack }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [envGroups, setEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+
+  const getEnvGroups = async () => {
+    const stackEnvGroups = stack.latest_revision.env_groups;
+    return Promise.all(
+      stackEnvGroups.map((envGroup) =>
+        api
+          .getEnvGroup<PopulatedEnvGroup>(
+            "<token>",
+            {},
+            {
+              cluster_id: currentCluster.id,
+              id: currentProject.id,
+              name: envGroup.name,
+              namespace: stack.namespace,
+              version: envGroup.env_group_version,
+            }
+          )
+          .then((res) => res.data)
+      )
+    );
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getEnvGroups().then((envGroups) => {
+      if (!isSubscribed) {
+        return;
+      }
+      setEnvGroups(envGroups);
+      setIsLoading(false);
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [stack]);
+
+  if (isLoading) {
+    return (
+      <Placeholder height="250px">
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (envGroups.length === 0) {
+    return (
+      <Placeholder height="250px">
+        <div>
+          <h3>No environment groups found for this stack</h3>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <Card.Grid style={{ marginTop: "0px" }}>
+      {envGroups.map((envGroup) => {
+        return (
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
+              {envGroup.name}
+            </Card.Title>
+
+            <Card.Actions>
+              <Card.ActionButton
+                as={DynamicLink}
+                to={{
+                  pathname: "/env-groups",
+                  search: `?namespace=${stack.namespace}&selected_env_group=${
+                    envGroup.name
+                  }&redirect_url=${encodeURIComponent(
+                    window.location.pathname
+                  )}`,
+                }}
+              >
+                <i className="material-icons-outlined">launch</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
+        );
+      })}
+    </Card.Grid>
+  );
+};
+
+export default EnvGroups;

+ 5 - 10
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -92,7 +92,7 @@ const Select = <T extends unknown>({
         <SelectStyles.Selector
           className={className}
           onClick={() => setExpanded(!expanded)}
-          expanded={expanded}
+          expanded={!readOnly && expanded}
           readOnly={readOnly}
         >
           <SelectStyles.CurrentValue>
@@ -153,18 +153,15 @@ export const SelectStyles = {
     height: 35px;
     border: 1px solid #ffffff55;
     font-size: 13px;
+    color: ${props => props.readOnly ? "#ffffff44" : ""};
     padding: 5px 10px;
     padding-left: 15px;
     border-radius: 3px;
     display: flex;
     justify-content: space-between;
     align-items: center;
-    cursor: ${(props) => (props.readOnly ? "normal" : "pointer")};
+    cursor: ${(props) => (props.readOnly ? "not-allowed" : "pointer")};
     background: ${(props) => {
-      if (props.readOnly) {
-        return "#ffffff55";
-      }
-
       if (props.expanded) {
         return "#ffffff33";
       }
@@ -174,10 +171,8 @@ export const SelectStyles = {
     :hover {
       background: ${(props) => {
         if (props.readOnly) {
-          return "#ffffff55";
-        }
-
-        if (props.expanded) {
+          return "#ffffff11";
+        } else if (props.expanded) {
           return "#ffffff33";
         }
         return "#ffffff22";

+ 79 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx

@@ -0,0 +1,79 @@
+import Heading from "components/form-components/Heading";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+const Settings = ({
+  stackName,
+  onDelete,
+}: {
+  stackName: string;
+  onDelete: () => void;
+}) => {
+  const { setCurrentOverlay } = useContext(Context);
+
+  const handleDelete = () => {
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${stackName}?`,
+      onYes: () => {
+        onDelete();
+        setCurrentOverlay(null);
+      },
+      onNo: () => setCurrentOverlay(null),
+    });
+  };
+  return (
+    <Wrapper>
+      <StyledSettingsSection>
+        <Heading>Settings</Heading>
+        <Button color="#b91133" onClick={handleDelete}>
+          Delete stack
+        </Button>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default Settings;
+
+const Wrapper = styled.div`
+  width: 100%;
+  padding-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  overflow: auto;
+  height: calc(100% - 55px);
+`;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 20px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;

+ 37 - 6
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -1,13 +1,13 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import Placeholder from "components/Placeholder";
 import styled from "styled-components";
 import { Stack } from "./types";
 import { readableDate } from "shared/string_utils";
-import { CardGrid, Card } from "./launch/components/styles";
+import { Card } from "./launch/components/styles";
 import Status, { StatusProps } from "./components/Status";
 import {
   Flex,
@@ -19,7 +19,13 @@ import {
 } from "./components/styles";
 import { getStackStatus, getStackStatusMessage } from "./shared";
 
-const StackList = ({ namespace }: { namespace: string }) => {
+const StackList = ({
+  namespace,
+  sortBy,
+}: {
+  namespace: string;
+  sortBy: "created_at" | "updated_at" | "alphabetical";
+}) => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
@@ -87,6 +93,21 @@ const StackList = ({ namespace }: { namespace: string }) => {
     };
   }, [namespace]);
 
+  const sortedStacks = useMemo(() => {
+    return (
+      stacks?.sort((a, b) => {
+        switch (sortBy) {
+          case "created_at":
+            return Date.parse(a.created_at) < Date.parse(b.created_at) ? 1 : -1;
+          case "updated_at":
+            return Date.parse(a.updated_at) < Date.parse(b.updated_at) ? 1 : -1;
+          default:
+            return a.name > b.name ? 1 : -1;
+        }
+      }) || []
+    );
+  }, [stacks, sortBy]);
+
   if (isLoading) {
     return <Loading />;
   }
@@ -102,9 +123,19 @@ const StackList = ({ namespace }: { namespace: string }) => {
     );
   }
 
+  if (sortedStacks.length === 0) {
+    return (
+      <Placeholder height="250px">
+        <div>
+          <h3>No stacks found with the given filters</h3>
+        </div>
+      </Placeholder>
+    );
+  }
+
   return (
     <>
-      <CardGrid>
+      <Card.Grid>
         {stacks.map((stack) => (
           <StackCard
             as={DynamicLink}
@@ -160,7 +191,7 @@ const StackList = ({ namespace }: { namespace: string }) => {
             </Flex>
           </StackCard>
         ))}
-      </CardGrid>
+      </Card.Grid>
     </>
   );
 };
@@ -221,7 +252,7 @@ const DataContainer = styled.div`
   overflow: hidden;
 `;
 
-const StackCard = styled(Card)`
+const StackCard = styled(Card.Wrapper)`
   font-size: 13px;
   font-weight: 500;
 `;

+ 84 - 73
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -15,11 +15,38 @@ import styled from "styled-components";
 import Heading from "components/form-components/Heading";
 import TitleSection from "components/TitleSection";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { BackButton, Icon, Polymer } from "./components/styles";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import { CreateStackBody } from "../types";
 
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 
+const parseEnvGroup = (namespace: string) => (
+  envGroup: CreateStackBody["env_groups"][0]
+): PopulatedEnvGroup => {
+  const variables = envGroup?.variables || {};
+  const secretVariables = envGroup?.secret_variables || {};
+
+  return {
+    name: envGroup.name,
+    version: 1,
+    namespace,
+    applications: envGroup.linked_applications,
+    meta_version: 2,
+    variables: {
+      ...variables,
+      ...Object.keys(secretVariables).reduce((acc, key) => {
+        acc[key] = "PORTERSECRET_" + key;
+        return acc;
+      }, {} as any),
+    },
+  };
+};
+
 const NewApp = () => {
-  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+  const { addAppResource, newStack, namespace } = useContext(
+    StacksLaunchContext
+  );
   const { currentCluster } = useContext(Context);
 
   const params = useParams<{
@@ -72,15 +99,29 @@ const NewApp = () => {
   }, [params]);
 
   if (isLoading) {
-    return <Wrapper><Loading /></Wrapper>;
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
   }
 
   if (hasError) {
     return <>Unexpected error</>;
   }
 
-  const handleSubmit = async (rawValues: any) => {
+  const handleSubmit = async ({
+    values: rawValues,
+    metadata,
+  }: {
+    values: any;
+    metadata: any;
+  }) => {
     setSaveButtonStatus("loading");
+    const syncedEnvGroups =
+      metadata["container.env"]?.added?.map(
+        ({ name }: { name: string }) => name
+      ) || [];
 
     // Convert dotted keys to nested objects
     let values: any = {};
@@ -165,13 +206,16 @@ const NewApp = () => {
       return;
     }
 
-    addAppResource({
-      name: appName,
-      source_config_name: newStack.source_configs[0]?.name || "",
-      template_name: params.template_name,
-      template_version: params.version,
-      values,
-    });
+    addAppResource(
+      {
+        name: appName,
+        source_config_name: newStack.source_configs[0]?.name || "",
+        template_name: params.template_name,
+        template_version: params.version,
+        values,
+      },
+      [...syncedEnvGroups]
+    );
 
     setSaveButtonStatus("successful");
     setTimeout(() => {
@@ -181,21 +225,24 @@ const NewApp = () => {
   };
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection>
         <DynamicLink to={`/stacks/launch/overview`}>
           <BackButton>
-            <i className="material-icons">
-              keyboard_backspace
-            </i>
+            <i className="material-icons">keyboard_backspace</i>
           </BackButton>
         </DynamicLink>
         <Polymer>
-        <Icon src={hardcodedIcons[template.metadata.name]} />
+          <Icon src={hardcodedIcons[template.metadata.name]} />
         </Polymer>
-        Add {template.metadata.name.charAt(0).toUpperCase() + template.metadata.name.slice(1)} to Stack
+        Add{" "}
+        {template.metadata.name.charAt(0).toUpperCase() +
+          template.metadata.name.slice(1)}{" "}
+        to Stack
       </TitleSection>
-      <Heading>Application Name <Required>*</Required></Heading>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
       <InputRow
         type="string"
         value={appName}
@@ -205,17 +252,26 @@ const NewApp = () => {
       />
 
       <div style={{ position: "relative" }}>
-      <Heading>Application Settings</Heading>
-      <Helper>Configure settings for this application.</Helper>
-      <PorterFormWrapper
-        formData={template.form}
-        onSubmit={handleSubmit}
-        isLaunch
-        saveValuesStatus={saveButtonStatus}
-        saveButtonText="Add Application"
-      />
+        <Heading>Application Settings</Heading>
+        <Helper>Configure settings for this application.</Helper>
+        <PorterFormWrapper
+          formData={template.form}
+          onSubmit={handleSubmit}
+          isLaunch
+          saveValuesStatus={saveButtonStatus}
+          saveButtonText="Add Application"
+          valuesToOverride={{ namespace }}
+          injectedProps={{
+            "key-value-array": {
+              availableSyncEnvGroups: newStack.env_groups.map(
+                parseEnvGroup(namespace)
+              ),
+            },
+          }}
+          includeMetadata
+        />
       </div>
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -231,51 +287,6 @@ const Wrapper = styled.div`
   margin-top: calc(50vh - 150px);
 `;
 
-const Icon = styled.img`
-  width: 40px;
-  margin-right: 14px;
-
-  opacity: 0;
-  animation: floatIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const BackButton = styled.div`
-  > i {
-    cursor: pointer;
-    font-size: 24px;
-    color: #969fbbaa;
-    margin-right: 10px;
-    padding: 3px;
-    margin-left: 0px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-`;
-
-const Polymer = styled.div`
-  margin-bottom: -6px;
-
-  > i {
-    color: #ffffff;
-    font-size: 24px;
-    margin-left: 5px;
-    margin-right: 18px;
-  }
-`;
-
 const StyledLaunchFlow = styled.div`
   min-width: 300px;
   width: calc(100% - 100px);
@@ -283,4 +294,4 @@ const StyledLaunchFlow = styled.div`
   margin-top: ${(props: { disableMarginTop?: boolean }) =>
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
-`;
+`;

+ 156 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx

@@ -0,0 +1,156 @@
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useMemo, useState } from "react";
+import { isAlphanumeric } from "shared/common";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
+import { BackButton, Icon, Polymer, SubmitButton } from "./components/styles";
+import { StacksLaunchContext } from "./Store";
+import sliders from "assets/sliders.svg";
+
+const envArrayToObject = (variables: KeyValueType[]) => {
+  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
+    acc[curr.key] = curr.value;
+    return acc;
+  }, {});
+};
+
+const NewEnvGroup = () => {
+  const { addEnvGroup } = useContext(StacksLaunchContext);
+  const [name, setName] = useState("");
+  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
+
+  const { pushFiltered } = useRouting();
+
+  const handleOnSubmit = () => {
+    const variables = envVariables.filter(
+      (variable) => !variable.locked && !variable.hidden
+    );
+    const secret_variables = envVariables.filter(
+      (variable) => variable.locked || variable.hidden
+    );
+
+    addEnvGroup({
+      name,
+      variables: envArrayToObject(variables),
+      secret_variables: envArrayToObject(secret_variables),
+      linked_applications: [],
+    });
+    setName("");
+    setEnvVariables([]);
+    pushFiltered("/stacks/launch/overview", []);
+    return;
+  };
+
+  const hasError = useMemo(() => {
+    if (!isAlphanumeric(name) || name === "") {
+      return { message: "Name cannot be empty." };
+    }
+
+    if (!envVariables.length) {
+      return { message: "Please add at least one environment variable." };
+    }
+
+    if (envVariables.some((variable) => !variable.value || !variable.key)) {
+      return { message: "Please fill in all environment variables." };
+    }
+
+    return null;
+  }, [name, envVariables]);
+
+  return (
+    <>
+      <TitleSection>
+        <DynamicLink to={`/stacks/launch/overview`}>
+          <BackButton>
+            <i className="material-icons">keyboard_backspace</i>
+          </BackButton>
+        </DynamicLink>
+        <Polymer>
+          <SliderIcon src={sliders} />
+        </Polymer>
+        Add a Env Group to Stack
+      </TitleSection>
+      <Heading isAtTop={true}>Name</Heading>
+      <Subtitle>
+        <Warning
+          makeFlush={true}
+          highlight={!isAlphanumeric(name) && name !== ""}
+        >
+          Lowercase letters, numbers, and "-" only.
+        </Warning>
+      </Subtitle>
+      <InputRow
+        type="text"
+        value={name}
+        setValue={(x: string) => {
+          setName(x);
+        }}
+        placeholder="ex: doctor-scientist"
+        width="100%"
+      />
+
+      <Heading>Environment Variables</Heading>
+      <Helper>
+        Set environment variables for your secrets and environment-specific
+        configuration.
+      </Helper>
+      <EnvGroupArray
+        values={envVariables}
+        setValues={(x: any) => setEnvVariables((prev) => [...x])}
+        fileUpload={true}
+        secretOption={true}
+      />
+
+      <SubmitButton
+        onClick={handleOnSubmit}
+        makeFlush
+        clearPosition
+        text="Save env group"
+        disabled={!!hasError}
+        statusPosition="left"
+        status={hasError?.message || ""}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;
+
+export const SliderIcon = styled.img`
+  width: 25px;
+  margin-right: 16px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 0px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;

+ 91 - 7
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -6,14 +6,21 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import useAuth from "shared/auth/useAuth";
 import { useRouting } from "shared/routing";
-import { CardGrid, SubmitButton } from "./components/styles";
-import { AppCard } from "./components/AppCard";
+import {
+  AddResourceButtonStyles,
+  SubmitButton,
+  Card,
+} from "./components/styles";
 import { AddResourceButton } from "./components/AddResourceButton";
 import styled from "styled-components";
 
 import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import TitleSection from "components/TitleSection";
+import DynamicLink from "components/DynamicLink";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import sliders from "assets/sliders.svg";
+import DocsHelper from "components/DocsHelper";
 
 const Overview = () => {
   const {
@@ -22,6 +29,8 @@ const Overview = () => {
     setStackName,
     setStackNamespace,
     submit,
+    removeAppResource,
+    removeEnvGroup,
   } = useContext(StacksLaunchContext);
   const { currentProject, currentCluster } = useContext(Context);
   const [isAuthorized] = useAuth();
@@ -99,7 +108,7 @@ const Overview = () => {
   }, [namespace, newStack.name]);
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
         <Polymer>
           <i className="material-icons">lan</i>
@@ -143,18 +152,71 @@ const Overview = () => {
         />
       </ClusterSection>
 
+      <Heading>
+        Env Groups
+        {/* <InlineDocsHelper
+          disableMargin={true}
+          tooltipText="Environment Groups"
+          link="https://docs.porter.run/deploying-applications/environment-groups"
+        /> */}
+      </Heading>
+      <Helper>Add scoped environment groups to this stack:</Helper>
+      <Card.Grid>
+        {newStack.env_groups.map((envGroup) => (
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.SmallerIcon src={sliders} />
+              {envGroup.name}
+            </Card.Title>
+            <Card.Actions>
+              <Card.ActionButton
+                onClick={() => {
+                  removeEnvGroup(envGroup);
+                }}
+              >
+                <i className="material-icons-outlined">close</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
+        ))}
+
+        <AddResourceButtonStyles.Wrapper>
+          <AddResourceButtonStyles.Flex>
+            <LinkMask to={`/stacks/launch/new-env-group`}></LinkMask>
+            <Icon>
+              <i className="material-icons">add</i>
+            </Icon>
+            Add a new env group
+          </AddResourceButtonStyles.Flex>
+        </AddResourceButtonStyles.Wrapper>
+      </Card.Grid>
+
       <Heading>Applications</Heading>
       <Helper>
         At least one application is required:
         <Required>*</Required>
       </Helper>
-      <CardGrid>
+      <Card.Grid>
         {newStack.app_resources.map((app) => (
-          <AppCard key={app.name} app={app} />
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.Icon src={hardcodedIcons[app.template_name]}></Card.Icon>
+              {app.name}
+            </Card.Title>
+            <Card.Actions>
+              <Card.ActionButton
+                onClick={() => {
+                  removeAppResource(app);
+                }}
+              >
+                <i className="material-icons-outlined">close</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
         ))}
 
         <AddResourceButton />
-      </CardGrid>
+      </Card.Grid>
 
       <SubmitButton
         disabled={!isValid || submitButtonStatus !== ""}
@@ -166,7 +228,7 @@ const Overview = () => {
       >
         Create Stack
       </SubmitButton>
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -229,3 +291,25 @@ const StyledLaunchFlow = styled.div`
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
 `;
+
+const LinkMask = styled(DynamicLink)`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+`;
+
+const Icon = styled.div`
+  margin-bottom: -3px;
+  > i {
+    margin-right: 20px;
+    margin-left: 9px;
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const InlineDocsHelper = styled(DocsHelper)`
+  display: inline-block;
+`;

+ 3 - 11
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -31,7 +31,7 @@ const SelectSource = () => {
   };
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
         <Polymer>
           <i className="material-icons">lan</i>
@@ -59,7 +59,7 @@ const SelectSource = () => {
         clearPosition
         makeFlush
       />
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -67,7 +67,7 @@ export default SelectSource;
 
 const Br = styled.div<{ height?: string }>`
   width: 100%;
-  height: ${props => props.height || "1px"};
+  height: ${(props) => props.height || "1px"};
 `;
 
 const Required = styled.div`
@@ -86,11 +86,3 @@ const Polymer = styled.div`
     margin-right: 18px;
   }
 `;
-
-const StyledLaunchFlow = styled.div`
-  width: calc(100% - 100px);
-  margin-left: 50px;
-  min-width: 300px;
-  margin-top: ${(props: { disableMarginTop?: boolean }) =>
-    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
-`;

+ 103 - 9
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -15,10 +15,17 @@ export type StacksLaunchContextType = {
     sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
   ) => void;
 
-  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+  addAppResource: (
+    appResource: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => void;
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
 
+  addEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => void;
+
+  removeEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => void;
+
   submit: () => Promise<void>;
 };
 
@@ -27,6 +34,7 @@ const defaultValues: StacksLaunchContextType = {
     name: "",
     app_resources: [],
     source_configs: [],
+    env_groups: [],
   },
 
   namespace: "",
@@ -42,6 +50,10 @@ const defaultValues: StacksLaunchContextType = {
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
 
+  addEnvGroup: () => {},
+
+  removeEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => {},
+
   submit: async () => {},
 };
 
@@ -92,25 +104,105 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
   };
 
   const addAppResource: StacksLaunchContextType["addAppResource"] = (
-    appResource
+    appResource,
+    syncedEnvGroups
   ) => {
-    setNewStack((prev) => ({
-      ...prev,
-      app_resources: [...prev.app_resources, appResource],
-    }));
+    setNewStack((prev) => {
+      const envGroups = syncedEnvGroups
+        .map((envGroupName) => {
+          return prev.env_groups.find(
+            (envGroup) => envGroup.name === envGroupName
+          );
+        })
+        .map((envGroup) => {
+          return {
+            ...envGroup,
+            linked_applications: [
+              ...envGroup.linked_applications,
+              appResource.name,
+            ],
+          };
+        });
+
+      // Replace prev.envGroups with envGroups based on name
+      const newEnvGroups = prev.env_groups.map((envGroup) => {
+        const newEnvGroup = envGroups.find(
+          (envGroup2) => envGroup2.name === envGroup.name
+        );
+        return newEnvGroup ? newEnvGroup : envGroup;
+      });
+
+      return {
+        ...prev,
+        app_resources: [...prev.app_resources, appResource],
+        env_groups: newEnvGroups,
+      };
+    });
   };
 
   const removeAppResource: StacksLaunchContextType["removeAppResource"] = (
     appResource
   ) => {
+    setNewStack((prev) => {
+      const removedAppName = appResource.name;
+      const newEnvGroups = prev.env_groups.map((envGroup) => {
+        const newLinkedApplications = envGroup.linked_applications.filter(
+          (linkedApplication) => linkedApplication !== removedAppName
+        );
+        return {
+          ...envGroup,
+          linked_applications: newLinkedApplications,
+        };
+      });
+
+      return {
+        ...prev,
+        app_resources: prev.app_resources.filter(
+          (ar) => ar.name !== appResource.name
+        ),
+        env_groups: newEnvGroups,
+      };
+    });
+  };
+
+  const addEnvGroup: StacksLaunchContextType["addEnvGroup"] = (envGroup) => {
     setNewStack((prev) => ({
       ...prev,
-      app_resources: prev.app_resources.filter(
-        (ar) => ar.name !== appResource.name
-      ),
+      env_groups: [...prev.env_groups, envGroup],
     }));
   };
 
+  const removeEnvGroup: StacksLaunchContextType["removeEnvGroup"] = (
+    envGroup
+  ) => {
+    setNewStack((prev) => {
+      const removedEnvGroup = prev.env_groups.find(
+        (eg) => eg.name === envGroup.name
+      );
+
+      if (removedEnvGroup.linked_applications.length > 0) {
+        const formatter = new Intl.ListFormat("en", {
+          style: "long",
+          type: "conjunction",
+        });
+
+        setCurrentError(
+          `Cannot remove env group ${
+            envGroup.name
+          } because it is linked to applications: ${formatter.format(
+            removedEnvGroup.linked_applications
+          )}`
+        );
+        return prev;
+      }
+
+      return {
+        ...prev,
+        env_groups: prev.env_groups.filter((eg) => eg.name !== envGroup.name),
+      };
+    });
+  };
+
   const submit: StacksLaunchContextType["submit"] = async () => {
     try {
       await api.createStack("<token>", newStack, {
@@ -134,6 +226,8 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
         addSourceConfig,
         addAppResource,
         removeAppResource,
+        addEnvGroup,
+        removeEnvGroup,
         submit,
       }}
     >

+ 0 - 54
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx

@@ -1,54 +0,0 @@
-import React, { useContext } from "react";
-import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
-import { ButtonWithIcon, Card } from "./styles";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-
-import styled from "styled-components";
-
-export const AppCard = ({
-  app,
-}: {
-  app: StacksLaunchContextType["newStack"]["app_resources"][0];
-}) => {
-  const { removeAppResource } = useContext(StacksLaunchContext);
-
-  const handleDelete = () => {
-    removeAppResource(app);
-  };
-
-  return (
-    <UnclickableCard>
-      <Flex>
-        <Icon src={hardcodedIcons[app.template_name]} />
-        {app.name}
-      </Flex>
-      <DeleteButton onClick={handleDelete}>
-        <i className="material-icons-outlined">close</i>
-      </DeleteButton>
-    </UnclickableCard>
-  );
-};
-
-const UnclickableCard = styled(Card)`
-  cursor: default;
-  :hover {
-    border: 1px solid #ffffff0f;
-  }
-`;
-
-const DeleteButton = styled(ButtonWithIcon)`
-  margin-right: 5px;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 14px;
-  font-weight: 500;
-`;
-
-const Icon = styled.img`
-  height: 30px;
-  margin-right: 15px;
-  margin-left: 5px;
-`;

+ 130 - 45
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -1,39 +1,99 @@
 import SaveButton from "components/SaveButton";
 import styled from "styled-components";
 
-export const CardGrid = styled.div`
-  margin-top: 32px;
-  margin-bottom: 32px;
-  display: grid;
-  grid-row-gap: 25px;
-`;
+export const Card = {
+  Grid: styled.div`
+    margin-top: 32px;
+    margin-bottom: 32px;
+    display: grid;
+    grid-row-gap: 25px;
+  `,
+  Wrapper: styled.div<{ variant?: "clickable" | "unclickable" }>`
+    display: flex;
+    color: #ffffff;
+    background: #2b2e3699;
+    justify-content: space-between;
+    border-radius: 5px;
+    height: 75px;
+    padding: 12px;
+    padding-left: 14px;
+    border: 1px solid #ffffff0f;
+    align-items: center;
 
-export const Card = styled.div`
-  display: flex;
-  color: #ffffff;
-  background: #2b2e3699;
-  justify-content: space-between;
-  border-radius: 5px;
-  cursor: pointer;
-  height: 75px;
-  padding: 12px;
-  padding-left: 14px;
-  border: 1px solid #ffffff0f;
-  align-items: center;
-
-  :hover {
-    border: 1px solid #ffffff3c;
-  }
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
+    ${(props) => {
+      if (props.variant === "unclickable") {
+        return `
+          cursor: default;
+          :hover {
+            border: 1px solid #ffffff0f;
+          }
+        `;
+      }
+
+      return `
+        cursor: pointer;
+        :hover {
+          border: 1px solid #ffffff3c;
+        }
+      `;
+    }}
+
+    animation: fadeIn 0.5s;
+    @keyframes fadeIn {
+      from {
+        opacity: 0;
+      }
+      to {
+        opacity: 1;
+      }
     }
-    to {
-      opacity: 1;
+  `,
+
+  Title: styled.div`
+    display: flex;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 500;
+  `,
+  SmallerIcon: styled.img`
+    height: 20px;
+    margin-right: 18px;
+    margin-left: 8px;
+  `,
+  Icon: styled.img`
+    height: 30px;
+    margin-right: 15px;
+    margin-left: 5px;
+  `,
+  Actions: styled.div`
+    margin-right: 5px;
+    display: flex;
+  `,
+  ActionButton: styled.button`
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    background-color: #ffffff11;
+    border: 1px solid #ffffff22;
+    cursor: pointer;
+    color: white;
+
+    :not(:first-child) {
+      margin-left: 10px;
     }
-  }
-`;
+
+    &:hover {
+      background-color: #ffffff3c;
+    }
+
+    > i {
+      font-size: 18px;
+    }
+  `,
+};
 
 export const SubmitButton = styled(SaveButton)`
   width: 100%;
@@ -42,7 +102,7 @@ export const SubmitButton = styled(SaveButton)`
 `;
 
 export const AddResourceButtonStyles = {
-  Wrapper: styled(Card)`
+  Wrapper: styled(Card.Wrapper)`
     align-items: center;
     position: relative;
     font-size: 14px;
@@ -127,22 +187,47 @@ export const SelectorStyles = {
   `,
 };
 
-export const ButtonWithIcon = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  background-color: #ffffff11;
-  border: 1px solid #ffffff22;
-  cursor: pointer;
-
-  &:hover {
-    background-color: #ffffff3c;
+export const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
   }
+`;
+
+export const Polymer = styled.div`
+  margin-bottom: -6px;
 
   > i {
-    font-size: 18px;
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+export const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
   }
 `;

+ 29 - 14
dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx

@@ -2,6 +2,7 @@ import React from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import styled from "styled-components";
 import NewApp from "./NewApp";
+import NewEnvGroup from "./NewEnvGroup";
 import Overview from "./Overview";
 import SelectSource from "./SelectSource";
 import StacksLaunchContextProvider from "./Store";
@@ -12,20 +13,25 @@ const LaunchRoutes = () => {
   return (
     <LaunchContainer>
       <StacksLaunchContextProvider>
-        <Switch>
-          <Route path={`${path}/source`}>
-            <SelectSource />
-          </Route>
-          <Route path={`${path}/overview`}>
-            <Overview />
-          </Route>
-          <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
-            <NewApp />
-          </Route>
-          <Route path={`*`}>
-            <Redirect to={`${path}/source`} />
-          </Route>
-        </Switch>
+        <StyledLaunchFlow>
+          <Switch>
+            <Route path={`${path}/source`}>
+              <SelectSource />
+            </Route>
+            <Route path={`${path}/overview`}>
+              <Overview />
+            </Route>
+            <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
+              <NewApp />
+            </Route>
+            <Route path={`${path}/new-env-group`}>
+              <NewEnvGroup />
+            </Route>
+            <Route path={`*`}>
+              <Redirect to={`${path}/source`} />
+            </Route>
+          </Switch>
+        </StyledLaunchFlow>
       </StacksLaunchContextProvider>
     </LaunchContainer>
   );
@@ -37,3 +43,12 @@ const LaunchContainer = styled.div`
   margin: 0 auto;
   width: 100%;
 `;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  min-width: 300px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  margin-bottom: 50px;
+`;

+ 30 - 1
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -20,6 +20,17 @@ export type CreateStackBody = {
       dockerfile?: unknown;
     };
   }[];
+
+  env_groups: {
+    name: string;
+    variables: {
+      [key: string]: string;
+    };
+    secret_variables: {
+      [key: string]: string;
+    };
+    linked_applications: string[];
+  }[];
 };
 
 export type CreateStackResponse = Stack;
@@ -41,6 +52,7 @@ export type Stack = {
 export type FullStackRevision = StackRevision & {
   resources: AppResource[];
   source_configs: SourceConfig[];
+  env_groups: EnvGroup[];
 };
 
 export type StackRevision = {
@@ -48,7 +60,14 @@ export type StackRevision = {
   created_at: string;
   status: "deploying" | "deployed" | "failed"; // type with enum
   stack_id: string;
-  reason: "DeployError" | "SaveError" | "RollbackError";
+  reason:
+    | "DeployError"
+    | "SaveError"
+    | "RollbackError"
+    | "EnvGroupUpgrade"
+    | "ApplicationUpgrade"
+    | "SourceConfigUpgrade"
+    | "Rollback";
   message: string;
 };
 
@@ -89,3 +108,13 @@ export type AppResource = {
     template_version: string;
   };
 };
+
+export type EnvGroup = {
+  env_group_version: number;
+  updated_at: string;
+  stack_id: string;
+  name: string;
+  stack_revision_id: number;
+  created_at: string;
+  id: string;
+};

+ 2 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -189,6 +189,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
     setTimeout(() => {
       pushFiltered(props, dst, ["project_id"], {
         cluster: currentCluster.name,
+        namespace: selectedNamespace,
       });
     }, 500);
   };
@@ -355,6 +356,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
         props.currentTemplate.name === "job" ? "/jobs" : "/applications";
       pushFiltered(props, dst, ["project_id"], {
         cluster: currentCluster.name,
+        namespace: selectedNamespace,
       });
     }, 1000);
   };

+ 8 - 0
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -32,6 +32,7 @@ type PropsType = {
   syncedEnvGroups?: PopulatedEnvGroup[];
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
   normalEnvVarsOnly?: boolean;
+  availableEnvGroups?: PopulatedEnvGroup[];
 };
 
 type StateType = {
@@ -114,6 +115,13 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    if (Array.isArray(this.props.availableEnvGroups)) {
+      this.setState({
+        envGroups: this.props.availableEnvGroups,
+        loading: false,
+      });
+      return;
+    }
     this.updateEnvGroups();
   }
 

+ 3 - 4
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -8,9 +8,8 @@ import { ClusterType } from "shared/types";
 
 import Drawer from "./Drawer";
 import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
-import { NavLink } from "react-router-dom";
 import { Tooltip } from "@material-ui/core";
+import SidebarLink from "./SidebarLink";
 
 type PropsType = RouteComponentProps & {
   forceCloseDrawer: boolean;
@@ -173,7 +172,7 @@ class ClusterSection extends Component<PropsType, StateType> {
 
     if (clusters.length > 0) {
       return (
-        <ClusterSelector to="/cluster-dashboard">
+        <ClusterSelector path="/cluster-dashboard">
           <LinkWrapper>
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
@@ -339,7 +338,7 @@ const LinkWrapper = styled.div`
   width: 100%;
 `;
 
-const ClusterSelector = styled(NavLink)`
+const ClusterSelector = styled(SidebarLink)`
   position: relative;
   display: block;
   padding-left: 7px;

+ 13 - 46
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,7 +7,6 @@ import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import sliders from "assets/sliders.svg";
-import PullRequestIcon from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 
@@ -16,7 +15,7 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { NavLink } from "react-router-dom";
+import SidebarLink from "./SidebarLink";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -103,64 +102,34 @@ class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
-  /**
-   * Helper function that will keep the query params before redirect the user to a new page
-   *
-   * @param location
-   * @param path Path to redirect to
-   * @returns React router `to` object
-   */
-  withQueryParams = (location: any, path: string) => {
-    let { currentCluster, currentProject } = this.context;
-    let params = this.props.match.params as any;
-    let pathNamespace = params.namespace;
-    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-    if (!pathNamespace) {
-      pathNamespace = getQueryParam(this.props, "namespace");
-    }
-
-    if (pathNamespace) {
-      search = search.concat(`&namespace=${pathNamespace}`);
-    }
-
-    return {
-      ...location,
-      pathname: path,
-      search,
-    };
-  };
-
   renderClusterContent = () => {
     let { currentCluster, currentProject } = this.context;
 
     if (currentCluster) {
       return (
         <>
-          <NavButton
-            to={(location) => this.withQueryParams(location, "/applications")}
-          >
+          <NavButton path="/applications">
             <Img src={monoweb} />
             Applications
           </NavButton>
-          <NavButton to={() => this.withQueryParams(location, "/jobs")}>
+          <NavButton path="/jobs">
             <Img src={monojob} />
             Jobs
           </NavButton>
-          <NavButton to={() => this.withQueryParams(location, "/env-groups")}>
+          <NavButton path="/env-groups">
             <Img src={sliders} />
             Env Groups
           </NavButton>
           {currentCluster.service === "eks" &&
             currentCluster.infra_id > 0 &&
             currentProject.enable_rds_databases && (
-              <NavButton to={"/databases"}>
+              <NavButton path="/databases">
                 <Icon className="material-icons-outlined">storage</Icon>
                 Databases
               </NavButton>
             )}
           {currentProject?.preview_envs_enabled && (
-            <NavButton to="/preview-environments">
+            <NavButton path="/preview-environments">
               <InlineSVGWrapper
                 id="Flat"
                 fill="#FFFFFF"
@@ -194,9 +163,7 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
           )}
           {currentProject?.stacks_enabled ? (
-            <NavButton
-              to={(location) => this.withQueryParams(location, "/stacks")}
-            >
+            <NavButton path={"/stacks"}>
               <Icon className="material-icons-outlined">lan</Icon>
               Stacks
             </NavButton>
@@ -213,16 +180,16 @@ class Sidebar extends Component<PropsType, StateType> {
       return (
         <>
           <SidebarLabel>Home</SidebarLabel>
-          <NavButton to="/dashboard">
+          <NavButton path={"/dashboard"}>
             <Img src={category} />
             Dashboard
           </NavButton>
-          <NavButton to="/launch">
+          <NavButton path="/launch">
             <Img src={rocket} />
             Launch
           </NavButton>
           {currentProject && currentProject.managed_infra_enabled && (
-            <NavButton to={"/infrastructure"}>
+            <NavButton path={"/infrastructure"}>
               <i className="material-icons">build_circle</i>
               Infrastructure
             </NavButton>
@@ -233,7 +200,7 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton to="/integrations">
+            <NavButton path={"/integrations"}>
               <Img src={integrations} />
               Integrations
             </NavButton>
@@ -243,7 +210,7 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton to="/project-settings">
+            <NavButton path={"/project-settings"}>
               <Img enlarge={true} src={settings} />
               Settings
             </NavButton>
@@ -337,7 +304,7 @@ const ProjectPlaceholder = styled.div`
   }
 `;
 
-const NavButton = styled(NavLink)`
+const NavButton = styled(SidebarLink)`
   display: flex;
   align-items: center;
   position: relative;

+ 45 - 0
dashboard/src/main/home/sidebar/SidebarLink.tsx

@@ -0,0 +1,45 @@
+import React, { useContext } from "react";
+import { NavLink, NavLinkProps, useParams } from "react-router-dom";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+
+const SidebarLink: React.FC<{ path: string } & Omit<NavLinkProps, "to">> = ({
+  children,
+  path,
+  ...props
+}) => {
+  const params = useParams<{ namespace: string }>();
+  const { getQueryParam } = useRouting();
+  const { currentCluster, currentProject } = useContext(Context);
+
+  /**
+   * Helper function that will keep the query params before redirect the user to a new page
+   *
+   */
+  const withQueryParams = (path: string) => (location: any) => {
+    let pathNamespace = params.namespace;
+    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
+
+    if (!pathNamespace) {
+      pathNamespace = getQueryParam("namespace");
+    }
+
+    if (pathNamespace) {
+      search = search.concat(`&namespace=${pathNamespace}`);
+    }
+
+    return {
+      ...location,
+      pathname: path,
+      search,
+    };
+  };
+
+  return (
+    <NavLink to={withQueryParams(path)} {...props}>
+      {children}
+    </NavLink>
+  );
+};
+
+export default SidebarLink;

+ 14 - 4
dashboard/src/shared/api.tsx

@@ -96,6 +96,18 @@ const overwriteAWSIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/integrations/aws/overwrite`;
 });
 
+const updateClusterName = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
+});
+
 const createAzureIntegration = baseApi<
   {
     azure_client_id: string;
@@ -2074,10 +2086,7 @@ const updateStackSourceConfig = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
 );
 
-const getGithubStatus = baseApi<{}, {}>(
-  "GET",
-  ({}) => `/api/status/github`
-);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
@@ -2091,6 +2100,7 @@ export default {
   getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
+  updateClusterName,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,

+ 1 - 1
dashboard/src/shared/types.tsx

@@ -21,7 +21,7 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
-  is_stack: boolean;
+  stack_id: string;
   image_repo_uri: string;
   git_action_config: any;
   build_config: BuildConfig;

+ 0 - 1
internal/models/release.go

@@ -35,7 +35,6 @@ func (r *Release) ToReleaseType() *types.PorterRelease {
 		ID:           r.ID,
 		WebhookToken: r.WebhookToken,
 		ImageRepoURI: r.ImageRepoURI,
-		IsStack:      r.StackResourceID != 0,
 	}
 
 	if r.GitActionConfig != nil {

+ 39 - 0
internal/models/stack.go

@@ -62,6 +62,8 @@ type StackRevision struct {
 	Resources []StackResource
 
 	SourceConfigs []StackSourceConfig
+
+	EnvGroups []StackEnvGroup
 }
 
 func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
@@ -88,10 +90,17 @@ func (s StackRevision) ToStackRevisionType(stackID string) *types.StackRevision
 		resources = append(resources, *stackResource.ToStackResource(stackID, s.RevisionNumber, s.SourceConfigs))
 	}
 
+	envGroups := make([]types.StackEnvGroup, 0)
+
+	for _, stackEnvGroup := range s.EnvGroups {
+		envGroups = append(envGroups, *stackEnvGroup.ToStackEnvGroupType(stackID, s.RevisionNumber))
+	}
+
 	return &types.StackRevision{
 		StackRevisionMeta: &metaType,
 		SourceConfigs:     sourceConfigs,
 		Resources:         resources,
+		EnvGroups:         envGroups,
 		Reason:            s.Reason,
 		Message:           s.Message,
 	}
@@ -176,3 +185,33 @@ func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevision
 		ImageTag:        s.ImageTag,
 	}
 }
+
+type StackEnvGroup struct {
+	gorm.Model
+
+	StackRevisionID uint
+
+	Name string
+
+	Namespace string
+
+	ProjectID uint
+
+	ClusterID uint
+
+	UID string
+
+	EnvGroupVersion uint
+}
+
+func (s StackEnvGroup) ToStackEnvGroupType(stackID string, stackRevisionID uint) *types.StackEnvGroup {
+	return &types.StackEnvGroup{
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		EnvGroupVersion: s.EnvGroupVersion,
+	}
+}

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.StackRevision{},
 		&models.StackResource{},
 		&models.StackSourceConfig{},
+		&models.StackEnvGroup{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 15 - 3
internal/repository/gorm/stack.go

@@ -49,7 +49,7 @@ func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace str
 	// query for each stack's revision
 	revisions := make([]*models.StackRevision, 0)
 
-	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
+	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Preload("EnvGroups").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
 	stack_revisions.id IN (
 	  SELECT s2.id FROM (SELECT MAX(stack_revisions.id) id FROM stack_revisions WHERE stack_revisions.stack_id IN (?) GROUP BY stack_revisions.stack_id) s2
 	)
@@ -83,6 +83,7 @@ func (repo *StackRepository) ReadStackByID(projectID, stackID uint) (*models.Sta
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -100,6 +101,7 @@ func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string)
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -127,7 +129,7 @@ func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision)
 func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -137,7 +139,7 @@ func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.St
 func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -181,3 +183,13 @@ func (repo *StackRepository) UpdateStackResource(resource *models.StackResource)
 
 	return resource, nil
 }
+
+func (repo *StackRepository) ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error) {
+	envGroup := &models.StackEnvGroup{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND namespace = ? AND name = ?", projectID, clusterID, namespace, name).Order("id desc").First(&envGroup).Error; err != nil {
+		return nil, err
+	}
+
+	return envGroup, nil
+}

+ 2 - 0
internal/repository/stack.go

@@ -17,4 +17,6 @@ type StackRepository interface {
 
 	ReadStackResource(resourceID uint) (*models.StackResource, error)
 	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
+
+	ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error)
 }

+ 4 - 0
internal/repository/test/stack.go

@@ -58,3 +58,7 @@ func (repo *StackRepository) ReadStackResource(resourceID uint) (*models.StackRe
 func (repo *StackRepository) UpdateStackResource(resource *models.StackResource) (*models.StackResource, error) {
 	panic("unimplemented")
 }
+
+func (repo *StackRepository) ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error) {
+	panic("unimplemented")
+}

+ 23 - 0
internal/stacks/helpers.go

@@ -76,3 +76,26 @@ func CloneAppResources(
 
 	return res, nil
 }
+
+func CloneEnvGroups(envGroups []models.StackEnvGroup) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			UID:             uid,
+			Name:            envGroup.Name,
+			EnvGroupVersion: envGroup.EnvGroupVersion,
+			Namespace:       envGroup.Namespace,
+			ProjectID:       envGroup.ProjectID,
+			ClusterID:       envGroup.ClusterID,
+		})
+	}
+
+	return res, nil
+}

+ 121 - 0
internal/stacks/hooks.go

@@ -1,9 +1,11 @@
 package stacks
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -65,12 +67,131 @@ func UpdateHelmRevision(config *config.Config, projID, clusterID uint, rel *rele
 		}
 	}
 
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	stackRevision.Model = gorm.Model{}
+	stackRevision.RevisionNumber++
+	stackRevision.Resources = clonedAppResources
+	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Status = "deployed"
+	stackRevision.Reason = "ApplicationUpgrade"
+	stackRevision.Message = fmt.Sprintf("The application %s was updated from version %d to %d", rel.Name, rel.Version-1, rel.Version)
+
+	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
+
+	return err
+}
+
+func UpdateEnvGroupVersion(config *config.Config, projID, clusterID uint, envGroup *types.EnvGroup) error {
+	// read stack env group by params
+	stackEnvGroup, err := config.Repo.Stack().ReadStackEnvGroupFirstMatch(projID, clusterID, envGroup.Namespace, envGroup.Name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil
+		}
+
+		return err
+	}
+
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackEnvGroup.StackRevisionID)
+
+	if err != nil {
+		return err
+	}
+
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(stack.Revisions) == 0 {
+		return fmt.Errorf("length of stack revision list was 0")
+	}
+
+	currStackRevision := stack.Revisions[0]
+	stackRevision := &currStackRevision
+
+	clonedSourceConfigs, err := CloneSourceConfigs(stackRevision.SourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedAppResources, err := CloneAppResources(stackRevision.Resources, stackRevision.SourceConfigs, clonedSourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	for i, clonedEnvGroup := range clonedEnvGroups {
+		if clonedEnvGroup.Name == envGroup.Name {
+			clonedEnvGroups[i].EnvGroupVersion = envGroup.Version
+		}
+	}
+
+	// find all synced apps which should have an updated revision number
+	for i, clonedAppResource := range clonedAppResources {
+		for _, appName := range envGroup.Applications {
+			if clonedAppResource.Name == appName {
+				clonedAppResources[i].HelmRevisionID = clonedAppResource.HelmRevisionID + 1
+			}
+		}
+	}
+
 	stackRevision.Model = gorm.Model{}
 	stackRevision.RevisionNumber++
 	stackRevision.Resources = clonedAppResources
 	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Reason = "EnvGroupUpgrade"
+	stackRevision.Message = fmt.Sprintf("The environment group %s was updated from version %d to %d", envGroup.Name, envGroup.Version-1, envGroup.Version)
+	stackRevision.Status = "deployed"
 
 	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
 
 	return err
 }
+
+func GetStackForEnvGroup(config *config.Config, projID, clusterID uint, envGroup *types.EnvGroup) (string, error) {
+	// read stack env group by params
+	stackEnvGroup, err := config.Repo.Stack().ReadStackEnvGroupFirstMatch(projID, clusterID, envGroup.Namespace, envGroup.Name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return "", nil
+		}
+
+		return "", err
+	}
+
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackEnvGroup.StackRevisionID)
+
+	if err != nil {
+		return "", err
+	}
+
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return "", err
+	}
+
+	return stack.UID, nil
+}