Bläddra i källkod

Global env vars (#3216)

* changes

* Env Group

* Changes

* Changes

* Env Update

* Changes

* Testing Schema

* Env Update

* Env Vars

* Env Vars

* Update Backend

* Update Porter

* Update PorterForm.tsx

* Update ProvisionerSettings.tsx

* Update ProvisionerSettings.tsx

* Update ProvisionerSettings.tsx

* Update ProvisionerSettings.tsx

* Global Env

* Update and Fix Bugs

* Update and Fix Bugs

* OverRide Behavior

* OverRide Behavior

* EnvGroup Array

* UI clean up

* Final Changes

* Update add_env_group_app.go

Remove Prints

* Update create_env_group.go

* Update add_env_group_app.go

* Update add_env_group_app.go

* Update create_env_group.go

* Remove Whitespace

* File Resets

* Final Changes

* Final Changes

* Final Changes

* Final Changes

* case on simplified view, not capi provisioner enabled

* Fix Bug

* Fix Bug

* Fix Bug

* Fix Delete

* Fix Delete

* Fix Delete

* Fix Delete

* Changes

* Changes

* Changes

* Changes

* Changes

* Changes

* Add Env Vars

* ChangeLog Updates

* dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx

* Add on Creation

* Add on Creation

* Add on Creation

---------

Co-authored-by: Justin Rhee <jusrhee@Justins-MacBook-Air.local>
sdess09 2 år sedan
förälder
incheckning
a7027af873
27 ändrade filer med 2846 tillägg och 267 borttagningar
  1. 488 0
      api/server/handlers/namespace/create_stacks_env_group.go
  2. 76 9
      api/server/handlers/porter_app/create.go
  3. 145 13
      api/server/handlers/porter_app/parse.go
  4. 29 0
      api/server/router/namespace.go
  5. 18 0
      api/types/namespace.go
  6. 2 0
      api/types/porter_app.go
  7. 0 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  8. 11 11
      dashboard/src/components/porter-form/types.ts
  9. 157 0
      dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx
  10. 13 104
      dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogModal.tsx
  11. 337 4
      dashboard/src/main/home/app-dashboard/expanded-app/EnvVariablesTab.tsx
  12. 125 8
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  13. 356 0
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvGroupModal.tsx
  14. 267 0
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/ExpandableEnvGroup.tsx
  15. 157 1
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  16. 3 3
      dashboard/src/main/home/cluster-dashboard/DashboardRouter.tsx
  17. 28 27
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  18. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  19. 3 3
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  20. 402 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks.tsx
  21. 6 6
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  22. 122 37
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  23. 7 5
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroupDashboard.tsx
  24. 6 1
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  25. 43 31
      dashboard/src/main/home/sidebar/Sidebar.tsx
  26. 20 1
      dashboard/src/shared/api.tsx
  27. 23 0
      internal/kubernetes/envgroup/create.go

+ 488 - 0
api/server/handlers/namespace/create_stacks_env_group.go

@@ -0,0 +1,488 @@
+package namespace
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync"
+
+	"sigs.k8s.io/yaml"
+
+	"github.com/google/uuid"
+	"github.com/stefanmcshane/helm/pkg/release"
+
+	"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/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"github.com/stefanmcshane/helm/pkg/chart"
+)
+
+type CreateStacksEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateStacksEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateStacksEnvGroupHandler {
+	return &CreateStacksEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateStacksEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CreateStacksEnvGroupRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 504, "error getting agent"))
+		return
+	}
+	// if the environment group exists and has MetaVersion=1, throw an error
+
+	aggregateReleases := []*release.Release{}
+	for i := range request.Apps {
+		namespaceStack := "porter-stack-" + request.Apps[i]
+		helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, namespaceStack)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 504, "error getting agent"))
+			return
+		}
+		releases, err := envgroup.GetStackSyncedReleases(helmAgent, namespaceStack)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 504, "error getting releases"))
+			return
+		}
+
+		aggregateReleases = append(aggregateReleases, releases...)
+	}
+
+	errors := rolloutStacksApplications(c, c.Config(), cluster, request.Name, namespace, agent, aggregateReleases, r, w)
+
+	if len(errors) > 0 {
+		errStrArr := make([]string, 0)
+
+		for _, err := range errors {
+			errStrArr = append(errStrArr, err.Error())
+		}
+
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrPassThroughToClient(err, 504, "error getting adding env group"))
+		return
+	}
+	c.WriteResult(w, r, nil)
+}
+
+func rolloutStacksApplications(
+	c *CreateStacksEnvGroupHandler,
+	config *config.Config,
+	cluster *models.Cluster,
+	envGroupName string,
+	namespace string,
+	agent *kubernetes.Agent,
+	releases []*release.Release,
+	r *http.Request,
+	w http.ResponseWriter,
+) []error {
+	registries, err := config.Repo.Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	if err != nil {
+		return []error{err}
+	}
+	// asynchronously update releases with that image repo uri
+	var wg sync.WaitGroup
+	mu := &sync.Mutex{}
+	errors := make([]error, 0)
+
+	for i, rel := range releases {
+		index := i
+		release := rel
+		wg.Add(1)
+		cm, _, err := agent.GetLatestVersionedConfigMap(envGroupName, "porter-stack-"+releases[index].Name)
+		if err != nil {
+			return []error{err}
+		}
+
+		versionStr, ok := cm.ObjectMeta.Labels["version"]
+		if !ok {
+			return []error{err}
+		}
+		versionInt, err := strconv.Atoi(versionStr)
+		if err != nil {
+			return []error{err}
+		}
+
+		version := uint(versionInt)
+		newSection := &SyncedEnvSection{
+			Name:    envGroupName,
+			Version: version,
+		}
+
+		newSectionKeys := make([]SyncedEnvSectionKey, 0)
+
+		for key, val := range cm.Data {
+			newSectionKeys = append(newSectionKeys, SyncedEnvSectionKey{
+				Name:   key,
+				Secret: strings.Contains(val, "PORTERSECRET"),
+			})
+		}
+
+		newSection.Keys = newSectionKeys
+
+		go func() {
+			defer wg.Done()
+			// read release via agent
+			newConfig, err := getNewStacksConfig(release.Config, newSection, release)
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+			// if this is a job chart, update the config and set correct paused param to true
+			if release.Chart.Name() == "job" {
+				newConfig["paused"] = true
+			}
+
+			if req := releases[index].Chart.Metadata.Dependencies; req != nil {
+				for _, dep := range req {
+					dep.Name = getType(dep.Name)
+				}
+			}
+
+			metadata := &chart.Metadata{
+				Name:        "umbrella",
+				Description: "Web application that is exposed to external traffic.",
+				Version:     "0.96.0",
+				APIVersion:  "v2",
+				Home:        "https://getporter.dev/",
+				Icon:        "https://user-images.githubusercontent.com/65516095/111255214-07d3da80-85ed-11eb-99e2-fddcbdb99bdb.png",
+				Keywords: []string{
+					"porter",
+					"application",
+					"service",
+					"umbrella",
+				},
+				Type:         "application",
+				Dependencies: releases[index].Chart.Metadata.Dependencies,
+			}
+			charter := &chart.Chart{
+				Metadata: metadata,
+			}
+			conf := &helm.InstallChartConfig{
+				Chart:      charter,
+				Name:       releases[index].Name,
+				Namespace:  "porter-stack-" + releases[index].Name,
+				Values:     newConfig,
+				Cluster:    cluster,
+				Repo:       config.Repo,
+				Registries: registries,
+			}
+			helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, "porter-stack-"+releases[index].Name)
+			if err != nil {
+				fmt.Println("Could Not Get Helm Agent ")
+				return
+			}
+			_, err = helmAgent.UpgradeInstallChart(r.Context(), conf, config.DOConf, config.ServerConf.DisablePullSecretsInjection)
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+		}()
+
+		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, releases[index].Name)
+		ctx, span := telemetry.NewSpan(r.Context(), "serve-create-porter-app")
+		updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error writing updated app to DB")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return []error{err}
+		}
+		imageInfo := attemptToGetImageInfoFromRelease(releases[i].Config)
+		_, err = createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, releases[i].Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating porter app event")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return []error{err}
+		}
+
+	}
+
+	wg.Wait()
+
+	return errors
+}
+
+func getNewStacksConfig(curr map[string]interface{}, syncedEnvSection *SyncedEnvSection, release *release.Release) (map[string]interface{}, error) {
+	// look for container.env.synced
+	aggEnvConf := make(map[string]interface{})
+
+	for _, dep := range release.Chart.Metadata.Dependencies {
+		envConf, err := getStacksNestedMap(curr, dep.Name, "container", "env")
+
+		normalKeys, ok := envConf["normal"].(map[string]interface{})
+		if !ok {
+			fmt.Println("Normal Keys", normalKeys)
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		for k, v := range envConf {
+			aggEnvConf[k] = v
+		}
+
+		syncedEnvInter, syncedEnvExists := envConf["synced"]
+
+		if !syncedEnvExists {
+			return curr, nil
+		} else {
+			syncedArr := make([]*SyncedEnvSection, 0)
+			syncedArrInter, ok := syncedEnvInter.([]interface{})
+
+			if !ok {
+				return nil, fmt.Errorf("could not convert to synced env section: not an array")
+			}
+
+			for _, syncedArrInterObj := range syncedArrInter {
+				syncedArrObj := &SyncedEnvSection{}
+				syncedArrInterObjMap, ok := syncedArrInterObj.(map[string]interface{})
+
+				if !ok {
+					continue
+				}
+
+				if nameField, nameFieldExists := syncedArrInterObjMap["name"]; nameFieldExists {
+					syncedArrObj.Name, ok = nameField.(string)
+
+					if !ok {
+						continue
+					}
+				}
+
+				if versionField, versionFieldExists := syncedArrInterObjMap["version"]; versionFieldExists {
+					versionFloat, ok := versionField.(float64)
+
+					if !ok {
+						continue
+					}
+
+					syncedArrObj.Version = uint(versionFloat)
+				}
+
+				if keyField, keyFieldExists := syncedArrInterObjMap["keys"]; keyFieldExists {
+					keyFieldInterArr, ok := keyField.([]interface{})
+
+					if !ok {
+						continue
+					}
+
+					keyFieldMapArr := make([]map[string]interface{}, 0)
+
+					for _, keyFieldInter := range keyFieldInterArr {
+						mapConv, ok := keyFieldInter.(map[string]interface{})
+						if !ok {
+							continue
+						}
+
+						// check if mapConv["name"] is in normalKeys
+						keyName, ok := mapConv["name"].(string) // check if "name" key exists and is a string
+						if !ok {
+							continue
+						}
+						if _, exists := normalKeys[keyName]; !exists {
+							keyFieldMapArr = append(keyFieldMapArr, mapConv)
+						}
+
+					}
+
+					keyFieldRes := make([]SyncedEnvSectionKey, 0)
+
+					for _, keyFieldMap := range keyFieldMapArr {
+						toAdd := SyncedEnvSectionKey{}
+
+						if nameField, nameFieldExists := keyFieldMap["name"]; nameFieldExists {
+							toAdd.Name, ok = nameField.(string)
+
+							if !ok {
+								continue
+							}
+
+							// only append if not in aggEnvConf
+							if _, exists := aggEnvConf[toAdd.Name]; !exists {
+								if secretField, secretFieldExists := keyFieldMap["secret"]; secretFieldExists {
+									toAdd.Secret, ok = secretField.(bool)
+
+									if !ok {
+										continue
+									}
+								}
+
+								keyFieldRes = append(keyFieldRes, toAdd)
+							}
+						}
+					}
+					syncedArrObj.Keys = keyFieldRes
+				}
+
+				syncedArr = append(syncedArr, syncedArrObj)
+			}
+
+			resArr := make([]SyncedEnvSection, 0)
+			foundMatch := false
+
+			for _, candidate := range syncedArr {
+				if candidate.Name == syncedEnvSection.Name {
+					resArr = append(resArr, *filterEnvConf(syncedEnvSection, normalKeys))
+					foundMatch = true
+				} else {
+					resArr = append(resArr, *candidate)
+				}
+			}
+
+			if !foundMatch {
+				return curr, nil
+			}
+			envConf["synced"] = resArr
+		}
+	}
+
+	// to remove all types that Helm may not be able to work with, we marshal to and from
+	// yaml for good measure. Otherwise we get silly error messages like:
+	// Upgrade failed: template: web/templates/deployment.yaml:138:40: executing \"web/templates/deployment.yaml\"
+	// at <$syncedEnv.keys>: can't evaluate field keys in type namespace.SyncedEnvSection
+	currYAML, err := yaml.Marshal(curr)
+	if err != nil {
+		return nil, err
+	}
+
+	res := make(map[string]interface{})
+
+	err = yaml.Unmarshal([]byte(currYAML), &res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func getStacksNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+	for _, field := range fields {
+		objField, ok := curr[field]
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}
+
+func filterEnvConf(syncedEnv *SyncedEnvSection, normalEnv map[string]interface{}) *SyncedEnvSection {
+	// filter out keys that are already in normalEnv
+	keys := make([]SyncedEnvSectionKey, 0)
+
+	for _, key := range syncedEnv.Keys {
+		if _, exists := normalEnv[key.Name]; !exists {
+			keys = append(keys, key)
+		}
+	}
+
+	syncedEnv.Keys = keys
+
+	return syncedEnv
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+// func postStacksUpgrade(config *config.Config, projectID, clusterID uint, envGroup *types.EnvGroup) error {
+// 	// update the relevant env group version number if tied to a stack resource
+// 	return stacks.UpdateEnvGroupVersion(config, projectID, clusterID, envGroup)
+// }
+
+func getType(name string) string {
+	if strings.HasSuffix(name, "-web") {
+		return "web"
+	} else if strings.HasSuffix(name, "-wkr") {
+		return "worker"
+	} else if strings.HasSuffix(name, "-job") {
+		return "job"
+	}
+	return ""
+}
+
+func createPorterAppEvent(ctx context.Context, status string, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+	event := models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Status:             status,
+		Type:               "DEPLOY",
+		TypeExternalSource: "KUBERNETES",
+		PorterAppID:        appID,
+		Metadata: map[string]any{
+			"revision":  revision,
+			"image_tag": tag,
+		},
+	}
+
+	err := repo.CreateEvent(ctx, &event)
+	if err != nil {
+		return nil, err
+	}
+
+	if event.ID == uuid.Nil {
+		return nil, err
+	}
+
+	return &event, nil
+}
+
+func attemptToGetImageInfoFromRelease(values map[string]interface{}) types.ImageInfo {
+	imageInfo := types.ImageInfo{}
+
+	if values == nil {
+		return imageInfo
+	}
+
+	globalImage, err := getNestedMap(values, "global", "image")
+	if err != nil {
+		return imageInfo
+	}
+
+	repoVal, okRepo := globalImage["repository"]
+	tagVal, okTag := globalImage["tag"]
+	if okRepo && okTag {
+		imageInfo.Repository = repoVal.(string)
+		imageInfo.Tag = tagVal.(string)
+	}
+
+	return imageInfo
+}

+ 76 - 9
api/server/handlers/porter_app/create.go

@@ -3,11 +3,14 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
 
 	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -125,11 +128,24 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		strings.Contains(request.Builder, "paketo")
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "builder", Value: request.Builder})
 
+	if shouldCreate {
+		// create the namespace if it does not exist already
+		_, err = k8sAgent.CreateNamespace(namespace, nil)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating namespace")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		cloneEnvGroup(c, w, r, k8sAgent, request.EnvGroups, namespace)
+	}
 	chart, values, releaseJobValues, err := parse(
 		porterYaml,
 		imageInfo,
 		c.Config(),
 		cluster.ProjectID,
+		request.UserUpdate,
+		request.EnvGroups,
+		namespace,
 		releaseValues,
 		releaseDependencies,
 		SubdomainCreateOpts{
@@ -151,14 +167,6 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	if shouldCreate {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-application", Value: true})
 
-		// create the namespace if it does not exist already
-		_, err = k8sAgent.CreateNamespace(namespace, nil)
-		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating namespace")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
-
 		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
 		if request.OverrideRelease && releaseJobValues != nil {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-pre-deploy-job", Value: true})
@@ -346,7 +354,6 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			Repo:       c.Repo(),
 			Registries: registries,
 		}
-
 		// update the chart
 		_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {
@@ -489,3 +496,63 @@ func createReleaseJobChart(
 		Registries: registries,
 	}, nil
 }
+
+func cloneEnvGroup(c *CreatePorterAppHandler, w http.ResponseWriter, r *http.Request, agent *kubernetes.Agent, envGroups []string, namespace string) {
+	for _, envGroupName := range envGroups {
+		cm, _, err := agent.GetLatestVersionedConfigMap(envGroupName, "default")
+		if err != nil {
+			if errors.Is(err, kubernetes.IsNotFoundError) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", envGroupName, "default"), http.StatusNotFound,
+					"no config map found for envgroup",
+				))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+		secret, _, err := agent.GetLatestVersionedSecret(envGroupName, "default")
+		if err != nil {
+			if errors.Is(err, kubernetes.IsNotFoundError) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", envGroupName, "default"), http.StatusNotFound,
+					"no k8s secret found for envgroup",
+				))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+		vars := make(map[string]string)
+		secretVars := make(map[string]string)
+
+		for key, val := range cm.Data {
+			if !strings.Contains(val, "PORTERSECRET") {
+				vars[key] = val
+			}
+		}
+
+		for key, val := range secret.Data {
+			secretVars[key] = string(val)
+		}
+
+		configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
+			Name:            envGroupName,
+			Namespace:       namespace,
+			Variables:       vars,
+			SecretVariables: secretVars,
+		})
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		_, err = envgroup.ToEnvGroup(configMap)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 145 - 13
api/server/handlers/porter_app/parse.go

@@ -19,11 +19,12 @@ import (
 )
 
 type PorterStackYAML struct {
-	Version *string           `yaml:"version"`
-	Build   *Build            `yaml:"build"`
-	Env     map[string]string `yaml:"env"`
-	Apps    map[string]*App   `yaml:"apps"`
-	Release *App              `yaml:"release"`
+	Version   *string             `yaml:"version"`
+	Build     *Build              `yaml:"build"`
+	Env       map[string]string   `yaml:"env"`
+	SyncedEnv []*SyncedEnvSection `yaml:"synced_env"`
+	Apps      map[string]*App     `yaml:"apps"`
+	Release   *App                `yaml:"release"`
 }
 
 type Build struct {
@@ -49,11 +50,25 @@ type SubdomainCreateOpts struct {
 	stackName      string
 }
 
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
 func parse(
 	porterYaml []byte,
 	imageInfo types.ImageInfo,
 	config *config.Config,
 	projectID uint,
+	userUpdate bool,
+	envGroups []string,
+	namespace string,
 	existingValues map[string]interface{},
 	existingDependencies []*chart.Dependency,
 	opts SubdomainCreateOpts,
@@ -66,8 +81,46 @@ func parse(
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
 	}
+	synced_env := make([]*SyncedEnvSection, 0)
+
+	for i := range envGroups {
+		cm, _, err := opts.k8sAgent.GetLatestVersionedConfigMap(envGroups[i], namespace)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
+		}
+
+		versionStr, ok := cm.ObjectMeta.Labels["version"]
+		if !ok {
+			return nil, nil, nil, fmt.Errorf("error extracting version from config map")
+		}
+		versionInt, err := strconv.Atoi(versionStr)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("error converting version to int: %w", err)
+		}
+
+		version := uint(versionInt)
+
+		newSection := &SyncedEnvSection{
+			Name:    envGroups[i],
+			Version: version,
+		}
+
+		newSectionKeys := make([]SyncedEnvSectionKey, 0)
+
+		for key, val := range cm.Data {
+			newSectionKeys = append(newSectionKeys, SyncedEnvSectionKey{
+				Name:   key,
+				Secret: strings.Contains(val, "PORTERSECRET"),
+			})
+		}
+
+		newSection.Keys = newSectionKeys
+		synced_env = append(synced_env, newSection)
+	}
 
-	values, err := buildUmbrellaChartValues(parsed, imageInfo, existingValues, opts, injectLauncher, shouldCreate)
+	parsed.SyncedEnv = synced_env
+	// 	fmt.Println("This is the config map:" ,cm)
+	values, err := buildUmbrellaChartValues(parsed, imageInfo, existingValues, opts, injectLauncher, shouldCreate, userUpdate)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
@@ -81,7 +134,7 @@ func parse(
 	// return the parsed release values for the release job chart, if they exist
 	var preDeployJobValues map[string]interface{}
 	if parsed.Release != nil && parsed.Release.Run != nil {
-		preDeployJobValues = buildPreDeployJobChartValues(parsed.Release, parsed.Env, imageInfo, injectLauncher)
+		preDeployJobValues = buildPreDeployJobChartValues(parsed.Release, parsed.Env, parsed.SyncedEnv, imageInfo, injectLauncher, existingValues, strings.TrimSuffix(strings.TrimPrefix(namespace, "porter-stack-"), "")+"-r", userUpdate)
 	}
 
 	return chart, convertedValues, preDeployJobValues, nil
@@ -94,6 +147,7 @@ func buildUmbrellaChartValues(
 	opts SubdomainCreateOpts,
 	injectLauncher bool,
 	shouldCreate bool,
+	userUpdate bool,
 ) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
 
@@ -105,7 +159,8 @@ func buildUmbrellaChartValues(
 
 	for name, app := range parsed.Apps {
 		appType := getType(name, app)
-		defaultValues := getDefaultValues(app, parsed.Env, appType)
+
+		defaultValues := getDefaultValues(app, parsed.Env, parsed.SyncedEnv, appType, existingValues, name, userUpdate)
 		convertedConfig := convertMap(app.Config).(map[string]interface{})
 		helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
 
@@ -204,8 +259,8 @@ func validateHelmValues(values map[string]interface{}, shouldCreate bool, appTyp
 	return ""
 }
 
-func buildPreDeployJobChartValues(release *App, env map[string]string, imageInfo types.ImageInfo, injectLauncher bool) map[string]interface{} {
-	defaultValues := getDefaultValues(release, env, "job")
+func buildPreDeployJobChartValues(release *App, env map[string]string, synced_env []*SyncedEnvSection, imageInfo types.ImageInfo, injectLauncher bool, existingValues map[string]interface{}, name string, userUpdate bool) map[string]interface{} {
+	defaultValues := getDefaultValues(release, env, synced_env, "job", existingValues, name+"-r", userUpdate)
 	convertedConfig := convertMap(release.Config).(map[string]interface{})
 	helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
 
@@ -230,6 +285,12 @@ func buildPreDeployJobChartValues(release *App, env map[string]string, imageInfo
 	return helm_values
 }
 
+// func populateSyncedEnvGroups(release *App, opts SubdomainCreateOpts) {
+// 	// TODO
+// 	cm, _, err := opts.k8sAgent.GetLatestVersionedConfigMap()
+// 	fmt.Println("This is the config map:" ,cm)
+// }
+
 func getType(name string, app *App) string {
 	if app.Type != nil {
 		return *app.Type
@@ -240,17 +301,26 @@ func getType(name string, app *App) string {
 	return "worker"
 }
 
-func getDefaultValues(app *App, env map[string]string, appType string) map[string]interface{} {
+func getDefaultValues(app *App, env map[string]string, synced_env []*SyncedEnvSection, appType string, existingValues map[string]interface{}, name string, userUpdate bool) map[string]interface{} {
 	var defaultValues map[string]interface{}
 	var runCommand string
 	if app.Run != nil {
 		runCommand = *app.Run
 	}
+	var syncedEnvs []map[string]interface{}
+	envConf, err := getStacksNestedMap(existingValues, name+"-"+appType, "container", "env")
+	if !userUpdate && err == nil {
+		syncedEnvs = envConf
+	} else {
+		syncedEnvs = deconstructSyncedEnvs(synced_env, env)
+	}
+
 	defaultValues = map[string]interface{}{
 		"container": map[string]interface{}{
 			"command": runCommand,
 			"env": map[string]interface{}{
 				"normal": CopyEnv(env),
+				"synced": syncedEnvs,
 			},
 		},
 	}
@@ -258,6 +328,32 @@ func getDefaultValues(app *App, env map[string]string, appType string) map[strin
 	return defaultValues
 }
 
+func deconstructSyncedEnvs(synced_env []*SyncedEnvSection, env map[string]string) []map[string]interface{} {
+	synced := make([]map[string]interface{}, 0)
+	for _, group := range synced_env {
+		keys := make([]map[string]interface{}, 0)
+		for _, key := range group.Keys {
+			if _, exists := env[key.Name]; !exists {
+				// Only include keys not present in env
+				keys = append(keys, map[string]interface{}{
+					"name":   key.Name,
+					"secret": key.Secret,
+				})
+			}
+		}
+
+		syncedGroup := map[string]interface{}{
+			"keys":    keys,
+			"name":    group.Name,
+			"version": group.Version,
+		}
+
+		synced = append(synced, syncedGroup)
+	}
+
+	return synced
+}
+
 func buildUmbrellaChart(parsed *PorterStackYAML, config *config.Config, projectID uint, existingDependencies []*chart.Dependency) (*chart.Chart, error) {
 	deps := make([]*chart.Dependency, 0)
 	for alias, app := range parsed.Apps {
@@ -338,7 +434,6 @@ func createChartFromDependencies(deps []*chart.Dependency) (*chart.Chart, error)
 		Type:         "application",
 		Dependencies: deps,
 	}
-
 	// create a new chart object with the metadata
 	c := &chart.Chart{
 		Metadata: metadata,
@@ -404,7 +499,6 @@ func CopyEnv(env map[string]string) map[string]interface{} {
 		}
 		envCopy[k] = v
 	}
-
 	return envCopy
 }
 
@@ -566,3 +660,41 @@ func attemptToGetImageInfoFromRelease(values map[string]interface{}) types.Image
 
 	return imageInfo
 }
+
+func getStacksNestedMap(obj map[string]interface{}, fields ...string) ([]map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+	for _, field := range fields {
+		objField, ok := curr[field]
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	syncedInterface, ok := curr["synced"]
+	if !ok {
+		return nil, fmt.Errorf("synced not found")
+	}
+
+	synced, ok := syncedInterface.([]interface{})
+	if !ok {
+		return nil, fmt.Errorf("synced is not a slice of interface{}")
+	}
+
+	result := make([]map[string]interface{}, len(synced))
+	for i, v := range synced {
+		mapElement, ok := v.(map[string]interface{})
+		if !ok {
+			return nil, fmt.Errorf("element %d in synced is not a map[string]interface{}", i)
+		}
+		result[i] = mapElement
+	}
+	return result, nil
+}

+ 29 - 0
api/server/router/namespace.go

@@ -206,6 +206,35 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/envgroup/create -> namespace.NewCreateEnvGroupHandler
+	createStacksEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/stacks/envgroup/create",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createStacksEnvGroupHandler := namespace.NewCreateStacksEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createStacksEnvGroupEndpoint,
+		Handler:  createStacksEnvGroupHandler,
+		Router:   r,
+	})
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/add_application -> namespace.NewAddEnvGroupAppHandler
 	updateEnvGroupAppsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 18 - 0
api/types/namespace.go

@@ -169,6 +169,24 @@ type CreateEnvGroupRequest struct {
 	SecretVariables map[string]string `json:"secret_variables"`
 }
 
+// CreateEnvGroupRequest represents the request body to create or update an env group
+//
+// swagger:model
+type CreateStacksEnvGroupRequest struct {
+	// the name of the env group to create or update
+	// example: prod-env-group
+	Name string `json:"name" form:"required,dns1123"`
+
+	// the variables to include in the env group
+	Variables map[string]string `json:"variables" form:"required"`
+
+	// the secret variables to include in the env group
+	SecretVariables map[string]string `json:"secret_variables"`
+
+	// the list of all applications to include in the env group
+	Apps []string `json:"apps"`
+}
+
 type CreateConfigMapResponse struct {
 	*v1.ConfigMap
 }

+ 2 - 0
api/types/porter_app.go

@@ -47,6 +47,8 @@ type CreatePorterAppRequest struct {
 	PorterYamlPath   string    `json:"porter_yaml_path"`
 	ImageInfo        ImageInfo `json:"image_info" form:"omitempty"`
 	OverrideRelease  bool      `json:"override_release"`
+	EnvGroups        []string  `json:"env_groups"`
+	UserUpdate       bool      `json:"user_update"`
 }
 
 type UpdatePorterAppRequest struct {

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

@@ -66,7 +66,6 @@ const KeyValueArray: React.FC<Props> = (props) => {
   useEffect(() => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
-      // console.log(values);
       const envGroups: PartialEnvGroup[] = values?.synced || [];
 
       if (Array.isArray(props.injectedProps?.availableSyncEnvGroups)) {

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

@@ -102,13 +102,13 @@ export interface ArrayInputField extends GenericInputField {
 export interface SelectField extends GenericInputField {
   type: "select";
   settings:
-    | {
-        type: "normal";
-        options: { value: string; label: string }[];
-      }
-    | {
-        type: "provider";
-      };
+  | {
+    type: "normal";
+    options: { value: string; label: string }[];
+  }
+  | {
+    type: "provider";
+  };
   width: string;
   label?: string;
   dropdownLabel?: string;
@@ -213,8 +213,8 @@ export interface PorterFormValidationInfo {
 }
 
 // internal field state interfaces
-export interface StringInputFieldState {}
-export interface CheckboxFieldState {}
+export interface StringInputFieldState { }
+export interface CheckboxFieldState { }
 
 export type PartialEnvGroup = {
   name: string;
@@ -242,8 +242,8 @@ export interface KeyValueArrayFieldState {
   showEditorModal: boolean;
   synced_env_groups: PopulatedEnvGroup[];
 }
-export interface ArrayInputFieldState {}
-export interface SelectFieldState {}
+export interface ArrayInputFieldState { }
+export interface SelectFieldState { }
 
 export type PorterFormFieldFieldState =
   | StringInputFieldState

+ 157 - 0
dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx

@@ -0,0 +1,157 @@
+import React, { FC } from 'react';
+import * as Diff from "deep-diff";
+import styled from 'styled-components';
+
+const createCompareLink = (repoId: string, oldTag: string, newTag: string) => {
+  const baseUrl = 'https://github.com';
+  const link = `${baseUrl}/${repoId}/compare/${oldTag}...${newTag}`;
+  return link;
+}
+
+const getTagsFromChange = (changeString: string) => {
+  const tagPattern = /"global image tag: "([^"]*)" -> "([^"]*)""/;
+  const match = changeString.match(tagPattern);
+  if (match) {
+    return { oldTag: match[1], newTag: match[2] };
+  }
+  return null;
+}
+
+const ChangeBoxComponent: FC<BoxProps> = ({ type, children }) => {
+
+  return (
+    <ChangeBox type={type}>
+      {children}
+    </ChangeBox>
+  );
+
+};
+
+type Props = {
+  oldYaml: any;
+  newYaml: any;
+};
+
+const ChangeLogComponent: FC<Props> = ({ oldYaml, newYaml }) => {
+  const diff = Diff.diff(oldYaml, newYaml);
+  const changes: JSX.Element[] = [];
+  const servicePattern = /^[a-zA-Z0-9\-]*-[a-zA-Z0-9]*[^\.]$/;
+
+  diff?.forEach((difference: any) => {
+    let path = difference.path?.join(".");
+    // Extract the base path and check if it includes forbidden paths
+
+    const syncedPaths = ["synced"];
+    const isSyncedPath = syncedPaths.some(subPath => path?.includes(subPath));
+
+    // Restructure the path when synced is included
+    if (isSyncedPath) {
+      const parts = path?.split(".");
+      const syncedIndex = parts?.indexOf("synced");
+      path = `${parts[0]}.${parts[syncedIndex]}.${parts[parts?.length - 1]}`;
+    }
+
+    // Extract the base path and check if it includes forbidden paths
+    const basePath = path?.split('.').slice(0, -1).join('.');
+    const forbiddenPaths = ["container", "env", "keys", "name"];
+    const isForbiddenPath = forbiddenPaths.some(subPath => basePath?.includes(subPath));
+
+    if (difference.kind === "E" && isForbiddenPath && !isSyncedPath) {
+      return;  // Skip if it's a forbidden path
+    }
+
+    console.log("Filtered Difference: ", difference);
+    console.log("Filtered Path: ", path);
+
+    // rest of th
+
+    switch (difference.kind) {
+      case "E":
+        const tags = getTagsFromChange(path);
+        if (tags) {
+          const repoId = "your-repo-id-here"; // replace with your repoId
+          const link = createCompareLink(repoId, tags.oldTag, tags.newTag);
+          changes.push(
+            <ChangeBoxComponent type="E">
+              Image tag changed: {tags.oldTag} -{'>'} {tags.newTag}
+            </ChangeBoxComponent>
+          );
+        } else {
+          changes.push(
+            <ChangeBoxComponent type="E">
+              {`${path}: ${JSON.stringify(difference.lhs)} -> ${JSON.stringify(difference.rhs)}`}
+            </ChangeBoxComponent>
+          );
+        }
+        break;
+      case "N":
+        if (servicePattern.test(path)) {
+          changes.push(
+            <ChangeBoxComponent type="N">{`${path} created`}</ChangeBoxComponent>
+          );
+        } else {
+          changes.push(
+            <ChangeBoxComponent type="N">{`${path} added: ${JSON.stringify(difference.rhs)}`}</ChangeBoxComponent>
+          );
+        }
+        break;
+      case "D":
+        if (servicePattern.test(path)) {
+          changes.push(
+            <ChangeBoxComponent type="D">{`${path} deleted`}</ChangeBoxComponent>
+          );
+        } else {
+          changes.push(
+            <ChangeBoxComponent type="D">{`${path} removed`}</ChangeBoxComponent>
+          );
+        }
+        break;
+      case "A":
+        path = `${path}[${difference.index}]`;
+        if (difference.item.kind === "N") {
+          changes.push(
+            <ChangeBoxComponent type="N">{`${path} added`}</ChangeBoxComponent>
+          );
+        } else if (difference.item.kind === "D") {
+          changes.push(
+            <ChangeBoxComponent type="D">{`${path} deleted`}</ChangeBoxComponent>
+          );
+        }
+        break;
+      default:
+        break;
+    }
+
+    if (changes.length === 0) {
+      changes.push(<ChangeBoxComponent type="E">No changes detected</ChangeBoxComponent>);
+    }
+  });
+  return <ChangeLog>{changes}</ChangeLog>;
+};
+
+export default ChangeLogComponent;
+
+const ChangeLog = styled.div`
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  overflow: hidden;
+`;
+
+type BoxProps = {
+  type: string,
+  children?: React.ReactNode,
+};
+
+const ChangeBox = styled.div<BoxProps>`
+  padding: 10px;
+  background-color: ${({ type }) =>
+    type === "N"
+      ? "#034a53"
+      : type === "D"
+        ? "#632f34"
+        : type === "E"
+          ? "#272831"
+          : "#fff"};
+  color: "#fff";
+`;

+ 13 - 104
dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogModal.tsx

@@ -13,6 +13,7 @@ import { ChartType } from "shared/types";
 import * as Diff from "deep-diff";
 import api from "shared/api";
 import { Context } from "shared/Context";
+import ChangeLogComponent from "./ChangeLogComponent";
 
 type Props = {
   modalVisible: boolean;
@@ -137,82 +138,6 @@ const ChangeLogModal: React.FC<Props> = ({
     fetchData();
   }, [currentChart.config]);
 
-  const parseYamlAndDisplayDifferences = (oldYaml: any, newYaml: any) => {
-    const diff = Diff.diff(oldYaml, newYaml);
-    const changes: JSX.Element[] = [];
-    // Define the regex pattern to match service creation
-    const servicePattern = /^[a-zA-Z0-9\-]*-[a-zA-Z0-9]*[^\.]$/;
-    diff?.forEach((difference: any) => {
-      let path = difference.path?.join(" ");
-      switch (difference.kind) {
-        case "N":
-          // Check if the added item is a service by testing the path against the regex pattern
-          if (servicePattern.test(path)) {
-            changes.push(<ChangeBox type="N">{`${path} created`}</ChangeBox>);
-          } else {
-            // If not, display the full message
-            changes.push(
-              <ChangeBox type="N">{`${path} added: ${JSON.stringify(
-                difference.rhs
-              )}`}</ChangeBox>
-            );
-          }
-          break;
-        case "D":
-          if (servicePattern.test(path)) {
-            // If so, display a simplified message
-            changes.push(<ChangeBox type="D">
-              {`${path} deleted`}
-            </ChangeBox>);
-          } else {
-
-            changes.push(<ChangeBox type="D">
-              {`${path} removed`}
-            </ChangeBox>);
-          }
-          break;
-        case "E":
-          changes.push(
-            <ChangeBox type="E">
-              {`${path}: ${JSON.stringify(difference.lhs)} -> ${JSON.stringify(
-                difference.rhs
-              )}`}
-            </ChangeBox>
-          );
-          break;
-        case "A":
-          path = path + `[${difference.index}]`;
-          if (difference.item.kind === "N")
-            changes.push(
-              <Text>{`${path} added: ${JSON.stringify(
-                difference.item.rhs
-              )}`}</Text>
-            );
-          if (difference.item.kind === "D")
-            changes.push(<Text>{`${path} removed`}</Text>);
-          if (difference.item.kind === "E")
-            changes.push(
-              <Text>
-                {`${path} updated: ${JSON.stringify(
-                  difference.item.lhs
-                )} -> ${JSON.stringify(difference.item.rhs)}`}
-              </Text>
-            );
-          break;
-      }
-    });
-    if (changes.length === 0) {
-      changes.push(
-        <ChangeBox type="E">
-          {`No changes detected`}
-        </ChangeBox>
-      )
-    }
-
-    return <ChangeLog>{changes}</ChangeLog>
-
-  };
-
   return (
     <>
       <Modal closeModal={() => setModalVisible(false)} width={"800px"}>
@@ -253,13 +178,17 @@ const ChangeLogModal: React.FC<Props> = ({
                 </>
               ) : (
                 <div style={{ maxHeight: "400px", overflowY: "auto" }}>
-                  {revertModal ? parseYamlAndDisplayDifferences(
-                    currentChart.config,
-                    chartEvent?.config
-                  ) : parseYamlAndDisplayDifferences(
-                    prevChartEvent?.config,
-                    chartEvent?.config
-                  )}
+                  {revertModal ?
+
+                    <ChangeLogComponent
+                      oldYaml={currentChart.config}
+                      newYaml={chartEvent?.config}
+                    />
+                    : <ChangeLogComponent
+                      oldYaml={prevChartEvent?.config}
+                      newYaml={chartEvent?.config}
+                    />
+                  }
                 </div>
               )}
 
@@ -305,24 +234,4 @@ const ChangeLogModal: React.FC<Props> = ({
   );
 };
 
-export default ChangeLogModal;
-
-const ChangeLog = styled.div`
-  display: flex;
-  flex-direction: column;
-  border-radius: 8px;
-  overflow: hidden;
-`;
-
-const ChangeBox = styled.div<{ type: string }>`
-  padding: 10px;
-  background-color: ${({ type }) =>
-    type === "N"
-      ? "#034a53"
-      : type === "D"
-        ? "#632f34"
-        : type === "E"
-          ? "#272831"
-          : "#fff"};
-  color: "#fff";
-`;
+export default ChangeLogModal;

+ 337 - 4
dashboard/src/main/home/app-dashboard/expanded-app/EnvVariablesTab.tsx

@@ -1,17 +1,29 @@
 import Button from "components/porter/Button";
 import Spacer from "components/porter/Spacer";
-import EnvGroupArray from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
-import React, { useEffect, useState } from "react";
-import styled from "styled-components";
+import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
+import React, { useContext, useEffect, useState } from "react";
+import styled, { keyframes } from "styled-components";
 import Text from "components/porter/Text";
 import Error from "components/porter/Error";
+import sliders from "assets/sliders.svg";
+import EnvGroupModal from "./env-vars/EnvGroupModal";
+import ExpandableEnvGroup from "./env-vars/ExpandableEnvGroup";
+import { PopulatedEnvGroup, PartialEnvGroup } from "../../../../components/porter-form/types";
+import _, { isObject, differenceBy, omit } from "lodash";
+import api from "../../../../shared/api";
+import { Context } from "../../../../shared/Context";
 
 interface EnvVariablesTabProps {
   envVars: any;
   setEnvVars: (x: any) => void;
   status: React.ReactNode;
   updatePorterApp: any;
+  syncedEnvGroups: PopulatedEnvGroup[];
+  setSyncedEnvGroups: (values: PopulatedEnvGroup[]) => void;
   clearStatus: () => void;
+  appData: any;
+  deletedEnvGroups: PopulatedEnvGroup[];
+  setDeletedEnvGroups: (values: PopulatedEnvGroup[]) => void;
 }
 
 export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
@@ -19,17 +31,87 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
   setEnvVars,
   status,
   updatePorterApp,
+  syncedEnvGroups,
+  setSyncedEnvGroups,
+  deletedEnvGroups,
+  setDeletedEnvGroups,
   clearStatus,
+  appData,
 }) => {
+  const [showEnvModal, setShowEnvModal] = useState(false);
+  const [envGroups, setEnvGroups] = useState<any>([])
+  const { currentCluster, currentProject } = useContext(Context);
+
   useEffect(() => {
     setEnvVars(envVars);
   }, [envVars]);
+  useEffect(() => {
+    updateEnvGroups();
+  }, []);
+
+  const updateEnvGroups = async () => {
+    let envGroups: PartialEnvGroup[] = [];
+    try {
+      envGroups = await api
+        .listEnvGroups<PartialEnvGroup[]>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: "default",
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
+    } catch (error) {
+      // setLoading(false)
+      // setError(true);
+      return;
+    }
+    const populateEnvGroupsPromises = envGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            namespace: envGroup.namespace,
+            version: envGroup.version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
+      setEnvGroups(populatedEnvGroups)
+      // setLoading(false)
+      const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(appData.chart.name));
+
+      setSyncedEnvGroups(filteredEnvGroups)
+
+    } catch (error) {
+      // setLoading(false)
+      // setError(true);
+      return;
+    }
+  }
+
+  const deleteEnvGroup = (envGroup: PopulatedEnvGroup) => {
+
+    setDeletedEnvGroups([...deletedEnvGroups, envGroup]);
+    setSyncedEnvGroups(syncedEnvGroups?.filter(
+      (env) => env.name !== envGroup.name
+    ))
+  }
   return (
     <>
       <Text size={16}>Environment variables</Text>
       <Spacer y={0.5} />
       <Text color="helper">Shared among all services.</Text>
-      <EnvGroupArray
+      <EnvGroupArrayStacks
         key={envVars.length}
         values={envVars}
         setValues={(x: any) => {
@@ -39,7 +121,44 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
           setEnvVars(x)
         }}
         fileUpload={true}
+        syncedEnvGroups={syncedEnvGroups}
       />
+      <LoadButton
+        onClick={() => setShowEnvModal(true)}
+      >
+        <img src={sliders} /> Load from Env Group
+      </LoadButton>
+      {showEnvModal && <EnvGroupModal
+        setValues={(x: any) => {
+          if (status !== "") {
+            clearStatus();
+          }
+          setEnvVars(x);
+        }}
+        values={envVars}
+        closeModal={() => setShowEnvModal(false)}
+        syncedEnvGroups={syncedEnvGroups}
+        setSyncedEnvGroups={setSyncedEnvGroups}
+        namespace={appData.chart.namespace}
+      />}
+      {!!syncedEnvGroups?.length && (
+        <>
+          <Spacer y={0.5} />
+          <Text size={16}>Synced environment groups</Text >
+          {syncedEnvGroups?.map((envGroup: any) => {
+            return (
+              <ExpandableEnvGroup
+                key={envGroup?.name}
+                envGroup={envGroup}
+                onDelete={() => {
+                  deleteEnvGroup(envGroup);
+                }}
+              />
+            );
+          })}
+        </>
+      )}
+
       <Spacer y={0.5} />
       <Button
         onClick={() => {
@@ -54,3 +173,217 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
     </>
   );
 };
+
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  borderColor?: string;
+};
+
+const KeyInput = styled.input<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+export const MultiLineInput = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border: 1px solid #ffffff44;
+  background: #ffffff11;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  margin-top: 15px;
+  padding: 10px 14px;
+  overflow: hidden;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  height: 25px;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 40px;
+  width: 100%;
+  align-items: center;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  width: 30px;
+  height: 30px;
+  margin-left: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  border: 1px solid #ffffff00;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const NoVariablesTextWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff99;
+`;
+

+ 125 - 8
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -48,6 +48,7 @@ import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/use
 import Anser, { AnserJsonEntry } from "anser";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
+import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
 
 type Props = RouteComponentProps & {};
@@ -115,7 +116,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
-
+  const [syncedEnvGroups, setSyncedEnvGroups] = useState<PopulatedEnvGroup[]>([])
+  const [deletedEnvGroups, setDeleteEnvGroups] = useState<PopulatedEnvGroup[]>([])
   const [porterApp, setPorterApp] = useState<PorterApp>();
   // this is the version of the porterApp that is being edited. on save, we set the real porter app to be this version
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
@@ -193,8 +195,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         );
       } catch (err) {
-        // do nothing, unable to find release chart
-        // console.log(err);
+        setError(err)
       }
 
       // update apps and release
@@ -207,7 +208,40 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         resPorterApp?.data?.porter_yaml_path ?? "porter.yaml",
         newAppData
       );
+      let envGroups: PartialEnvGroup[] = [];
+      envGroups = await api
+        .listEnvGroups<PartialEnvGroup[]>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: "default",
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
 
+      const populateEnvGroupsPromises = envGroups?.map((envGroup) =>
+        api
+          .getEnvGroup<PopulatedEnvGroup>(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+              name: envGroup.name,
+              namespace: envGroup.namespace,
+              version: envGroup.version,
+            }
+          )
+          .then((res) => res.data)
+      );
+
+      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
+
+      const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(newAppData.chart.name));
+
+      setSyncedEnvGroups(filteredEnvGroups)
       setPorterJson(porterJson);
       setAppData(newAppData);
       // annoying that we have to parse buildpacks like this but alas
@@ -215,7 +249,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       setPorterApp(parsedPorterApp);
       setTempPorterApp(parsedPorterApp);
       setBuildView(!_.isEmpty(parsedPorterApp.dockerfile) ? "docker" : "buildpacks")
-
       const [newServices, newEnvVars] = updateServicesAndEnvVariables(
         resChartData?.data,
         preDeployChartData?.data,
@@ -279,7 +312,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       }
     } catch (err) {
       setError(err);
-      console.log(err);
     } finally {
       setIsLoading(false);
     }
@@ -289,6 +321,28 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     setShowDeleteOverlay(false);
     setDeleting(true);
     const { appName } = props.match.params as any;
+    if (syncedEnvGroups) {
+      const removeApplicationToEnvGroupPromises = syncedEnvGroups?.map((envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: appData.chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: "default",
+          }
+        );
+      });
+
+      try {
+        await Promise.all(removeApplicationToEnvGroupPromises);
+      } catch (error) {
+        setError(error);
+      }
+    }
     try {
       await api.deletePorterApp(
         "<token>",
@@ -329,6 +383,59 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   };
 
   const updatePorterApp = async (options: Partial<PorterAppOptions>) => {
+    //setting the EnvGroups Config Maps
+    const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
+      return !syncedEnvGroups.some((syncedEnvGroup) => {
+        return syncedEnvGroup.name === deletedEnvGroup.name;
+      });
+    });
+    setDeleteEnvGroups(filteredEnvGroups);
+    if (deletedEnvGroups) {
+      const removeApplicationToEnvGroupPromises = deletedEnvGroups?.map((envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: appData.chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: "default",
+          }
+        );
+      });
+
+      try {
+        await Promise.all(removeApplicationToEnvGroupPromises);
+      } catch (error) {
+        setCurrentError(
+          "We couldn't remove the synced env group from the application, please try again."
+        );
+      }
+    }
+    const addApplicationToEnvGroupPromises = syncedEnvGroups?.map(
+      (envGroup: any) => {
+        return api.addApplicationToEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: appData.chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: "default",
+          }
+        );
+      }
+    );
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setError(error);
+    }
     try {
       setButtonStatus("loading");
       if (
@@ -347,7 +454,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         );
         const yamlString = yaml.dump(finalPorterYaml);
         const base64Encoded = btoa(yamlString);
-
         const updatedPorterApp = {
           porter_yaml: base64Encoded,
           override_release: true,
@@ -357,6 +463,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           git_branch: tempPorterApp.git_branch,
           buildpacks: "",
           ...options,
+          env_groups: syncedEnvGroups?.map((env) => env.name),
+          user_update: true,
         }
         if (buildView === "docker") {
           updatedPorterApp.dockerfile = tempPorterApp.dockerfile;
@@ -371,12 +479,15 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         await api.createPorterApp(
           "<token>",
           updatedPorterApp,
+
           {
             cluster_id: currentCluster.id,
             project_id: currentProject.id,
             stack_name: appData.app.name,
           }
         );
+
+
         setPorterYaml(finalPorterYaml);
         setPorterApp(tempPorterApp);
         setButtonStatus("success");
@@ -386,6 +497,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       }
     } catch (err) {
       // TODO: better error handling
+
       console.log(err);
       const errMessage =
         err?.response?.data?.error ??
@@ -455,7 +567,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         }
       }
     } catch (error) {
-      // console.log(error);
+      setError(error);
     }
   };
 
@@ -702,11 +814,16 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             envVars={envVars}
             setEnvVars={(envVars: KeyValueType[]) => {
               setEnvVars(envVars);
-              onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
+              //onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
             }}
+            syncedEnvGroups={syncedEnvGroups}
             status={buttonStatus}
             updatePorterApp={updatePorterApp}
             clearStatus={() => setButtonStatus("")}
+            setSyncedEnvGroups={setSyncedEnvGroups}
+            appData={appData}
+            deletedEnvGroups={deletedEnvGroups}
+            setDeletedEnvGroups={setDeleteEnvGroups}
           />
         );
       default:

+ 356 - 0
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvGroupModal.tsx

@@ -0,0 +1,356 @@
+import { RouteComponentProps, withRouter } from "react-router";
+import styled, { css } from "styled-components";
+import React, { useContext, useEffect, useState } from "react";
+import Loading from "components/Loading";
+
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import ExpandableSection from "components/porter/ExpandableSection";
+import Fieldset from "components/porter/Fieldset";
+import Button from "components/porter/Button";
+import Select from "components/porter/Select";
+import api from "shared/api";
+import { getGithubAction } from "./utils";
+import AceEditor from "react-ace";
+import YamlEditor from "components/YamlEditor";
+import Error from "components/porter/Error";
+import Container from "components/porter/Container";
+import Checkbox from "components/porter/Checkbox";
+import { Context } from "../../../../../shared/Context";
+import sliders from "assets/sliders.svg";
+import { isEmpty, isObject } from "lodash";
+import {
+  EnvGroupData,
+  formattedEnvironmentValue,
+} from "../../../cluster-dashboard/env-groups/EnvGroup";
+import {
+  PartialEnvGroup,
+  PopulatedEnvGroup,
+} from "components/porter-form/types";
+import { KeyValueType } from "../../../cluster-dashboard/env-groups/EnvGroupArray";
+import { set } from "zod";
+
+type Props = RouteComponentProps & {
+  closeModal: () => void;
+  availableEnvGroups?: PartialEnvGroup[];
+  setValues: (x: KeyValueType[]) => void;
+  values: KeyValueType[];
+  syncedEnvGroups: PopulatedEnvGroup[];
+  setSyncedEnvGroups: (values: PopulatedEnvGroup[]) => void;
+  namespace: string;
+  newApp?: boolean;
+}
+
+const EnvGroupModal: React.FC<Props> = ({
+  closeModal,
+  setValues,
+  availableEnvGroups,
+  syncedEnvGroups,
+  setSyncedEnvGroups,
+  values,
+  namespace,
+  newApp,
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [envGroups, setEnvGroups] = useState<any>([])
+  const [loading, setLoading] = useState<boolean>(true);
+  const [error, setError] = useState<any>(null);
+  const [shouldSync, setShouldSync] = useState<boolean>(true);
+  const [selectedEnvGroup, setSelectedEnvGroup] = useState<PopulatedEnvGroup | null>(null);
+  const [cloneSuccess, setCloneSuccess] = useState(false);
+
+  const updateEnvGroups = async () => {
+    let envGroups: PartialEnvGroup[] = [];
+    try {
+      envGroups = await api
+        .listEnvGroups<PartialEnvGroup[]>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: "default",
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
+    } catch (error) {
+      setLoading(false)
+      setError(true);
+      return;
+    }
+
+    const populateEnvGroupsPromises = envGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            namespace: envGroup.namespace,
+            version: envGroup.version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
+      setEnvGroups(populatedEnvGroups)
+      setLoading(false)
+
+    } catch (error) {
+      setLoading(false)
+      setError(true);
+    }
+  };
+
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+
+  useEffect(() => {
+    if (Array.isArray(availableEnvGroups)) {
+      setEnvGroups(availableEnvGroups);
+      setLoading(false);
+      return;
+    }
+    updateEnvGroups();
+  }, []);
+
+  const cloneEnvGroup = async () => {
+    setCloneSuccess(false);
+    try {
+      await api.cloneEnvGroup(
+        "<token>",
+        {
+          name: selectedEnvGroup.name,
+          namespace: namespace,
+          clone_name: selectedEnvGroup.name,
+          version: selectedEnvGroup.version,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "default",
+        }
+      );
+      setCloneSuccess(true);
+    } catch (error) {
+      console.log(error);
+    }
+  };
+  const renderEnvGroupList = () => {
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (!envGroups?.length) {
+      return (
+        <Placeholder>
+          No environment groups found in this namespace
+        </Placeholder>
+      );
+    } else {
+      return envGroups
+        .filter((envGroup) => {
+          if (!Array.isArray(syncedEnvGroups)) {
+            return true;
+          }
+          return !syncedEnvGroups.find(
+            (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
+          );
+        })
+        .map((envGroup: any, i: number) => {
+          return (
+            <EnvGroupRow
+              key={i}
+              isSelected={selectedEnvGroup === envGroup}
+              lastItem={i === envGroups.length - 1}
+              onClick={() => setSelectedEnvGroup(envGroup)}
+            >
+              <img src={sliders} />
+              {envGroup.name}
+            </EnvGroupRow>
+          );
+        });
+    }
+  };
+
+  const onSubmit = () => {
+    if (shouldSync) {
+
+      syncedEnvGroups.push(selectedEnvGroup);
+      if (!newApp) {
+        cloneEnvGroup();
+      }
+      setSyncedEnvGroups(syncedEnvGroups);
+    }
+    else {
+      const _values = [...values];
+
+      Object.entries(selectedEnvGroup?.variables || {})
+        .map(
+          ([key, value]) =>
+            _values.push({
+              key,
+              value: value as string,
+              hidden: false,
+              locked: false,
+              deleted: false,
+            })
+        )
+      setValues(_values);
+    }
+    closeModal();
+  };
+
+  return (
+    <Modal closeModal={closeModal}>
+      <Text size={16}>
+        Load env group
+      </Text>
+      <Spacer height="15px" />
+      {syncedEnvGroups.length != envGroups.length ? (<>
+        <Text color="helper">
+          Select an Env Group to load into your application.
+        </Text>
+        <Spacer y={0.5} />
+        <GroupModalSections>
+          <SidebarSection $expanded={!selectedEnvGroup}>
+            <EnvGroupList>{renderEnvGroupList()}</EnvGroupList>
+          </SidebarSection>
+          {selectedEnvGroup && (
+            <><SidebarSection>
+
+              <GroupEnvPreview>
+                {isObject(selectedEnvGroup?.variables) ? (
+                  <>
+                    {Object.entries(selectedEnvGroup?.variables || {})
+                      .map(
+                        ([key, value]) =>
+                          `${key}=${formattedEnvironmentValue(value)}`
+                      )
+                      .join("\n")}
+                  </>
+                ) : (
+                  <>This environment group has no variables</>
+                )}
+              </GroupEnvPreview>
+              {/* {clashingKeys?.length > 0 && (
+                <>
+                  <ClashingKeyRowDivider />
+                  {this.renderEnvGroupPreview(clashingKeys)}
+                </>
+              )} */}
+            </SidebarSection>
+              <Checkbox
+                checked={shouldSync}
+                toggleChecked={() =>
+                  setShouldSync((!shouldSync))
+                }
+              >
+                <Text color="helper">Sync Env Group</Text>
+              </Checkbox>
+            </>
+          )
+
+          }
+
+        </GroupModalSections>
+        <Spacer y={1} />
+
+        <Spacer y={1} />
+        <Button
+          onClick={onSubmit}
+          disabled={!selectedEnvGroup}
+        >
+          Load Env Group
+        </Button> </>
+      ) : (<Text >
+        No selectable Env Groups
+      </Text>)}
+    </Modal>
+  )
+}
+
+export default withRouter(EnvGroupModal);
+
+const LoadingWrapper = styled.div`
+height: 150px;
+`;
+const Placeholder = styled.div`
+width: 100%;
+height: 150px;
+display: flex;
+align-items: center;
+justify-content: center;
+color: #aaaabb;
+font-size: 13px;
+`;
+
+const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>`
+display: flex;
+width: 100%;
+font-size: 13px;
+border-bottom: 1px solid
+  ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+color: #ffffff;
+user-select: none;
+align-items: center;
+padding: 10px 0px;
+cursor: pointer;
+background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
+:hover {
+  background: #ffffff11;
+}
+
+> img,
+i {
+  width: 16px;
+  height: 18px;
+  margin-left: 12px;
+  margin-right: 12px;
+  font-size: 20px;
+}
+`;
+const EnvGroupList = styled.div`
+width: 100%;
+border-radius: 3px;
+background: #ffffff11;
+border: 1px solid #ffffff44;
+overflow-y: auto;
+`;
+
+const SidebarSection = styled.section<{ $expanded?: boolean }>`
+  height: 100%;
+  overflow-y: auto;
+  ${(props) =>
+    props.$expanded &&
+    css`
+      grid-column: span 2;
+    `}
+`;
+
+const GroupEnvPreview = styled.pre`
+  font-family: monospace;
+  margin: 0 0 10px 0;
+  white-space: pre-line;
+  word-break: break-word;
+  user-select: text;
+`;
+const GroupModalSections = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  height: 100%;
+  display: grid;
+  gap: 10px;
+  grid-template-columns: 1fr 1fr;
+  max-height: 365px;
+`;

+ 267 - 0
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/ExpandableEnvGroup.tsx

@@ -0,0 +1,267 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import EnvGroupArray from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import React, { useEffect, useState } from "react";
+import styled, { keyframes } from "styled-components";
+import Text from "components/porter/Text";
+import Error from "components/porter/Error";
+import sliders from "assets/sliders.svg";
+import EnvGroupModal from "./env-vars/EnvGroupModal";
+import { PopulatedEnvGroup } from "../../../../components/porter-form/types";
+import _, { isObject, differenceBy, omit } from "lodash";
+
+
+const ExpandableEnvGroup: React.FC<{
+  envGroup: PopulatedEnvGroup;
+  onDelete: () => void;
+}> = ({ envGroup, onDelete }) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+  return (
+    <>
+      <StyledCard>
+        <Flex>
+          <ContentContainer>
+            <EventInformation>
+              <EventName>{envGroup.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            <ActionButton onClick={() => onDelete()}>
+              <span className="material-icons">delete</span>
+            </ActionButton>
+            <ActionButton onClick={() => setIsExpanded((prev) => !prev)}>
+              <i className="material-icons">
+                {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
+              </i>
+            </ActionButton>
+          </ActionContainer>
+        </Flex>
+        {isExpanded && (
+          <>
+            {isObject(envGroup.variables) ? (
+              <>
+                {Object.entries(envGroup.variables || {})?.map(
+                  ([key, value], i: number) => {
+                    // Preprocess non-string env values set via raw Helm values
+                    if (typeof value === "object") {
+                      value = JSON.stringify(value);
+                    } else {
+                      value = String(value);
+                    }
+
+                    return (
+                      <InputWrapper key={i}>
+                        <KeyInput
+                          placeholder="ex: key"
+                          width="270px"
+                          value={key}
+                          disabled
+                        />
+                        <Spacer x={.5} inline />
+                        {value?.includes("PORTERSECRET") ? (
+                          <KeyInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            type={
+                              value.includes("PORTERSECRET")
+                                ? "password"
+                                : "text"
+                            }
+                          />
+                        ) : (
+                          <MultiLineInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            rows={value?.split("\n").length}
+                            spellCheck={false}
+                          ></MultiLineInput>
+                        )}
+                      </InputWrapper>
+                    );
+                  }
+                )}
+              </>
+            ) : (
+              <NoVariablesTextWrapper>
+                This env group has no variables yet
+              </NoVariablesTextWrapper>
+            )}
+          </>
+        )}
+      </StyledCard>
+    </>
+  );
+};
+
+export default ExpandableEnvGroup;
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  borderColor?: string;
+};
+
+const KeyInput = styled.input<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+export const MultiLineInput = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border: 1px solid #ffffff44;
+  background: #ffffff11;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  margin-top: 15px;
+  padding: 10px 14px;
+  overflow: hidden;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  height: 25px;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 40px;
+  width: 100%;
+  align-items: center;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  width: 30px;
+  height: 30px;
+  margin-left: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  border: 1px solid #ffffff00;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const NoVariablesTextWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff99;
+`;

+ 157 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -7,6 +7,7 @@ import yaml from "js-yaml";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import web from "assets/web.png";
+import sliders from "assets/sliders.svg";
 
 import Back from "components/porter/Back";
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
@@ -28,6 +29,10 @@ import { Service } from "./serviceTypes";
 import GithubConnectModal from "./GithubConnectModal";
 import Link from "components/porter/Link";
 import { BuildMethod, PorterApp } from "../types/porterApp";
+import { PartialEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
+import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
+import EnvGroupModal from "../expanded-app/env-vars/EnvGroupModal";
+import ExpandableEnvGroup from "../expanded-app/env-vars/ExpandableEnvGroup";
 
 type Props = RouteComponentProps & {};
 
@@ -97,6 +102,10 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
   const [buildView, setBuildView] = useState<BuildMethod>("buildpacks");
 
+  const [syncedEnvGroups, setSyncedEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+  const [showEnvModal, setShowEnvModal] = useState(false);
+  const [deletedEnvGroups, setDeleteEnvGroups] = useState<PopulatedEnvGroup[]>([])
+
   const handleSetAccessData = (data: GithubAppAccessData) => {
     setAccessData(data);
     setShowGithubConnectModal(
@@ -251,6 +260,12 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         porterApp.name.length > 61)
     );
   };
+  const deleteEnvGroup = (envGroup: PopulatedEnvGroup) => {
+    setDeleteEnvGroups([...deletedEnvGroups, envGroup]);
+    setSyncedEnvGroups(syncedEnvGroups?.filter(
+      (env) => env.name !== envGroup.name
+    ))
+  }
 
   const deployPorterApp = async () => {
     try {
@@ -299,6 +314,8 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         git_repo_id: porterApp.git_repo_id,
         build_context: porterApp.build_context,
         image_repo_uri: porterApp.image_repo_uri,
+        env_groups: syncedEnvGroups?.map((env: PopulatedEnvGroup) => env.name),
+        user_update: true,
       }
       if (buildView === "docker") {
         porterAppRequest.dockerfile = porterApp.dockerfile;
@@ -321,6 +338,68 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         props.history.push(`/apps/${porterApp.name}`);
       }
 
+
+      const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
+        return !syncedEnvGroups.some((syncedEnvGroup) => {
+          return syncedEnvGroup.name === deletedEnvGroup.name;
+        });
+      });
+      setDeleteEnvGroups(filteredEnvGroups);
+      if (deletedEnvGroups) {
+        const removeApplicationToEnvGroupPromises = deletedEnvGroups?.map((envGroup: any) => {
+          return api.removeApplicationFromEnvGroup(
+            "<token>",
+            {
+              name: envGroup?.name,
+              app_name: porterApp.name,
+            },
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+              namespace: "default",
+            }
+          );
+        });
+
+        try {
+          await Promise.all(removeApplicationToEnvGroupPromises);
+        } catch (err: any) {
+          const errMessage =
+            err?.response?.data?.error ??
+            err?.toString() ??
+            "An error occurred while deploying your app. Please try again.";
+          setDeploymentError(errMessage);
+          updateStackStep("stack-launch-failure", errMessage);
+        }
+      }
+      const addApplicationToEnvGroupPromises = syncedEnvGroups?.map(
+        (envGroup: any) => {
+          return api.addApplicationToEnvGroup(
+            "<token>",
+            {
+              name: envGroup?.name,
+              app_name: porterApp.name,
+            },
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+              namespace: "default",
+            }
+          );
+        }
+      );
+
+      try {
+        await Promise.all(addApplicationToEnvGroupPromises);
+      } catch (err: any) {
+        const errMessage =
+          err?.response?.data?.error ??
+          err?.toString() ??
+          "An error occurred while deploying your app. Please try again.";
+        setDeploymentError(errMessage);
+        updateStackStep("stack-launch-failure", errMessage);
+      }
+
       // log analytics event that we successfully deployed
       updateStackStep("stack-launch-success");
 
@@ -471,13 +550,48 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Text color="helper">
                   Specify environment variables shared among all services.
                 </Text>
-                <EnvGroupArray
+                <EnvGroupArrayStacks
+                  key={formState.envVariables.length}
                   values={formState.envVariables}
                   setValues={(x: any) => {
                     setFormState({ ...formState, envVariables: x });
                   }}
                   fileUpload={true}
+                  syncedEnvGroups={syncedEnvGroups}
                 />
+                <LoadButton
+                  onClick={() => setShowEnvModal(true)}
+                >
+                  <img src={sliders} /> Load from Env Group
+                </LoadButton>
+                {showEnvModal && <EnvGroupModal
+                  setValues={(x: any) => {
+                    setFormState({ ...formState, envVariables: x });
+                  }}
+                  values={formState.envVariables}
+                  closeModal={() => setShowEnvModal(false)}
+                  syncedEnvGroups={syncedEnvGroups}
+                  setSyncedEnvGroups={setSyncedEnvGroups}
+                  namespace={"porter-stack-" + porterApp.name}
+                  newApp={true}
+                />}
+                {!!syncedEnvGroups?.length && (
+                  <>
+                    <Spacer y={0.5} />
+                    <Text size={16}>Synced environment groups</Text >
+                    {syncedEnvGroups?.map((envGroup: any) => {
+                      return (
+                        <ExpandableEnvGroup
+                          key={envGroup?.name}
+                          envGroup={envGroup}
+                          onDelete={() => {
+                            deleteEnvGroup(envGroup);
+                          }}
+                        />
+                      );
+                    })}
+                  </>
+                )}
               </>,
               formState.selectedSourceType == "github" &&
               <>
@@ -612,3 +726,45 @@ const StyledConfigureTemplate = styled.div`
 `;
 
 
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/DashboardRouter.tsx

@@ -8,7 +8,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { WithAuthProps, withAuth } from "shared/auth/AuthorizationHoc";
 import { ClusterType } from "shared/types";
-import { 
+import {
   getQueryParam,
   PorterUrl,
   pushQueryParams,
@@ -107,9 +107,9 @@ const DashboardRouter: React.FC<Props> = ({
   }, [currentCluster]);
 
   useEffect(() => {
-    let { currentNamespace } = props.match?.params as any;
+    let { currentNamespace } = (currentProject?.simplified_view_enabled && currentProject?.capi_provisioner_enabled) ? "default" : props.match?.params as any;
     if (!currentNamespace) {
-      currentNamespace = getQueryParam(props, "namespace");
+      currentNamespace = (currentProject?.simplified_view_enabled && currentProject?.capi_provisioner_enabled) ? "default" : getQueryParam(props, "namespace");
     }
     setSortType("Newest");
     setCurrentChart(null);

+ 28 - 27
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 
@@ -37,7 +37,6 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
     envVariables: [] as KeyValueType[],
     submitStatus: "",
   };
-
   componentDidMount() {
     this.updateNamespaces();
   }
@@ -171,29 +170,31 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
               placeholder="ex: doctor-scientist"
               width="100%"
             />
-
-            <Heading>Destination</Heading>
-            <Subtitle>
-              Specify the namespace you would like to create this environment
-              group in.
-            </Subtitle>
-            <DestinationSection>
-              <NamespaceLabel>
-                <i className="material-icons">view_list</i>Namespace
-              </NamespaceLabel>
-              <Selector
-                key={"namespace"}
-                activeValue={this.state.selectedNamespace}
-                setActiveValue={(namespace: string) =>
-                  this.setState({ selectedNamespace: namespace })
-                }
-                options={this.state.namespaceOptions}
-                width="250px"
-                dropdownWidth="335px"
-                closeOverlay={true}
-              />
-            </DestinationSection>
-
+            {!this?.context?.currentProject?.simplified_view_enabled && (<>
+              <Heading>Destination</Heading>
+              <Subtitle>
+                Specify the namespace you would like to create this environment
+                group in.
+              </Subtitle>
+              <DestinationSection>
+                <NamespaceLabel>
+                  <i className="material-icons">view_list</i>Namespace
+                </NamespaceLabel>
+                <Selector
+                  key={"namespace"}
+                  activeValue={this.state.selectedNamespace}
+                  setActiveValue={(namespace: string) =>
+                    this.setState({ selectedNamespace: namespace })
+                  }
+                  options={this.state.namespaceOptions}
+                  width="250px"
+                  dropdownWidth="335px"
+                  closeOverlay={true}
+                />
+              </DestinationSection>
+            </>
+            )
+            }
             <Heading>Environment variables</Heading>
             <Helper>
               Set environment variables for your secrets and environment-specific
@@ -206,8 +207,8 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
               fileUpload={true}
               secretOption={true}
             />
-           </Wrapper> 
-           <SaveButton
+          </Wrapper>
+          <SaveButton
             disabled={this.isDisabled()}
             text="Create env group"
             clearPosition={true}

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

@@ -52,10 +52,10 @@ export default class EnvGroup extends Component<PropsType, StateType> {
               </LastDeployed>
             </InfoWrapper>
 
-            <TagWrapper>
+            {!this.context?.currentProject.simplified_view_enabled && <TagWrapper>
               Namespace
               <NamespaceTag>{namespace.startsWith("porter-stack-") ? namespace.replace("porter-stack-", "") : namespace}</NamespaceTag>
-            </TagWrapper>
+            </TagWrapper>}
           </BottomWrapper>
 
           <Version>v{version}</Version>

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -284,10 +284,10 @@ const HideButton = styled(DeleteButton)`
   > i {
     font-size: 19px;
     cursor: ${(props: { disabled: boolean }) =>
-      props.disabled ? "default" : "pointer"};
+    props.disabled ? "default" : "pointer"};
     :hover {
       color: ${(props: { disabled: boolean }) =>
-        props.disabled ? "#ffffff44" : "#ffffff88"};
+    props.disabled ? "#ffffff44" : "#ffffff88"};
     }
   }
 `;
@@ -322,4 +322,4 @@ const Label = styled.div`
 const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
-`;
+`;

+ 402 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks.tsx

@@ -0,0 +1,402 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import Modal from "main/home/modals/Modal";
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+
+import upload from "assets/upload.svg";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
+import { dotenv_parse } from "shared/string_utils";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+type PropsType = {
+  label?: string;
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+  disabled?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
+  syncedEnvGroups?: PopulatedEnvGroup[];
+};
+
+const EnvGroupArray = ({
+  label,
+  values,
+  setValues,
+  disabled,
+  fileUpload,
+  secretOption,
+  syncedEnvGroups
+}: PropsType) => {
+  const [showEditorModal, setShowEditorModal] = useState(false);
+
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+  const isKeyOverriding = (key: string) => {
+
+    if (!syncedEnvGroups) return false;
+    return syncedEnvGroups.some(envGroup => key in envGroup.variables);
+  };
+
+  const readFile = (env: string) => {
+    const envObj = dotenv_parse(env);
+    const _values = [...values];
+
+    for (const key in envObj) {
+      let push = true;
+
+      for (let i = 0; i < values.length; i++) {
+        const existingKey = values[i]["key"];
+        const isExistingKeyDeleted = values[i]["deleted"];
+        if (key === existingKey && !isExistingKeyDeleted) {
+          _values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        _values.push({
+          key,
+          value: envObj[key],
+          hidden: false,
+          locked: false,
+          deleted: false,
+        });
+      }
+    }
+
+    setValues(_values);
+  };
+
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{label}</Label>
+        {!!values?.length &&
+          values.map((entry: KeyValueType, i: number) => {
+            if (!entry.deleted) {
+              return (
+                <InputWrapper key={i}>
+                  <Input
+                    placeholder="ex: key"
+                    width="270px"
+                    value={entry.key}
+                    onChange={(e: any) => {
+                      const _values = [...values];
+                      _values[i].key = e.target.value;
+                      setValues(_values);
+                    }}
+                    disabled={disabled || entry.locked}
+                    spellCheck={false}
+                    override={isKeyOverriding(entry.key)}
+                  />
+                  < Spacer x={.5} inline />
+                  {entry.hidden ? (
+                    entry.value?.includes("PORTERSECRET") ? (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        disabled
+                        type={"password"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />) : (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        onChange={(e: any) => {
+                          const _values = [...values];
+                          _values[i].value = e.target.value;
+                          setValues(_values);
+                        }}
+                        disabled={disabled || entry.locked}
+                        type={entry.hidden ? "password" : "text"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+
+                      />)
+                  ) : (
+                    entry.value?.includes("PORTERSECRET") ? (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        disabled
+                        type={"password"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />) : (
+                      <MultiLineInputer
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        onChange={(e: any) => {
+                          const _values = [...values];
+                          _values[i].value = e.target.value;
+                          setValues(_values);
+                        }}
+                        rows={entry.value?.split("\n").length}
+                        disabled={disabled || entry.locked}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />
+                    ))
+                  }
+                  {secretOption && (
+                    <HideButton
+                      onClick={() => {
+                        if (!entry.locked) {
+                          const _values = [...values];
+                          _values[i].hidden = !_values[i].hidden;
+                          setValues(_values);
+                        }
+                      }}
+                      disabled={entry.locked}
+                    >
+                      {entry.hidden ? (
+                        <i className="material-icons">lock</i>
+                      ) : (
+                        <i className="material-icons">lock_open</i>
+                      )}
+                    </HideButton>
+                  )}
+
+                  {!disabled && (
+                    <DeleteButton
+                      onClick={() => {
+                        setValues(values.filter((val, index) => index !== i));
+                      }}
+                    >
+                      <i className="material-icons">cancel</i>
+                    </DeleteButton>
+                  )}
+
+                  {isKeyOverriding(entry.key) && <><Spacer x={1} inline /> <Text color={'#6b74d6'} >Key is overriding value in a environment group</Text></>}
+                </InputWrapper>
+              );
+            }
+          })}
+        {!disabled && (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                const _values = [
+                  ...values,
+                  {
+                    key: "",
+                    value: "",
+                    hidden: false,
+                    locked: false,
+                    deleted: false,
+                  },
+                ];
+                setValues(_values);
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer x={.5} inline />
+            {fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setShowEditorModal(true);
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
+            )}
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {showEditorModal && (
+        <Modal
+          onRequestClose={() => setShowEditorModal(false)}
+          width="60%"
+          height="650px"
+        >
+          <EnvEditorModal
+            closeModal={() => setShowEditorModal(false)}
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default EnvGroupArray;
+
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "default" : "pointer"};
+    :hover {
+      color: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#ffffff44" : "#ffffff88"};
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+
+`;
+
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  override?: boolean;
+};
+
+const Input = styled.input<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: ${(props) => (props.override ? '2px solid #6b74d6' : ' 1px solid #ffffff55')};
+  border-radius: 3px;
+  width: ${(props) => props.width ? props.width : "270px"};
+  color: ${(props) => props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;
+
+export const MultiLineInputer = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: ${(props) => (props.override ? '2px solid #6b74d6' : ' 1px solid #ffffff55')};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

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

@@ -50,7 +50,7 @@ const EnvGroupDashboard = (props: PropsType) => {
   const setNamespace = (namespace: string) => {
     setState((state) => ({ ...state, namespace }));
     pushQueryParams(props, {
-      namespace: namespace ?? "ALL",
+      namespace: currentProject.simplified_view_enabled ? ("default") : (namespace ?? "ALL"),
     });
   };
 
@@ -107,10 +107,10 @@ const EnvGroupDashboard = (props: PropsType) => {
                 sortType={state.sortType}
               />
               <Spacer inline width="10px" />
-              <NamespaceSelector
+              {!currentProject.simplified_view_enabled && <NamespaceSelector
                 setNamespace={setNamespace}
-                namespace={state.namespace}
-              />
+                namespace={currentProject.simplified_view_enabled ? "default" : state.namespace}
+              />}
             </SortFilterWrapper>
             <Flex>
               {isAuthorizedToAdd && (
@@ -123,7 +123,7 @@ const EnvGroupDashboard = (props: PropsType) => {
 
           <EnvGroupList
             currentCluster={props.currentCluster}
-            namespace={currentProject?.capi_provisioner_enabled ? "default" : state.namespace}
+            namespace={currentProject?.simplified_view_enabled ? "default" : state.namespace}
             sortType={state.sortType}
             setExpandedEnvGroup={setExpandedEnvGroup}
           />
@@ -137,7 +137,7 @@ const EnvGroupDashboard = (props: PropsType) => {
       return (
         <ExpandedEnvGroup
           isAuthorized={props.isAuthorized}
-          namespace={state.expandedEnvGroup?.namespace || state.namespace}
+          namespace={currentProject?.simplified_view_enabled ? "default" : (state.expandedEnvGroup?.namespace || state.namespace)}
           currentCluster={props.currentCluster}
           envGroup={state.expandedEnvGroup}
           closeExpanded={() => closeExpanded()}

+ 122 - 37
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -230,7 +230,7 @@ export const ExpandedEnvGroupFC = ({
         }),
         {}
       );
-
+      //Create the Env Group
       try {
         const updatedEnvGroup = await api
           .updateEnvGroup<PopulatedEnvGroup>(
@@ -249,13 +249,89 @@ export const ExpandedEnvGroupFC = ({
           .then((res) => res.data);
         setButtonStatus("successful");
         updateEnvGroup(updatedEnvGroup);
+
         setTimeout(() => setButtonStatus(""), 1000);
-      } catch (error) {
+
+
+        if (currentProject?.simplified_view_enabled) {
+
+          //Clone the env group to all applications
+          try {
+            // Check if updatedEnvGroup.applications is an array and it has elements
+            if (Array.isArray(updatedEnvGroup.applications) && updatedEnvGroup.applications.length) {
+
+              for (const application of updatedEnvGroup.applications) {
+                await api.cloneEnvGroup(
+                  "<token>",
+                  {
+                    name: updatedEnvGroup.name,
+                    namespace: "porter-stack-" + application,  // Use the application's namespace
+                    clone_name: updatedEnvGroup.name,
+                    version: updatedEnvGroup.version,
+                  },
+                  {
+                    id: currentProject.id,
+                    cluster_id: currentCluster.id,
+                    namespace: "default",
+                  }
+                );
+              }
+            }
+          } catch (error) {
+            setCurrentError(error);
+          }
+
+          //Update the Stacks Env Groups with the new variables
+          try {
+            await api
+              .updateStacksEnvGroup<PopulatedEnvGroup>(
+                "<token>",
+                {
+                  name,
+                  variables: normalVariables,
+                  secret_variables: secretVariables,
+                  apps: updatedEnvGroup.applications,
+                },
+                {
+                  project_id: currentProject.id,
+                  cluster_id: currentCluster.id,
+                  namespace,
+                }
+              )
+              .then((res) => res.data);
+            setButtonStatus("successful");
+          } catch (error) {
+            setButtonStatus("Couldn't update successfully");
+            setCurrentError(error);
+          }
+
+        }
+      }
+
+
+
+
+
+
+
+      catch (error) {
         setButtonStatus("Couldn't update successfully");
         setCurrentError(error);
         setTimeout(() => setButtonStatus(""), 1000);
       }
-    } else {
+
+
+
+
+
+
+
+
+    }
+
+
+
+    else {
       // SEPARATE THE TWO KINDS OF VARIABLES
       let secret = variables.filter(
         (variable) =>
@@ -430,9 +506,9 @@ export const ExpandedEnvGroupFC = ({
       <HeaderWrapper>
         <TitleSection icon={key} iconWidth="33px">
           {envGroup.name}
-          <TagWrapper>
+          {!currentProject?.simplified_view_enabled && <TagWrapper>
             Namespace <NamespaceTag>{currentProject?.capi_provisioner_enabled && namespace.startsWith("porter-stack-") ? namespace.replace("porter-stack-", "") : namespace}</NamespaceTag>
-          </TagWrapper>
+          </TagWrapper>}
         </TitleSection>
       </HeaderWrapper>
 
@@ -583,7 +659,7 @@ const EnvGroupSettings = ({
           {!canDelete && (
             <Helper color="#f5cb42">
               Applications are still synced to this env group. Navigate to
-              "Linked Applications" and remove this env group from all
+              "Linked applications" and remove this env group from all
               applications to delete.
             </Helper>
           )}
@@ -601,33 +677,35 @@ const EnvGroupSettings = ({
             Delete {envGroup.name}
           </Button>
           <DarkMatter />
-          <Heading>Clone environment group</Heading>
-          <Helper>
-            Clone this set of environment variables into a new env group.
-          </Helper>
-          <InputRow
-            type="string"
-            value={name}
-            setValue={(x: string) => setName(x)}
-            label="New env group name"
-            placeholder="ex: my-cloned-env-group"
-          />
-          <InputRow
-            type="string"
-            value={cloneNamespace}
-            setValue={(x: string) => setCloneNamespace(x)}
-            label="New env group namespace"
-            placeholder="ex: default"
-          />
-          <FlexAlt>
-            <Button onClick={cloneEnvGroup}>Clone {envGroup.name}</Button>
-            {cloneSuccess && (
-              <StatusWrapper position="right" successful={true}>
-                <i className="material-icons">done</i>
-                <StatusTextWrapper>Successfully cloned</StatusTextWrapper>
-              </StatusWrapper>
-            )}
-          </FlexAlt>
+          {!currentProject?.simplified_view_enabled && (<>
+            <Heading>Clone environment group</Heading>
+            <Helper>
+              Clone this set of environment variables into a new env group.
+            </Helper>
+            <InputRow
+              type="string"
+              value={name}
+              setValue={(x: string) => setName(x)}
+              label="New env group name"
+              placeholder="ex: my-cloned-env-group"
+            />
+            <InputRow
+              type="string"
+              value={cloneNamespace}
+              setValue={(x: string) => setCloneNamespace(x)}
+              label="New env group namespace"
+              placeholder="ex: default"
+            />
+            <FlexAlt>
+              <Button onClick={cloneEnvGroup}>Clone {envGroup.name}</Button>
+              {cloneSuccess && (
+                <StatusWrapper position="right" successful={true}>
+                  <i className="material-icons">done</i>
+                  <StatusTextWrapper>Successfully cloned</StatusTextWrapper>
+                </StatusWrapper>
+              )}
+            </FlexAlt>
+          </>)}
         </InnerWrapper>
       )}
     </TabWrapper>
@@ -635,7 +713,7 @@ const EnvGroupSettings = ({
 };
 
 const ApplicationsList = ({ envGroup }: { envGroup: EditableEnvGroup }) => {
-  const { currentCluster } = useContext(Context);
+  const { currentCluster, currentProject } = useContext(Context);
 
   return (
     <>
@@ -658,12 +736,19 @@ const ApplicationsList = ({ envGroup }: { envGroup: EditableEnvGroup }) => {
                 </EventInformation>
               </ContentContainer>
               <ActionContainer>
-                <ActionButton
-                  to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+                {currentProject?.simplified_view_enabled ? (<ActionButton
+                  to={`/apps/${appName}`}
                   target="_blank"
                 >
                   <span className="material-icons-outlined">open_in_new</span>
-                </ActionButton>
+                </ActionButton>)
+                  :
+                  (<ActionButton
+                    to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+                    target="_blank"
+                  >
+                    <span className="material-icons-outlined">open_in_new</span>
+                  </ActionButton>)}
               </ActionContainer>
             </Flex>
           </StyledCard>

+ 7 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroupDashboard.tsx

@@ -21,7 +21,7 @@ type PropsType = RouteComponentProps &
   };
 
 const EnvGroupDashboard = (props: PropsType) => {
-  const namespace = getQueryParam(props, "namespace");
+  const namespace = (currentProject?.simplified_view_enabled && currentProject?.capi_provisioner_enabled) ? "default" : getQueryParam(props, "namespace");
   const params = useParams<{ name: string }>();
   const { currentProject } = useContext(Context);
   const [expandedEnvGroup, setExpandedEnvGroup] = useState<any>();
@@ -39,7 +39,9 @@ const EnvGroupDashboard = (props: PropsType) => {
     async () => {
       try {
         if (!namespace) {
-          return [];
+          if (!currentProject?.simplified_view_enabled) {
+            return [];
+          }
         }
 
         const res = await api.listEnvGroups(
@@ -47,7 +49,7 @@ const EnvGroupDashboard = (props: PropsType) => {
           {},
           {
             id: currentProject.id,
-            namespace: namespace,
+            namespace: currentProject?.simplified_view_enabled ? "default" : namespace,
             cluster_id: props.currentCluster.id,
           }
         );
@@ -95,7 +97,7 @@ const EnvGroupDashboard = (props: PropsType) => {
     return (
       <ExpandedEnvGroup
         isAuthorized={props.isAuthorized}
-        namespace={expandedEnvGroup?.namespace ?? namespace}
+        namespace={(currentProject?.simplified_view_enabled && currentProject?.capi_provisioner_enabled) ? "default" : expandedEnvGroup?.namespace ?? namespace}
         currentCluster={props.currentCluster}
         envGroup={expandedEnvGroup}
         closeExpanded={() => props.history.push("/env-groups")}
@@ -167,7 +169,7 @@ const Button = styled.div`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+    props.disabled ? "" : "#505edddd"};
   }
 
   > i {

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -19,12 +19,17 @@ const Dashboard = () => {
   const { getQueryParam, pushQueryParams } = useRouting();
 
   const handleNamespaceChange = (namespace: string) => {
+    console.log("HERE")
+    console.log(namespace)
     setCurrentNamespace(namespace);
     pushQueryParams({ namespace });
   };
 
   useEffect(() => {
     const newNamespace = getQueryParam("namespace");
+    console.log("HERE")
+    console.log(newNamespace)
+
     if (newNamespace !== currentNamespace) {
       setCurrentNamespace(newNamespace);
     }
@@ -112,7 +117,7 @@ const Button = styled(DynamicLink)`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+    props.disabled ? "" : "#505edddd"};
   }
 
   > i {

+ 43 - 31
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -8,6 +8,7 @@ import settings from "assets/settings-bold.png";
 import web from "assets/web-bold.png";
 import addOns from "assets/add-ons-bold.png";
 import infra from "assets/infra.png";
+import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 
@@ -137,21 +138,21 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/integrations"}>
-              <Img src={integrations} />
-              Integrations
-            </NavButton>
-          )}
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/project-settings"}>
-              <Img src={settings} />
-              Project settings
-            </NavButton>
-          )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
 
           <br />
 
@@ -188,43 +189,54 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={addOns} />
             Add-ons
           </NavButton>
+          <NavButton
+            path="/env-groups"
+
+            active={
+
+              window.location.pathname.startsWith("/env-groups")
+            }
+          >
+            <Img src={sliders} />
+            Env groups
+          </NavButton>
           {this.props.isAuthorized("integrations", "", [
             "get",
             "create",
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/integrations"}>
-              <Img src={integrations} />
-              Integrations
-            </NavButton>
-          )}
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-            <NavButton
-              path={"/cluster-dashboard"}
-              targetClusterName={currentCluster?.name}
-              active={
-                window.location.pathname.startsWith("/cluster-dashboard")
-              }
-            >
-              <Img src={infra} />
-              Infrastructure
-            </NavButton>
-          )}
+              <NavButton
+                path={"/cluster-dashboard"}
+                targetClusterName={currentCluster?.name}
+                active={
+                  window.location.pathname.startsWith("/cluster-dashboard")
+                }
+              >
+                <Img src={infra} />
+                Infrastructure
+              </NavButton>
+            )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/project-settings"}>
-              <Img src={settings} />
-              Project settings
-            </NavButton>
-          )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
 
           {/* Hacky workaround for setting currentCluster with legacy method */}
           <Clusters

+ 20 - 1
dashboard/src/shared/api.tsx

@@ -1684,6 +1684,24 @@ const updateEnvGroup = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/envgroup/create`
 );
 
+const updateStacksEnvGroup = baseApi<
+  {
+    name: string;
+    variables: { [key: string]: string };
+    secret_variables?: { [key: string]: string };
+    apps?: string[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+  }
+>(
+  "POST",
+  ({ cluster_id, project_id, namespace }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/envgroup/create`
+);
+
 const createConfigMap = baseApi<
   {
     name: string;
@@ -2825,6 +2843,7 @@ export default {
   createEnvGroup,
   cloneEnvGroup,
   updateEnvGroup,
+  updateStacksEnvGroup,
   listEnvGroups,
   getEnvGroup,
   deleteEnvGroup,
@@ -2881,4 +2900,4 @@ export default {
 
   // STATUS
   getGithubStatus,
-};
+};

+ 23 - 0
internal/kubernetes/envgroup/create.go

@@ -217,6 +217,29 @@ func GetSyncedReleases(helmAgent *helm.Agent, configMap *v1.ConfigMap) ([]*relea
 	return res, nil
 }
 
+func GetStackSyncedReleases(helmAgent *helm.Agent, namespace string) ([]*release.Release, error) {
+	res := make([]*release.Release, 0)
+
+	releases, err := helmAgent.ListReleases(context.Background(), namespace, &types.ReleaseListFilter{
+		StatusFilter: []string{
+			"deployed",
+			"uninstalled",
+			"pending",
+			"pending-install",
+			"pending-upgrade",
+			"pending-rollback",
+			"failed",
+		},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	res = append(res, releases...)
+
+	return res, nil
+}
+
 func EncodeSecrets(data map[string]string) map[string][]byte {
 	res := make(map[string][]byte)