Просмотр исходного кода

Merge pull request #2282 from porter-dev/nico/por-629-crud-operations-on-stack-resources

[POR-629] Create and Delete operations on stack resources
abelanger5 3 лет назад
Родитель
Сommit
3f86331b74
30 измененных файлов с 2323 добавлено и 623 удалено
  1. 192 0
      api/server/handlers/stack/add_application.go
  2. 152 0
      api/server/handlers/stack/add_env_group.go
  3. 16 7
      api/server/handlers/stack/create.go
  4. 132 0
      api/server/handlers/stack/remove_application.go
  5. 132 0
      api/server/handlers/stack/remove_env_group.go
  6. 283 1
      api/server/router/v1/stack.go
  7. 44 40
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  8. 25 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  9. 7 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  10. 3 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  11. 5 56
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  12. 29 41
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  13. 160 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx
  14. 157 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  15. 27 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx
  16. 66 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewEnvGroup.tsx
  17. 99 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  18. 22 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  19. 31 27
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  20. 63 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/routes.tsx
  21. 312 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx
  22. 165 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx
  23. 54 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  24. 20 250
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  25. 16 142
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  26. 1 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  27. 4 10
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  28. 15 8
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  29. 62 0
      dashboard/src/shared/api.tsx
  30. 29 10
      dashboard/src/shared/hooks/useChart.ts

+ 192 - 0
api/server/handlers/stack/add_application.go

@@ -0,0 +1,192 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"github.com/porter-dev/porter/api/server/shared"
+	"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/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackAddApplicationHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddApplicationHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddApplicationHandler {
+	return &StackAddApplicationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackAppResourceRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newResources, err := getResourceModels([]*types.CreateStackAppResourceRequest{req}, newSourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources = append(appResources, newResources...)
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range newResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    req,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// 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)
+
+	if err != nil {
+		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, " , ")
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else {
+		revision.Reason = "AddAppSuccess"
+		revision.Message = "New application " + req.Name + " added successfully."
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 152 - 0
api/server/handlers/stack/add_env_group.go

@@ -0,0 +1,152 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackAddEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddEnvGroupHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddEnvGroupHandler {
+	return &StackAddEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackEnvGroupRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newEnvGroups, err := getEnvGroupModels([]*types.CreateStackEnvGroupRequest{req}, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups = append(envGroups, newEnvGroups...)
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeployed),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroupDeployErrors := make([]string, 0)
+
+	cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+		Name:            req.Name,
+		Namespace:       namespace,
+		Variables:       req.Variables,
+		SecretVariables: req.SecretVariables,
+	})
+
+	if err != nil {
+		envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+	}
+
+	// add each of the linked applications to the env group
+	for _, appName := range req.LinkedApplications {
+		cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
+
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+		}
+	}
+
+	if len(envGroupDeployErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "AddEnvGroupSuccess"
+		revision.Message = "Env Group " + req.Name + " added successfully."
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 16 - 7
api/server/handlers/stack/create.go

@@ -46,13 +46,6 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// populate fields with defaults
-	for i, reqResource := range req.AppResources {
-		if reqResource.TemplateRepoURL == "" {
-			req.AppResources[i].TemplateRepoURL = p.Config().ServerConf.DefaultApplicationHelmRepoURL
-		}
-	}
-
 	uid, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
@@ -231,6 +224,18 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	if revision.Status != string(types.StackRevisionStatusFailed) && len(revision.Reason) == 0 {
+		revision.Reason = "CreationSuccess"
+		revision.Message = "Stack deployed successfully"
+
+		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
 	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
 
@@ -272,6 +277,10 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 	res := make([]models.StackResource, 0)
 
 	for _, appResource := range appResources {
+		if appResource.TemplateRepoURL == "" {
+			appResource.TemplateRepoURL = defaultRepoURL
+		}
+
 		uid, err := encryption.GenerateRandomBytes(16)
 
 		if err != nil {

+ 132 - 0
api/server/handlers/stack/remove_application.go

@@ -0,0 +1,132 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveApplicationHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveApplicationHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveApplicationHandler {
+	return &StackRemoveApplicationHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	appResourceName, reqErr := requestutils.GetURLParamString(r, "app_resource_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newResources []models.StackResource
+
+	for _, res := range appResources {
+		if res.Name != appResourceName {
+			newResources = append(newResources, res)
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      newResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = deleteAppResource(&deleteAppResourceOpts{
+		helmAgent: helmAgent,
+		name:      appResourceName,
+	})
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "RemoveAppSuccess"
+		revision.Message = "Application " + appResourceName + " removed successfully"
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveAppError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 132 - 0
api/server/handlers/stack/remove_env_group.go

@@ -0,0 +1,132 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"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"
+)
+
+type StackRemoveEnvGroupHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveEnvGroupHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveEnvGroupHandler {
+	return &StackRemoveEnvGroupHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envGroupName, reqErr := requestutils.GetURLParamString(r, "env_group_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newEnvGroups []models.StackEnvGroup
+	var envGroupNS string
+
+	for _, envGroup := range envGroups {
+		if envGroup.Name != envGroupName {
+			newEnvGroups = append(newEnvGroups, envGroup)
+		} else {
+			envGroupNS = envGroup.Namespace
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      newEnvGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = envgroup.DeleteEnvGroup(k8sAgent, envGroupName, envGroupNS)
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "RemoveEnvGroupSuccess"
+		revision.Message = "EnvGroup " + envGroupName + " removed successfully"
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveEnvGroupError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 283 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -65,6 +65,66 @@ type stackRevisionPathParams struct {
 	RevisionID string `json:"revision_id"`
 }
 
+// swagger:parameters removeApplication
+type stackRemoveApplicationPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the application
+	// in: path
+	// required: true
+	AppResourceName string `json:"app_resource_name"`
+}
+
+// swagger:parameters removeEnvGroup
+type stackRemoveEnvGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the environment group
+	// in: path
+	// required: true
+	EnvGroupName string `json:"env_group_name"`
+}
+
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1StackScopedRoutes,
@@ -538,5 +598,227 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application -> stack.NewStackAddApplicationHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application addApplication
+	//
+	// Adds an application to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an application to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddApplicationToStack
+	//     description: The application to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackAppResourceRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the application to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_application",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addApplicationHandler := stack.NewStackAddApplicationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addApplicationEndpoint,
+		Handler:  addApplicationHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} -> stack.NewStackRemoveApplicationHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} removeApplication
+	//
+	// Removes an existing application from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an application from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: app_resource_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the application from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_application/{app_resource_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeApplicationHandler := stack.NewStackRemoveApplicationHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeApplicationEndpoint,
+		Handler:  removeApplicationHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group -> stack.NewStackAddEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group addEnvGroup
+	//
+	// Adds an environment group to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an environment group to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddEnvGroupToStack
+	//     description: The environment group to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the environment group to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_env_group",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addEnvGroupHandler := stack.NewStackAddEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addEnvGroupEndpoint,
+		Handler:  addEnvGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} -> stack.NewStackRemoveEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} removeEnvGroup
+	//
+	// Removes an existing environment group from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an environment group from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: env_group_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the environment group from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_env_group/{env_group_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeEnvGroupHandler := stack.NewStackRemoveEnvGroupHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeEnvGroupEndpoint,
+		Handler:  removeEnvGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 44 - 40
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -161,23 +161,41 @@ export const ExpandedEnvGroupFC = ({
     });
   };
 
-  const handleDeleteEnvGroup = () => {
-    const { name } = currentEnvGroup;
+  const deleteEnvGroup = async () => {
+    const { name, stack_id } = currentEnvGroup;
 
-    setIsDeleting(true);
-    setCurrentOverlay(null);
-    api
-      .deleteEnvGroup(
-        "<token>",
+    if (stack_id?.length) {
+      return api.removeStackEnvGroup(
+        "<stack>",
+        {},
         {
-          name,
-        },
-        {
-          id: currentProject.id,
+          project_id: currentProject.id,
           cluster_id: currentCluster.id,
           namespace,
+          stack_id: stack_id,
+          env_group_name: name,
         }
-      )
+      );
+    }
+
+    return api.deleteEnvGroup(
+      "<token>",
+      {
+        name,
+      },
+      {
+        id: currentProject.id,
+        cluster_id: currentCluster.id,
+        namespace,
+      }
+    );
+  };
+
+  const handleDeleteEnvGroup = () => {
+    setIsDeleting(true);
+    setCurrentOverlay(null);
+
+    deleteEnvGroup()
       .then(() => {
         closeExpanded();
         setIsDeleting(true);
@@ -551,34 +569,20 @@ const EnvGroupSettings = ({
               applications to delete.
             </Helper>
           )}
-          {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>
-          )}
+
+          <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>

+ 25 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -644,16 +644,31 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
 
     try {
-      await api.uninstallTemplate(
-        "<token>",
-        {},
-        {
-          namespace: currentChart.namespace,
-          name: currentChart.name,
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
+      if (currentChart.stack_id) {
+        await api.removeStackAppResource(
+          "<token>",
+          {},
+          {
+            namespace: currentChart.namespace,
+            app_resource_name: currentChart.name,
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_id: currentChart.stack_id,
+          }
+        );
+      } else {
+        await api.uninstallTemplate(
+          "<token>",
+          {},
+          {
+            namespace: currentChart.namespace,
+            name: currentChart.name,
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        );
+      }
+
       props.closeChart();
     } catch (error) {
       console.log(error);

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

@@ -22,7 +22,6 @@ import useAuth from "shared/auth/useAuth";
 import ExpandedJobRun from "./jobs/ExpandedJobRun";
 import { useJobs } from "./jobs/useJobs";
 import { useChart } from "shared/hooks/useChart";
-import Modal from "main/home/modals/Modal";
 import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal";
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
@@ -258,7 +257,13 @@ export const ExpandedJobChartFC: React.FC<{
     }
 
     if (currentTab === "build-settings") {
-      return <BuildSettingsTab chart={chart} isPreviousVersion={disableForm} />;
+      return (
+        <BuildSettingsTab
+          chart={chart}
+          isPreviousVersion={disableForm}
+          onSave={refreshChart}
+        />
+      );
     }
 
     if (

+ 3 - 18
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -317,24 +317,9 @@ const SettingsSection: React.FC<PropsType> = ({
           )}
 
           <Heading>Additional Settings</Heading>
-          {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>
-          )}
+          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+            Delete {currentChart.name}
+          </Button>
         </StyledSettingsSection>
       ) : (
         <Loading />

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

@@ -7,6 +7,7 @@ import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
 import NamespaceSelector from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
+import { Action } from "./components/styles";
 import StackList from "./_StackList";
 const Dashboard = () => {
   const [currentNamespace, setCurrentNamespace] = useState("default");
@@ -38,11 +39,11 @@ const Dashboard = () => {
         title="Stacks"
         description="Groups of applications deployed from a shared source."
       />
-      <ActionRow>
-        <Button to={"/stacks/launch"}>
+      <Action.Row>
+        <Action.Button to={"/stacks/launch"}>
           <i className="material-icons">add</i>
           Create Stack
-        </Button>
+        </Action.Button>
         <FilterWrapper>
           <StyledSortSelector>
             <Label>
@@ -76,7 +77,7 @@ const Dashboard = () => {
             setNamespace={handleNamespaceChange}
           />
         </FilterWrapper>
-      </ActionRow>
+      </Action.Row>
       <StackList namespace={currentNamespace} sortBy={currentSort} />
     </>
   );
@@ -102,58 +103,6 @@ const StyledSortSelector = styled.div`
   margin-right: 30px;
 `;
 
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  min-width: 130px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ActionRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-`;
-
 const FilterWrapper = styled.div`
   display: flex;
 `;

+ 29 - 41
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -2,22 +2,21 @@ import Loading from "components/Loading";
 import Placeholder from "components/Placeholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
 import backArrow from "assets/back_arrow.png";
-import { useParams } from "react-router";
+import { useParams, useRouteMatch } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
 import { readableDate } from "shared/string_utils";
 import styled from "styled-components";
 import ChartList from "../../chart/ChartList";
-import SortSelector from "../../SortSelector";
 import Status from "../components/Status";
 import {
+  Action,
   Br,
   InfoWrapper,
   LastDeployed,
-  LineBreak,
   NamespaceTag,
   SepDot,
   Text,
@@ -29,50 +28,31 @@ import RevisionList from "./_RevisionList";
 import SourceConfig from "./_SourceConfig";
 import { NavLink } from "react-router-dom";
 import Settings from "./components/Settings";
+import { ExpandedStackStore } from "./Store";
+import DynamicLink from "components/DynamicLink";
 
 const ExpandedStack = () => {
-  const { namespace, stack_id } = useParams<{
+  const { namespace } = useParams<{
     namespace: string;
     stack_id: string;
   }>();
 
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+
   const { pushFiltered } = useRouting();
 
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
 
-  const [stack, setStack] = useState<Stack>();
-  const [isLoading, setIsLoading] = useState(true);
+  const { url } = useRouteMatch();
+
   const [isDeleting, setIsDeleting] = useState(false);
   const [currentTab, setCurrentTab] = useState("apps");
 
-  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
-
-  const getStack = async () => {
-    setIsLoading(true);
-    try {
-      const newStack = await api
-        .getStack<Stack>(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            stack_id: stack_id,
-            namespace,
-          }
-        )
-        .then((res) => res.data);
-
-      setStack(newStack);
-      setCurrentRevision(newStack.latest_revision);
-      setIsLoading(false);
-    } catch (error) {
-      setCurrentError(error);
-      pushFiltered("/stacks", []);
-    }
-  };
+  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>(
+    () => stack.latest_revision
+  );
 
   const handleDelete = () => {
     setIsDeleting(true);
@@ -96,12 +76,8 @@ const ExpandedStack = () => {
       });
   };
 
-  useEffect(() => {
-    getStack();
-  }, [stack_id]);
-
-  if (isLoading) {
-    return <Loading />;
+  if (stack === null) {
+    return null;
   }
 
   if (isDeleting) {
@@ -163,7 +139,7 @@ const ExpandedStack = () => {
         stackId={stack.id}
         stackNamespace={namespace}
         onRevisionClick={(revision) => setCurrentRevision(revision)}
-        onRollback={() => getStack()}
+        onRollback={() => refreshStack()}
       ></RevisionList>
       <Br />
       <TabSelector
@@ -175,6 +151,12 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
+                <Action.Row>
+                  <Action.Button to={`${url}/new-app-resource`}>
+                    <i className="material-icons">add</i>
+                    Create App Resource
+                  </Action.Button>
+                </Action.Row>
                 {currentRevision.id !== stack.latest_revision.id ? (
                   <ChartListWrapper>
                     <Placeholder>
@@ -209,7 +191,7 @@ const ExpandedStack = () => {
                   namespace={namespace}
                   revision={currentRevision}
                   readOnly={stack.latest_revision.id !== currentRevision.id}
-                  onSourceConfigUpdate={() => getStack()}
+                  onSourceConfigUpdate={() => refreshStack()}
                 ></SourceConfig>
               </>
             ),
@@ -220,6 +202,12 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
+                <Action.Row>
+                  <Action.Button to={`${url}/new-env-group`}>
+                    <i className="material-icons">add</i>
+                    Create Env Group
+                  </Action.Button>
+                </Action.Row>
                 <EnvGroups stack={stack} />
               </>
             ),

+ 160 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx

@@ -0,0 +1,160 @@
+import { AxiosError } from "axios";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewAppResourceForm from "../../components/NewAppResourceForm";
+import { CreateStackBody } from "../../types";
+import { ExpandedStackStore } from "../Store";
+
+const parsePopulatedEnvGroup = (envGroup: PopulatedEnvGroup) => {
+  const variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => !value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+  const secret_variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+
+  return {
+    name: envGroup.name,
+    variables,
+    secret_variables,
+    linked_applications: envGroup.applications as string[],
+  };
+};
+
+const Settings = () => {
+  const params = useParams<{
+    template_name: string;
+    template_version: string;
+  }>();
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [availableEnvGroups, setAvailableEnvGroups] = useState<
+    {
+      name: string;
+      variables: Record<string, string>;
+      secret_variables: Record<string, string>;
+      linked_applications: string[];
+    }[]
+  >([]);
+
+  const { pushFiltered } = useRouting();
+
+  const populateEnvGroups = async () => {
+    const stackEnvGroups = stack.latest_revision.env_groups;
+    const envGroupsPromises = stackEnvGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            namespace: stack.namespace,
+            version: envGroup.env_group_version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const response = await Promise.allSettled(envGroupsPromises);
+
+      const envGroups = response
+        .map((res) => {
+          if (res.status === "fulfilled") {
+            return res.value;
+          }
+          return undefined;
+        })
+        .filter(Boolean);
+
+      return envGroups;
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    populateEnvGroups().then((populatedEnvGroups) => {
+      if (!isSubscribed) {
+        return;
+      }
+
+      if (Array.isArray(populatedEnvGroups)) {
+        const availableEnvGroups = populatedEnvGroups.map(
+          parsePopulatedEnvGroup
+        );
+
+        setAvailableEnvGroups(availableEnvGroups);
+      }
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [stack, params, currentProject, currentCluster]);
+
+  const handleSubmit = async (
+    appResource: CreateStackBody["app_resources"][0]
+  ) => {
+    try {
+      await api.addStackAppResource(
+        "<token>",
+        {
+          ...appResource,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+
+      pushFiltered(`/stacks/${stack.namespace}/${stack.id}`, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+      if (axiosError.code === "409") {
+        throw "Application resource name already exists.";
+      }
+
+      throw "Unexpected error, please try again.";
+    }
+  };
+
+  return (
+    <NewAppResourceForm
+      availableEnvGroups={availableEnvGroups}
+      namespace={stack.namespace}
+      sourceConfig={stack.latest_revision.source_configs[0]}
+      templateInfo={{
+        name: params.template_name,
+        version: params.template_version,
+      }}
+      onCancel={() => {
+        pushFiltered(`../template-selector`, []);
+      }}
+      onSubmit={handleSubmit}
+    />
+  );
+};
+
+export default Settings;

+ 157 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -0,0 +1,157 @@
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import { BackButton, Card } from "../../launch/components/styles";
+import DynamicLink from "components/DynamicLink";
+import { VersionSelector } from "../../launch/components/VersionSelector";
+import TitleSection from "components/TitleSection";
+
+const TemplateSelector = () => {
+  const [templates, setTemplates] = useState<PorterTemplate[]>([]);
+  const [selectedVersion, setSelectedVersion] = useState<{
+    [template_name: string]: string;
+  }>({});
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+
+  const getTemplates = async () => {
+    try {
+      const res = await api.getTemplates<PorterTemplate[]>(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      );
+      let sortedVersionData = res.data
+        .map((template: PorterTemplate) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        })
+        .sort((a, b) => {
+          if (a.name < b.name) {
+            return -1;
+          }
+          if (a.name > b.name) {
+            return 1;
+          }
+          return 0;
+        });
+
+      return sortedVersionData;
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIsLoading(true);
+    getTemplates()
+      .then((porterTemplates) => {
+        const latestVersions = porterTemplates.reduce((acc, template) => {
+          return {
+            ...acc,
+            [template.name]: template.versions[0],
+          };
+        }, {} as Record<string, string>);
+
+        if (isSubscribed) {
+          setTemplates(porterTemplates);
+          setSelectedVersion(latestVersions);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (hasError) {
+    return (
+      <Placeholder>
+        <div>
+          <h2>Unexpected error</h2>
+          <p>
+            We had an error retrieving the available templates, please try
+            again.
+          </p>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <TitleSection>
+        <DynamicLink to={`../`}>
+          <BackButton>
+            <i className="material-icons">keyboard_backspace</i>
+          </BackButton>
+        </DynamicLink>
+        Select a template
+      </TitleSection>
+      <Card.Grid>
+        {templates.map((template) => {
+          return (
+            <Card.Wrapper
+              key={template.name}
+              as={DynamicLink}
+              to={`settings/${template.name}/${selectedVersion[template.name]}`}
+            >
+              <Card.Title>
+                New {template.name} with version:
+                <div
+                  onClickCapture={(e) => {
+                    e.preventDefault();
+                  }}
+                >
+                  <VersionSelector
+                    value={selectedVersion[template.name]}
+                    options={template.versions}
+                    onChange={(newVersion) => {
+                      setSelectedVersion((prev) => ({
+                        ...prev,
+                        [template.name]: newVersion,
+                      }));
+                    }}
+                  />
+                </div>
+              </Card.Title>
+              <Card.Actions>
+                <i className="material-icons-outlined">arrow_forward</i>
+              </Card.Actions>
+            </Card.Wrapper>
+          );
+        })}
+      </Card.Grid>
+    </>
+  );
+};
+
+export default TemplateSelector;

+ 27 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import Settings from "./_Settings";
+import TemplateSelector from "./_TemplateSelector";
+
+const NewAppResourceRoutes = () => {
+  const { url } = useRouteMatch();
+
+  return (
+    <Switch>
+      <Route path={`${url}/template-selector`}>
+        <TemplateSelector />
+      </Route>
+      <Route path={`${url}/settings/:template_name/:template_version`}>
+        <Settings />
+      </Route>
+      <Route path="/">
+        <Redirect to={`${url}/template-selector`} />
+      </Route>
+      <Route path="*">
+        <Redirect to={url} />
+      </Route>
+    </Switch>
+  );
+};
+
+export default NewAppResourceRoutes;

+ 66 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewEnvGroup.tsx

@@ -0,0 +1,66 @@
+import { AxiosError } from "axios";
+import React, { useContext } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewEnvGroupForm from "../components/NewEnvGroupForm";
+import { CreateStackBody } from "../types";
+import { ExpandedStackStore } from "./Store";
+
+const NewEnvGroup = () => {
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { pushFiltered } = useRouting();
+
+  const createEnvGroup = async (
+    newEnvGroup: CreateStackBody["env_groups"][0]
+  ) => {
+    try {
+      await api.addStackEnvGroup(
+        "<token>",
+        {
+          ...newEnvGroup,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+      pushFiltered("../" + stack.id, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+
+      if (axiosError.code === "404" || axiosError.code === "405") {
+        throw "New env group not implemented";
+      }
+
+      if (axiosError.code === "409") {
+        throw "Name is already in use";
+      }
+
+      if (error?.message) {
+        throw error.message;
+      }
+
+      throw error;
+    }
+  };
+
+  return (
+    <>
+      <NewEnvGroupForm
+        onSubmit={createEnvGroup}
+        onCancel={() => {
+          pushFiltered("../" + stack.id, []);
+        }}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;

+ 99 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx

@@ -0,0 +1,99 @@
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import type { Stack } from "../types";
+
+interface StoreType {
+  stack: Stack;
+  refreshStack: () => Promise<void>;
+}
+
+const defaultValues: StoreType = {
+  stack: {} as Stack,
+  refreshStack: async () => {},
+};
+
+export const ExpandedStackStore = createContext(defaultValues);
+
+const ExpandedStackStoreProvider: React.FC = ({ children }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const [stack, setStack] = useState<Stack>(null);
+  const [isLoading, setIsLoading] = useState(true);
+
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+  const { pushFiltered } = useRouting();
+
+  const getStack = async (props: { subscribed: boolean }) => {
+    setIsLoading(true);
+    api
+      .getStack<Stack>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace,
+          stack_id,
+        }
+      )
+      .then((res) => {
+        if (props.subscribed) {
+          setStack(res.data);
+        }
+      })
+      .catch(() => {
+        if (props.subscribed) {
+          setCurrentError("Couldn't find any stack with the given ID");
+          pushFiltered("/stacks", []);
+        }
+      })
+      .finally(() => {
+        if (props.subscribed) {
+          setIsLoading(false);
+        }
+      });
+  };
+
+  useEffect(() => {
+    let isSubscribed = { subscribed: true };
+
+    getStack(isSubscribed);
+
+    return () => {
+      isSubscribed.subscribed = false;
+    };
+  }, [currentCluster, currentProject, namespace, stack_id]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  return (
+    <ExpandedStackStore.Provider
+      value={{
+        stack,
+        refreshStack: async () => {
+          await getStack({ subscribed: true });
+        },
+      }}
+    >
+      {children}
+    </ExpandedStackStore.Provider>
+  );
+};
+
+export default ExpandedStackStoreProvider;

+ 22 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -112,6 +112,11 @@ const _RevisionList = ({
         >
           <Td>{revision.id}</Td>
           <Td>{readableDate(revision.created_at)}</Td>
+          <Td>
+            <RevisionStatusWrapper status={revision.status}>
+              {revision.status}
+            </RevisionStatusWrapper>
+          </Td>
           <Td>
             <RollbackButton
               disabled={isCurrent}
@@ -145,7 +150,7 @@ const _RevisionList = ({
             {currentRevision.id === latestRevision.id
               ? `Current Revision`
               : `Previewing Revision (Not Deployed)`}{" "}
-              - <Revision>No. {currentRevision.id}</Revision>
+            - <Revision>No. {currentRevision.id}</Revision>
             <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
         </RevisionHeader>
@@ -155,6 +160,7 @@ const _RevisionList = ({
               <Tr disableHover={true}>
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
+                <Th>Status</Th>
                 <Th>Rollback</Th>
               </Tr>
               {revisionList()}
@@ -297,3 +303,18 @@ const LoadingOverlay = styled.div`
   height: 100%;
   position: absolute;
 `;
+
+const RevisionStatusWrapper = styled.span<{ status: StackRevision["status"] }>`
+  text-transform: capitalize;
+  color: ${(props) => {
+    if (props.status === "deployed") {
+      return "#00b300";
+    }
+    if (props.status === "failed") {
+      return "#ff0000";
+    }
+    return "#ffffff";
+  }};
+  font-weight: 500;
+  font-size: 13px;
+`;

+ 31 - 27
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -7,6 +7,7 @@ import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
+import { useRouteMatch } from "react-router";
 
 type PopulatedEnvGroup = {
   applications: string[];
@@ -22,6 +23,7 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [envGroups, setEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+  const { url } = useRouteMatch();
 
   const getEnvGroups = async () => {
     const stackEnvGroups = stack.latest_revision.env_groups;
@@ -78,34 +80,36 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   }
 
   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.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>
+              <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>
+    </>
   );
 };
 

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

@@ -0,0 +1,63 @@
+import React from "react";
+import {
+  Redirect,
+  Route,
+  Switch,
+  useLocation,
+  useRouteMatch,
+} from "react-router";
+import styled from "styled-components";
+
+import ExpandedStack from "./ExpandedStack";
+import NewAppResourceRoutes from "./NewAppResource";
+import NewEnvGroup from "./NewEnvGroup";
+import ExpandedStackStoreProvider from "./Store";
+
+const ExpandedStackRoutes = () => {
+  const { path } = useRouteMatch();
+  const { pathname } = useLocation();
+
+  return (
+    <ExpandedStackStoreProvider>
+      <Switch>
+        <Redirect from="/:url*(/+)" to={pathname.slice(0, -1)} />
+        <Route path={`${path}/new-env-group`} exact>
+          <StyledLaunchFlow>
+            <LaunchContainer>
+              <NewEnvGroup />
+            </LaunchContainer>
+          </StyledLaunchFlow>
+        </Route>
+        <Route path={`${path}/new-app-resource`}>
+          <StyledLaunchFlow>
+            <LaunchContainer>
+              <NewAppResourceRoutes />
+            </LaunchContainer>
+          </StyledLaunchFlow>
+        </Route>
+        <Route path={`${path}`} exact>
+          <ExpandedStack />
+        </Route>
+        <Route path={`*`}>
+          <div>Not found</div>
+        </Route>
+      </Switch>
+    </ExpandedStackStoreProvider>
+  );
+};
+
+export default ExpandedStackRoutes;
+
+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;
+`;

+ 312 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx

@@ -0,0 +1,312 @@
+import Loading from "components/Loading";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import TitleSection from "components/TitleSection";
+import _ from "lodash";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import styled from "styled-components";
+import { BackButton, Icon, Polymer } from "../launch/components/styles";
+import { CreateStackBody, SourceConfig } from "../types";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import Heading from "components/form-components/Heading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+
+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 NewAppResourceForm = (props: {
+  templateInfo: {
+    name: string;
+    version: string;
+  };
+  namespace: string;
+  sourceConfig: Pick<
+    SourceConfig,
+    "build" | "image_repo_uri" | "image_tag" | "name"
+  >;
+  availableEnvGroups: CreateStackBody["env_groups"];
+  onSubmit: (
+    newApp: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const {
+    availableEnvGroups,
+    sourceConfig,
+    templateInfo,
+    namespace,
+    onCancel,
+    onSubmit,
+  } = props;
+
+  const { currentCluster } = useContext(Context);
+
+  const [hasError, setHasError] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
+  const [saveButtonStatus, setSaveButtonStatus] = useState("");
+
+  const [name, setName] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  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 = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    const stackSourceConfig = sourceConfig;
+    if (!stackSourceConfig) {
+      return;
+    }
+
+    let url = stackSourceConfig.image_repo_uri;
+    let tag = stackSourceConfig.image_tag;
+
+    if (url?.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (!_.isEmpty(stackSourceConfig.build)) {
+      if (template?.metadata?.name === "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
+      default:
+        provider = "";
+    }
+
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (url && tag) {
+      _.set(values, "image.repository", url);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (template?.metadata?.name == "job") {
+      _.set(values, "paused", true);
+    }
+
+    if (name === "") {
+      setSaveButtonStatus("App name cannot be empty");
+      return;
+    }
+    try {
+      await onSubmit(
+        {
+          name: name,
+          source_config_name: sourceConfig?.name || "",
+          template_name: templateInfo.name,
+          template_version: templateInfo.version,
+          values,
+        },
+        [...syncedEnvGroups]
+      );
+
+      setSaveButtonStatus("successful");
+      setTimeout(() => {
+        setSaveButtonStatus("");
+        setName("");
+        setTemplate(undefined);
+      }, 1000);
+    } catch (error) {
+      setSaveButtonStatus(error);
+      setTimeout(() => {
+        setSaveButtonStatus("");
+      }, 2000);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!templateInfo.name || !templateInfo.version) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setHasError(false);
+
+    api
+      .getTemplateInfo<ExpandedPorterTemplate>(
+        "<token>",
+        {},
+        { name: templateInfo.name, version: templateInfo.version }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setTemplate(res.data);
+        }
+      })
+      .catch((err) => {
+        setHasError(true);
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [templateInfo]);
+
+  if (isLoading) {
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+  return (
+    <>
+      <TitleSection>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <Polymer>
+          <Icon src={hardcodedIcons[template.metadata.name]} />
+        </Polymer>
+        Add{" "}
+        {template.metadata.name.charAt(0).toUpperCase() +
+          template.metadata.name.slice(1)}{" "}
+        to Stack
+      </TitleSection>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
+      <InputRow
+        type="string"
+        value={name}
+        setValue={(val: string) => setName(val)}
+        placeholder="ex: perspective-vortex"
+        width="470px"
+      />
+
+      <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"
+          valuesToOverride={{ namespace }}
+          injectedProps={{
+            "key-value-array": {
+              availableSyncEnvGroups: availableEnvGroups.map(
+                parseEnvGroup(namespace)
+              ),
+            },
+          }}
+          includeMetadata
+        />
+      </div>
+    </>
+  );
+};
+
+export default NewAppResourceForm;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Wrapper = styled.div`
+  margin-top: calc(50vh - 150px);
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

+ 165 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx

@@ -0,0 +1,165 @@
+import DynamicLink from "components/DynamicLink";
+import TitleSection from "components/TitleSection";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+import { BackButton, Polymer, SubmitButton } from "../launch/components/styles";
+import sliders from "assets/sliders.svg";
+import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
+import Heading from "components/form-components/Heading";
+import { isAlphanumeric } from "shared/common";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+
+const envArrayToObject = (variables: KeyValueType[]) => {
+  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
+    acc[curr.key] = curr.value;
+    return acc;
+  }, {});
+};
+
+type ProcessedEnvVariables = ReturnType<typeof envArrayToObject>;
+
+const NewEnvGroupForm = (props: {
+  onSubmit: (newEnvGroup: {
+    name: string;
+    variables: ProcessedEnvVariables;
+    secret_variables: ProcessedEnvVariables;
+  }) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const { onSubmit, onCancel } = props;
+
+  const [name, setName] = useState("");
+  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
+  const [submitError, setSubmitError] = useState("");
+
+  const handleOnSubmit = async () => {
+    const variables = envVariables.filter(
+      (variable) => !variable.locked && !variable.hidden
+    );
+    const secret_variables = envVariables.filter(
+      (variable) => variable.locked || variable.hidden
+    );
+
+    try {
+      await onSubmit({
+        name: name,
+        variables: envArrayToObject(variables),
+        secret_variables: envArrayToObject(secret_variables),
+      });
+    } catch (error) {
+      setSubmitError(error);
+      return;
+    }
+
+    setName("");
+    setEnvVariables([]);
+    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>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <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 || submitError || ""}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroupForm;
+
+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")};
+`;

+ 54 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts

@@ -1,3 +1,4 @@
+import DynamicLink from "components/DynamicLink";
 import styled from "styled-components";
 
 const StatusBase = styled.div`
@@ -129,3 +130,56 @@ export const NamespaceTag = {
     text-overflow: ellipsis;
   `,
 };
+
+export const Action = {
+  Row: styled.div`
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 35px;
+  `,
+  Button: styled(DynamicLink)`
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 13px;
+    cursor: pointer;
+    font-family: "Work Sans", sans-serif;
+    border-radius: 20px;
+    color: white;
+    height: 35px;
+    padding: 0px 8px;
+    min-width: 130px;
+    padding-bottom: 1px;
+    margin-right: 10px;
+    font-weight: 500;
+    padding-right: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    box-shadow: 0 5px 8px 0px #00000010;
+    cursor: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "not-allowed" : "pointer"};
+
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    :hover {
+      background: ${(props: { disabled?: boolean }) =>
+        props.disabled ? "" : "#505edddd"};
+    }
+
+    > i {
+      color: white;
+      width: 18px;
+      height: 18px;
+      font-weight: 600;
+      font-size: 12px;
+      border-radius: 20px;
+      display: flex;
+      align-items: center;
+      margin-right: 5px;
+      justify-content: center;
+    }
+  `,
+};

+ 20 - 250
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -1,276 +1,46 @@
-import Helper from "components/form-components/Helper";
-import InputRow from "components/form-components/InputRow";
-import Loading from "components/Loading";
-import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import _ from "lodash";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext } from "react";
 import { useParams } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import { ExpandedPorterTemplate } from "shared/types";
 import { StacksLaunchContext } from "./Store";
-import DynamicLink from "components/DynamicLink";
 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";
+import NewAppResourceForm from "../components/NewAppResourceForm";
 
 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, namespace } = useContext(
     StacksLaunchContext
   );
-  const { currentCluster } = useContext(Context);
 
   const params = useParams<{
     template_name: string;
     version: string;
   }>();
 
-  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [saveButtonStatus, setSaveButtonStatus] = useState("");
-
-  const [appName, setAppName] = useState("");
-
   const { pushFiltered } = useRouting();
 
-  useEffect(() => {
-    let isSubscribed = true;
-    if (!params.template_name || !params.version) {
-      return () => {
-        isSubscribed = false;
-      };
-    }
-
-    setHasError(false);
-
-    api
-      .getTemplateInfo<ExpandedPorterTemplate>(
-        "<token>",
-        {},
-        { name: params.template_name, version: params.version }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setTemplate(res.data);
-        }
-      })
-      .catch((err) => {
-        setHasError(true);
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [params]);
-
-  if (isLoading) {
-    return (
-      <Wrapper>
-        <Loading />
-      </Wrapper>
-    );
-  }
-
-  if (hasError) {
-    return <>Unexpected error</>;
-  }
-
-  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 = {};
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
-    }
-
-    const stackSourceConfig =
-      newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX];
-    if (!stackSourceConfig) {
-      return;
-    }
-
-    let url = stackSourceConfig.image_repo_uri;
-    let tag = stackSourceConfig.image_tag;
-
-    if (url?.includes(":")) {
-      let splits = url.split(":");
-      url = splits[0];
-      tag = splits[1];
-    } else if (!tag) {
-      tag = "latest";
-    }
-
-    if (!_.isEmpty(stackSourceConfig.build)) {
-      if (template?.metadata?.name === "job") {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
-        tag = "latest";
-      } else {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter";
-        tag = "latest";
-      }
-    }
-
-    let provider;
-    switch (currentCluster.service) {
-      case "eks":
-        provider = "aws";
-        break;
-      case "gke":
-        provider = "gcp";
-        break;
-      case "doks":
-        provider = "digitalocean";
-        break;
-      case "aks":
-        provider = "azure";
-        break;
-      case "vke":
-        provider = "vultr";
-        break;
-      default:
-        provider = "";
-    }
-
-    // Check the server URL to see if we can detect the cluster provider.
-    // There's no standard URL format for GCP that's why it's not currently included
-    if (provider === "") {
-      const server = currentCluster.server;
-
-      if (server.includes("eks")) provider = "eks";
-      else if (server.includes("ondigitalocean")) provider = "digitalocean";
-      else if (server.includes("azmk8s")) provider = "azure";
-      else if (server.includes("vultr")) provider = "vultr";
-    }
-
-    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
-    if (url && tag) {
-      _.set(values, "image.repository", url);
-      _.set(values, "image.tag", tag);
-    }
-
-    _.set(values, "ingress.provider", provider);
-
-    // pause jobs automatically
-    if (template?.metadata?.name == "job") {
-      _.set(values, "paused", true);
-    }
-
-    if (appName === "") {
-      setSaveButtonStatus("App name cannot be empty");
-      return;
-    }
-
-    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(() => {
-      setSaveButtonStatus("");
-      pushFiltered("/stacks/launch/overview", []);
-    }, 1000);
-  };
-
   return (
     <>
-      <TitleSection>
-        <DynamicLink to={`/stacks/launch/overview`}>
-          <BackButton>
-            <i className="material-icons">keyboard_backspace</i>
-          </BackButton>
-        </DynamicLink>
-        <Polymer>
-          <Icon src={hardcodedIcons[template.metadata.name]} />
-        </Polymer>
-        Add{" "}
-        {template.metadata.name.charAt(0).toUpperCase() +
-          template.metadata.name.slice(1)}{" "}
-        to Stack
-      </TitleSection>
-      <Heading>
-        Application Name <Required>*</Required>
-      </Heading>
-      <InputRow
-        type="string"
-        value={appName}
-        setValue={(val: string) => setAppName(val)}
-        placeholder="ex: perspective-vortex"
-        width="470px"
+      <NewAppResourceForm
+        sourceConfig={
+          newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX]
+        }
+        availableEnvGroups={newStack.env_groups}
+        namespace={namespace}
+        templateInfo={{
+          name: params.template_name,
+          version: params.version,
+        }}
+        onSubmit={async (newApp, syncedEnvGroups) => {
+          addAppResource(newApp, syncedEnvGroups);
+          pushFiltered("/stacks/launch/overview", []);
+          return;
+        }}
+        onCancel={() => {
+          pushFiltered("/stacks/launch/overview", []);
+        }}
       />
-
-      <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"
-          valuesToOverride={{ namespace }}
-          injectedProps={{
-            "key-value-array": {
-              availableSyncEnvGroups: newStack.env_groups.map(
-                parseEnvGroup(namespace)
-              ),
-            },
-          }}
-          includeMetadata
-        />
-      </div>
     </>
   );
 };

+ 16 - 142
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx

@@ -1,156 +1,30 @@
-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 React, { useContext } from "react";
 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 NewEnvGroupForm from "../components/NewEnvGroupForm";
 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 || ""}
-      />
-    </>
+    <NewEnvGroupForm
+      onSubmit={async (newEnvGroup) => {
+        addEnvGroup({
+          ...newEnvGroup,
+          linked_applications: [],
+        });
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+      onCancel={() => {
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+    />
   );
 };
 
 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")};
-`;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -157,6 +157,7 @@ export const SelectorStyles = {
     width: 100%;
     max-height: 200px;
     overflow-y: auto;
+    z-index: 999;
   `,
   Option: styled.div`
     min-height: 35px;

+ 4 - 10
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -1,14 +1,8 @@
 import React, { useContext } from "react";
-import {
-  Redirect,
-  Route,
-  Switch,
-  useLocation,
-  useRouteMatch,
-} from "react-router";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import Dashboard from "./Dashboard";
-import ExpandedStack from "./ExpandedStack/ExpandedStack";
+import ExpandedStackRoutes from "./ExpandedStack/routes";
 import LaunchRoutes from "./launch";
 
 const routes = () => {
@@ -25,9 +19,9 @@ const routes = () => {
         <LaunchRoutes />
       </Route>
       <Route path={`${path}/:namespace/:stack_id`}>
-        <ExpandedStack />
+        <ExpandedStackRoutes />
       </Route>
-      <Route path={`${path}/`} exact>
+      <Route path={`${path}`} exact>
         <Dashboard />
       </Route>
       <Route path={`*`}>

+ 15 - 8
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -55,19 +55,26 @@ export type FullStackRevision = StackRevision & {
   env_groups: EnvGroup[];
 };
 
+type StackRevisionReason =
+  | "DeployError"
+  | "SaveError"
+  | "RollbackError"
+  | "EnvGroupUpgrade"
+  | "ApplicationUpgrade"
+  | "SourceConfigUpgrade"
+  | "Rollback"
+  | "CreationSuccess"
+  | "AddEnvGroupSuccess"
+  | "AddAppSuccess"
+  | "RemoveEnvGroupSuccess"
+  | "RemoveAppSuccess";
+
 export type StackRevision = {
   id: number;
   created_at: string;
   status: "deploying" | "deployed" | "failed"; // type with enum
   stack_id: string;
-  reason:
-    | "DeployError"
-    | "SaveError"
-    | "RollbackError"
-    | "EnvGroupUpgrade"
-    | "ApplicationUpgrade"
-    | "SourceConfigUpgrade"
-    | "Rollback";
+  reason: StackRevisionReason;
   message: string;
 };
 

+ 62 - 0
dashboard/src/shared/api.tsx

@@ -2087,6 +2087,64 @@ const updateStackSourceConfig = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
 );
 
+const addStackAppResource = baseApi<
+  CreateStackBody["app_resources"][0],
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/add_application`
+);
+
+const removeStackAppResource = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    app_resource_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, app_resource_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_application/${app_resource_name}`
+);
+
+const addStackEnvGroup = baseApi<
+  CreateStackBody["env_groups"][0],
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/add_env_group`
+);
+
+const removeStackEnvGroup = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    env_group_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, env_group_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
+);
+
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -2284,6 +2342,10 @@ export default {
   rollbackStack,
   deleteStack,
   updateStackSourceConfig,
+  addStackAppResource,
+  removeStackAppResource,
+  addStackEnvGroup,
+  removeStackEnvGroup,
 
   // STATUS
   getGithubStatus,

+ 29 - 10
dashboard/src/shared/hooks/useChart.ts

@@ -96,6 +96,33 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
     }
   };
 
+  const uninstallChart = async () => {
+    if (chart.stack_id) {
+      await api.removeStackAppResource(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          app_resource_name: chart.name,
+          namespace: chart.namespace,
+          stack_id: chart.stack_id,
+        }
+      );
+    } else {
+      await api.uninstallTemplate(
+        "<token>",
+        {},
+        {
+          namespace: chart.namespace,
+          name: chart.name,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+    }
+  };
+
   /**
    * Delete/Uninstall chart
    */
@@ -128,16 +155,8 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
         return;
       }
 
-      await api.uninstallTemplate(
-        "<token>",
-        {},
-        {
-          namespace: chart.namespace,
-          name: chart.name,
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
+      await uninstallChart();
+
       setStatus("ready");
       closeChart();
       return;