Browse Source

support doppler (#4061)

Co-authored-by: jusrhee <justin@porter.run>
d-g-town 2 years ago
parent
commit
220f3c07f2
30 changed files with 1082 additions and 277 deletions
  1. 70 0
      api/server/handlers/environment_groups/are_external_providers_enabled.go
  2. 44 23
      api/server/handlers/environment_groups/create.go
  3. 38 18
      api/server/handlers/environment_groups/delete.go
  4. 53 0
      api/server/handlers/environment_groups/enable_external_providers.go
  5. 27 0
      api/server/handlers/environment_groups/list.go
  6. 58 0
      api/server/router/cluster.go
  7. BIN
      dashboard/src/assets/doppler.png
  8. 67 78
      dashboard/src/components/porter-form/types.ts
  9. 4 0
      dashboard/src/components/porter/Banner.tsx
  10. 7 0
      dashboard/src/components/porter/Button.tsx
  11. 1 0
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  12. 29 31
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  13. 8 8
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvGroupModal.tsx
  14. 8 8
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx
  15. 18 18
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  16. 19 19
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx
  17. 11 8
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx
  18. 8 11
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx
  19. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts
  20. 9 7
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  21. 18 14
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  22. 263 0
      dashboard/src/main/home/integrations/DopplerIntegrationList.tsx
  23. 36 25
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  24. 2 2
      dashboard/src/main/home/integrations/Integrations.tsx
  25. 240 0
      dashboard/src/main/home/integrations/edit-integration/GitlabIntegrationList.tsx
  26. 29 2
      dashboard/src/shared/api.tsx
  27. 8 2
      dashboard/src/shared/common.tsx
  28. 0 2
      go.sum
  29. 5 0
      internal/kubernetes/environment_groups/list.go
  30. 1 1
      internal/porter_app/environment.go

+ 70 - 0
api/server/handlers/environment_groups/are_external_providers_enabled.go

@@ -0,0 +1,70 @@
+package environment_groups
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"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/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AreExternalProvidersEnabledHandler is the handler for the /environment-group/are-external-providers-enabled endpoint
+type AreExternalProvidersEnabledHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAreExternalProvidersEnabledHandler creates an instance of AreExternalProvidersEnabledHandler
+func NewAreExternalProvidersEnabledHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AreExternalProvidersEnabledHandler {
+	return &AreExternalProvidersEnabledHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AreExternalProvidersEnabledResponse is the response object for the /environment-group/are-external-providers-enabled endpoint
+type AreExternalProvidersEnabledResponse struct {
+	// Enabled is true if external providers are enabled
+	Enabled bool `json:"enabled"`
+	// ReprovisionRequired is true if the cluster needs to be reprovisioned to enable external providers
+	ReprovisionRequired bool `json:"reprovision_required"`
+	// K8SUpgradeRequired is true if the cluster needs to be upgraded to v1.27 to enable external providers
+	K8SUpgradeRequired bool `json:"k8s_upgrade_required"`
+}
+
+// ServeHTTP checks if external providers are enabled
+func (c *AreExternalProvidersEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-are-external-providers-enabled")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	resp, err := c.Config().ClusterControlPlaneClient.AreExternalEnvGroupProvidersEnabled(ctx, connect.NewRequest(&porterv1.AreExternalEnvGroupProvidersEnabledRequest{
+		ProjectId: int64(project.ID),
+		ClusterId: int64(cluster.ID),
+	}))
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to check if external providers are enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, &AreExternalProvidersEnabledResponse{
+		Enabled:             resp.Msg.Enabled,
+		ReprovisionRequired: resp.Msg.ReprovisionRequired,
+		K8SUpgradeRequired:  resp.Msg.K8SUpgradeRequired,
+	})
+}

+ 44 - 23
api/server/handlers/environment_groups/create.go

@@ -4,6 +4,9 @@ import (
 	"net/http"
 	"net/http"
 	"time"
 	"time"
 
 
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -35,6 +38,12 @@ type UpdateEnvironmentGroupRequest struct {
 	// Name of the env group to create or update
 	// Name of the env group to create or update
 	Name string `json:"name"`
 	Name string `json:"name"`
 
 
+	// Type of the env group to create or update
+	Type string `json:"type"`
+
+	// AuthToken for the env group
+	AuthToken string `json:"auth_token"`
+
 	// Variables are values which are not sensitive. All values must be a string due to a kubernetes limitation.
 	// Variables are values which are not sensitive. All values must be a string due to a kubernetes limitation.
 	Variables map[string]string `json:"variables"`
 	Variables map[string]string `json:"variables"`
 
 
@@ -66,6 +75,7 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 
 
 	telemetry.WithAttributes(span,
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
 		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+		telemetry.AttributeKV{Key: "environment-group-type", Value: request.Type},
 	)
 	)
 
 
 	agent, err := c.GetAgent(r, cluster, "")
 	agent, err := c.GetAgent(r, cluster, "")
@@ -75,18 +85,40 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 		return
 	}
 	}
 
 
-	envGroup := environment_groups.EnvironmentGroup{
-		Name:            request.Name,
-		Variables:       request.Variables,
-		SecretVariables: request.SecretVariables,
-		CreatedAtUTC:    time.Now().UTC(),
-	}
-
-	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, nil)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+	var envGroup environment_groups.EnvironmentGroup
+	switch request.Type {
+	case "doppler":
+		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
+			ProjectId:            int64(cluster.ProjectID),
+			ClusterId:            int64(cluster.ID),
+			EnvGroupProviderType: porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_DOPPLER,
+			EnvGroupName:         request.Name,
+			EnvGroupAuthToken:    request.AuthToken,
+		}))
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to create environment group")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		envGroup = environment_groups.EnvironmentGroup{
+			Name:         request.Name,
+			CreatedAtUTC: time.Now().UTC(),
+		}
+	default:
+		envGroup := environment_groups.EnvironmentGroup{
+			Name:            request.Name,
+			Variables:       request.Variables,
+			SecretVariables: request.SecretVariables,
+			CreatedAtUTC:    time.Now().UTC(),
+		}
+
+		err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, nil)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 	}
 
 
 	envGroupResponse := &UpdateEnvironmentGroupResponse{
 	envGroupResponse := &UpdateEnvironmentGroupResponse{
@@ -94,15 +126,4 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 		CreatedAt: envGroup.CreatedAtUTC,
 		CreatedAt: envGroup.CreatedAtUTC,
 	}
 	}
 	c.WriteResult(w, r, envGroupResponse)
 	c.WriteResult(w, r, envGroupResponse)
-
-	// TODO: Syncing applications that are linked is currently done by the frontend. This should be done entirely
-	// applicationsToSync, err := environment_groups.LinkedApplications(ctx, agent, envGroup.Name)
-	// if err != nil {
-	// 	err := telemetry.Error(ctx, span, err, "unable to find linked applications for environment group")
-	// 	c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-	// 	return
-	// }
-	// for _, app := range applicationsToSync {
-	// 	TODO: Call porter app update
-	// }
 }
 }

+ 38 - 18
api/server/handlers/environment_groups/delete.go

@@ -3,22 +3,27 @@ package environment_groups
 import (
 import (
 	"net/http"
 	"net/http"
 
 
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 )
 
 
+// DeleteEnvironmentGroupHandler is the handler for the DELETE /environment-group endpoint
 type DeleteEnvironmentGroupHandler struct {
 type DeleteEnvironmentGroupHandler struct {
 	handlers.PorterHandlerReadWriter
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 	authz.KubernetesAgentGetter
 }
 }
 
 
+// NewDeleteEnvironmentGroupHandler creates an instance of DeleteEnvironmentGroupHandler
 func NewDeleteEnvironmentGroupHandler(
 func NewDeleteEnvironmentGroupHandler(
 	config *config.Config,
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	decoderValidator shared.RequestDecoderValidator,
@@ -30,11 +35,16 @@ func NewDeleteEnvironmentGroupHandler(
 	}
 	}
 }
 }
 
 
+// DeleteEnvironmentGroupRequest is the request object for the DELETE /environment-group endpoint
 type DeleteEnvironmentGroupRequest struct {
 type DeleteEnvironmentGroupRequest struct {
 	// Name of the env group to delete
 	// Name of the env group to delete
 	Name string `json:"name"`
 	Name string `json:"name"`
+
+	// Type of the env group to delete
+	Type string `json:"type"`
 }
 }
 
 
+// ServeHTTP deletes an environment group
 func (c *DeleteEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *DeleteEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-env-group")
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-env-group")
 	defer span.End()
 	defer span.End()
@@ -45,27 +55,37 @@ func (c *DeleteEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 	}
 	}
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 
-	if request.Name == "" {
-		err := telemetry.Error(ctx, span, nil, "environment group name is required for deletion")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
 	telemetry.WithAttributes(span,
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
 		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+		telemetry.AttributeKV{Key: "environment-group-type", Value: request.Type},
 	)
 	)
 
 
-	agent, err := c.GetAgent(r, cluster, "")
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
+	switch request.Type {
+	case "doppler":
+		_, err := c.Config().ClusterControlPlaneClient.DeleteEnvGroup(ctx, connect.NewRequest(&porterv1.DeleteEnvGroupRequest{
+			ProjectId:            int64(cluster.ProjectID),
+			ClusterId:            int64(cluster.ID),
+			EnvGroupProviderType: porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_DOPPLER,
+			EnvGroupName:         request.Name,
+		}))
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to create environment group")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	default:
+		agent, err := c.GetAgent(r, cluster, "")
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 
 
-	err = environment_groups.DeleteEnvironmentGroup(ctx, agent, request.Name)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to delete environment group")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+		err = environment_groups.DeleteEnvironmentGroup(ctx, agent, request.Name)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to delete environment group")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 	}
 }
 }

+ 53 - 0
api/server/handlers/environment_groups/enable_external_providers.go

@@ -0,0 +1,53 @@
+package environment_groups
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"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/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// EnableExternalProvidersHandler is the handler for the /environment-groups/enable-external-providers endpoint
+type EnableExternalProvidersHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewEnableExternalProvidersHandler creates an instance of EnableExternalProvidersHandler
+func NewEnableExternalProvidersHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *EnableExternalProvidersHandler {
+	return &EnableExternalProvidersHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// EnableExternalProvidersResponse is the response object for the /environment-groups/enable-external-providers endpoint
+func (c *EnableExternalProvidersHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-enable-external-providers")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	_, err := c.Config().ClusterControlPlaneClient.EnableExternalEnvGroupProviders(ctx, connect.NewRequest(&porterv1.EnableExternalEnvGroupProvidersRequest{
+		ProjectId: int64(cluster.ProjectID),
+		ClusterId: int64(cluster.ID),
+	}))
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to enable external providers")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 27 - 0
api/server/handlers/environment_groups/list.go

@@ -32,12 +32,19 @@ func NewListEnvironmentGroupsHandler(
 	}
 	}
 }
 }
 
 
+// ListEnvironmentGroupsRequest is the request object for the /environment-groups endpoint
+type ListEnvironmentGroupsRequest struct {
+	// Type of the env group to filter by. If empty, all env groups will be returned.
+	Type string `json:"type"`
+}
+
 type ListEnvironmentGroupsResponse struct {
 type ListEnvironmentGroupsResponse struct {
 	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
 	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
 }
 }
 
 
 type EnvironmentGroupListItem struct {
 type EnvironmentGroupListItem struct {
 	Name               string            `json:"name"`
 	Name               string            `json:"name"`
+	Type               string            `json:"type"`
 	LatestVersion      int               `json:"latest_version"`
 	LatestVersion      int               `json:"latest_version"`
 	Variables          map[string]string `json:"variables,omitempty"`
 	Variables          map[string]string `json:"variables,omitempty"`
 	SecretVariables    map[string]string `json:"secret_variables,omitempty"`
 	SecretVariables    map[string]string `json:"secret_variables,omitempty"`
@@ -52,6 +59,15 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 
+	request := &ListEnvironmentGroupsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "unable to decode or validate request body")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-type", Value: request.Type})
+
 	agent, err := c.GetAgent(r, cluster, "")
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "unable to connect to cluster")
 		err = telemetry.Error(ctx, span, err, "unable to connect to cluster")
@@ -66,6 +82,16 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 		return
 	}
 	}
 
 
+	if request.Type != "" {
+		var filteredEnvGroupVersions []environmentgroups.EnvironmentGroup
+		for _, envGroup := range allEnvGroupVersions {
+			if envGroup.Type == request.Type {
+				filteredEnvGroupVersions = append(filteredEnvGroupVersions, envGroup)
+			}
+		}
+		allEnvGroupVersions = filteredEnvGroupVersions
+	}
+
 	envGroupSet := make(map[string]struct{})
 	envGroupSet := make(map[string]struct{})
 	for _, envGroup := range allEnvGroupVersions {
 	for _, envGroup := range allEnvGroupVersions {
 		if envGroup.Name == "" {
 		if envGroup.Name == "" {
@@ -126,6 +152,7 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		}
 		}
 		envGroups = append(envGroups, EnvironmentGroupListItem{
 		envGroups = append(envGroups, EnvironmentGroupListItem{
 			Name:               latestVersion.Name,
 			Name:               latestVersion.Name,
+			Type:               latestVersion.Type,
 			LatestVersion:      latestVersion.Version,
 			LatestVersion:      latestVersion.Version,
 			Variables:          latestVersion.Variables,
 			Variables:          latestVersion.Variables,
 			SecretVariables:    secrets,
 			SecretVariables:    secrets,

+ 58 - 0
api/server/router/cluster.go

@@ -1751,5 +1751,63 @@ func getClusterRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/environment-groups/enable-external-providers
+	enableExternalProvidersEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/environment-groups/enable-external-providers", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	enableExternalProvidersHandler := environment_groups.NewEnableExternalProvidersHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: enableExternalProvidersEndpoint,
+		Handler:  enableExternalProvidersHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/environment-groups/are-external-providers-enabled
+	areExternalProvidersEnabledEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/environment-groups/are-external-providers-enabled", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	areExternalProvidersEnabledHandler := environment_groups.NewAreExternalProvidersEnabledHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: areExternalProvidersEnabledEndpoint,
+		Handler:  areExternalProvidersEnabledHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

BIN
dashboard/src/assets/doppler.png


+ 67 - 78
dashboard/src/components/porter-form/types.ts

@@ -5,14 +5,14 @@
 
 
 // YAML Field interfaces
 // YAML Field interfaces
 
 
-import { ChartType, ContextProps } from "../../shared/types";
+import { type ChartType, type ContextProps } from "../../shared/types";
 
 
-export interface GenericField {
+export type GenericField = {
   id: string;
   id: string;
   injectedProps: unknown;
   injectedProps: unknown;
 }
 }
 
 
-export interface GenericInputField extends GenericField {
+export type GenericInputField = {
   isReadOnly?: boolean;
   isReadOnly?: boolean;
   required?: boolean;
   required?: boolean;
   variable: string;
   variable: string;
@@ -20,24 +20,24 @@ export interface GenericInputField extends GenericField {
 
 
   // Read in value from Helm for existing revisions
   // Read in value from Helm for existing revisions
   value?: [any] | [];
   value?: [any] | [];
-}
+} & GenericField
 
 
-export interface HeadingField extends GenericField {
+export type HeadingField = {
   type: "heading";
   type: "heading";
   label: string;
   label: string;
-}
+} & GenericField
 
 
-export interface SubtitleField extends GenericField {
+export type SubtitleField = {
   type: "subtitle";
   type: "subtitle";
   label: string;
   label: string;
-}
+} & GenericField
 
 
-export interface ServiceIPListField extends GenericField {
+export type ServiceIPListField = {
   type: "service-ip-list";
   type: "service-ip-list";
   value: any[];
   value: any[];
-}
+} & GenericField
 
 
-export interface ResourceListField extends GenericField {
+export type ResourceListField = {
   type: "resource-list";
   type: "resource-list";
   value: any[];
   value: any[];
   context?: {
   context?: {
@@ -52,13 +52,13 @@ export interface ResourceListField extends GenericField {
       "resource-button": any;
       "resource-button": any;
     };
     };
   };
   };
-}
+} & GenericField
 
 
-export interface VeleroBackupField extends GenericField {
+export type VeleroBackupField = {
   type: "velero-create-backup";
   type: "velero-create-backup";
-}
+} & GenericField
 
 
-export interface InputField extends GenericInputField {
+export type InputField = {
   type: "input";
   type: "input";
   label?: string;
   label?: string;
   placeholder?: string;
   placeholder?: string;
@@ -69,15 +69,15 @@ export interface InputField extends GenericInputField {
     omitUnitFromValue?: boolean;
     omitUnitFromValue?: boolean;
     default: string | number;
     default: string | number;
   };
   };
-}
+} & GenericInputField
 
 
-export interface CheckboxField extends GenericInputField {
+export type CheckboxField = {
   type: "checkbox";
   type: "checkbox";
   label?: string;
   label?: string;
   settings?: {};
   settings?: {};
-}
+} & GenericInputField
 
 
-export interface KeyValueArrayField extends GenericInputField {
+export type KeyValueArrayField = {
   type: "key-value-array";
   type: "key-value-array";
   label?: string;
   label?: string;
   secretOption?: boolean;
   secretOption?: boolean;
@@ -92,29 +92,29 @@ export interface KeyValueArrayField extends GenericInputField {
   injectedProps: {
   injectedProps: {
     availableSyncEnvGroups: PopulatedEnvGroup[];
     availableSyncEnvGroups: PopulatedEnvGroup[];
   };
   };
-}
+} & GenericInputField
 
 
-export interface ArrayInputField extends GenericInputField {
+export type ArrayInputField = {
   type: "array-input";
   type: "array-input";
   label?: string;
   label?: string;
-}
+} & GenericInputField
 
 
-export interface DictionaryField extends GenericInputField {
+export type DictionaryField = {
   type: "dictionary";
   type: "dictionary";
   label?: string;
   label?: string;
-}
+} & GenericInputField
 
 
-export interface DictionaryArrayField extends GenericInputField {
+export type DictionaryArrayField = {
   type: "dictionary-array";
   type: "dictionary-array";
   label?: string;
   label?: string;
-}
+} & GenericInputField
 
 
-export interface SelectField extends GenericInputField {
+export type SelectField = {
   type: "select";
   type: "select";
   settings:
   settings:
   | {
   | {
     type: "normal";
     type: "normal";
-    options: { value: string; label: string }[];
+    options: Array<{ value: string; label: string }>;
   }
   }
   | {
   | {
     type: "provider";
     type: "provider";
@@ -124,25 +124,25 @@ export interface SelectField extends GenericInputField {
   dropdownLabel?: string;
   dropdownLabel?: string;
   dropdownWidth?: number;
   dropdownWidth?: number;
   dropdownMaxHeight?: string;
   dropdownMaxHeight?: string;
-}
+} & GenericInputField
 
 
-export interface VariableField extends GenericInputField {
+export type VariableField = {
   type: "variable";
   type: "variable";
   settings?: {
   settings?: {
     default: any;
     default: any;
   };
   };
-}
+} & GenericInputField
 
 
-export interface CronField extends GenericInputField {
+export type CronField = {
   type: "cron";
   type: "cron";
   label: string;
   label: string;
   placeholder: string;
   placeholder: string;
   settings: {
   settings: {
     default: string;
     default: string;
   };
   };
-}
+} & GenericInputField
 
 
-export interface TextAreaField extends GenericInputField {
+export type TextAreaField = {
   type: "text-area";
   type: "text-area";
   label: string;
   label: string;
   placeholder: string;
   placeholder: string;
@@ -154,15 +154,15 @@ export interface TextAreaField extends GenericInputField {
       minCount?: number;
       minCount?: number;
     };
     };
   };
   };
-}
+} & GenericInputField
 
 
-export interface UrlLinkField extends GenericInputField {
+export type UrlLinkField = {
   type: "url-link";
   type: "url-link";
   label: string;
   label: string;
   injectedProps: {
   injectedProps: {
     chart: ChartType;
     chart: ChartType;
   };
   };
-}
+} & GenericInputField
 
 
 export type FormField =
 export type FormField =
   | HeadingField
   | HeadingField
@@ -182,27 +182,27 @@ export type FormField =
   | DictionaryArrayField
   | DictionaryArrayField
   | UrlLinkField;
   | UrlLinkField;
 
 
-export interface ShowIfAnd {
+export type ShowIfAnd = {
   and: ShowIf[];
   and: ShowIf[];
 }
 }
 
 
-export interface ShowIfOr {
+export type ShowIfOr = {
   or: ShowIf[];
   or: ShowIf[];
 }
 }
 
 
-export interface ShowIfNot {
+export type ShowIfNot = {
   not: ShowIf;
   not: ShowIf;
 }
 }
 
 
 export type ShowIf = string | ShowIfAnd | ShowIfOr | ShowIfNot;
 export type ShowIf = string | ShowIfAnd | ShowIfOr | ShowIfNot;
 
 
-export interface Section {
+export type Section = {
   name: string;
   name: string;
   show_if?: ShowIf;
   show_if?: ShowIf;
   contents: FormField[];
   contents: FormField[];
 }
 }
 
 
-export interface Tab {
+export type Tab = {
   name: string;
   name: string;
   label: string;
   label: string;
   sections: Section[];
   sections: Section[];
@@ -211,7 +211,7 @@ export interface Tab {
   };
   };
 }
 }
 
 
-export interface PorterFormData {
+export type PorterFormData = {
   name: string;
   name: string;
   hasSource: boolean;
   hasSource: boolean;
   includeHiddenFields: boolean;
   includeHiddenFields: boolean;
@@ -219,14 +219,14 @@ export interface PorterFormData {
   tabs: Tab[];
   tabs: Tab[];
 }
 }
 
 
-export interface PorterFormValidationInfo {
+export type PorterFormValidationInfo = {
   validated: boolean;
   validated: boolean;
   error?: string;
   error?: string;
 }
 }
 
 
 // internal field state interfaces
 // internal field state interfaces
-export interface StringInputFieldState { }
-export interface CheckboxFieldState { }
+export type StringInputFieldState = { }
+export type CheckboxFieldState = { }
 
 
 export type PartialEnvGroup = {
 export type PartialEnvGroup = {
   name: string;
   name: string;
@@ -236,11 +236,10 @@ export type PartialEnvGroup = {
 
 
 export type PopulatedEnvGroup = {
 export type PopulatedEnvGroup = {
   name: string;
   name: string;
+  type?: string;
   namespace: string;
   namespace: string;
   version: number;
   version: number;
-  variables: {
-    [key: string]: string;
-  };
+  variables: Record<string, string>;
   applications: any[];
   applications: any[];
   meta_version: number;
   meta_version: number;
   stack_id?: string;
   stack_id?: string;
@@ -249,28 +248,24 @@ export type PopulatedEnvGroup = {
 export type NewPopulatedEnvGroup = {
 export type NewPopulatedEnvGroup = {
   name: string;
   name: string;
   current_version: number;
   current_version: number;
-  variables: {
-    [key: string]: string;
-  };
-  secret_variables: {
-    [key: string]: string;
-  };
+  variables: Record<string, string>;
+  secret_variables: Record<string, string>;
   linked_applications: any[];
   linked_applications: any[];
   created_at: number;
   created_at: number;
 };
 };
-export interface KeyValueArrayFieldState {
-  values: {
+export type KeyValueArrayFieldState = {
+  values: Array<{
     key: string;
     key: string;
     value: string;
     value: string;
-  }[];
+  }>;
   showEnvModal: boolean;
   showEnvModal: boolean;
   showEditorModal: boolean;
   showEditorModal: boolean;
   synced_env_groups: PopulatedEnvGroup[];
   synced_env_groups: PopulatedEnvGroup[];
 }
 }
-export interface ArrayInputFieldState { }
-export interface DictionaryFieldState {}
-export interface DictionaryArrayFieldState { }
-export interface SelectFieldState { }
+export type ArrayInputFieldState = { }
+export type DictionaryFieldState = {}
+export type DictionaryArrayFieldState = { }
+export type SelectFieldState = { }
 
 
 export type PorterFormFieldFieldState =
 export type PorterFormFieldFieldState =
   | StringInputFieldState
   | StringInputFieldState
@@ -283,27 +278,21 @@ export type PorterFormFieldFieldState =
 
 
 // reducer interfaces
 // reducer interfaces
 
 
-export interface PorterFormFieldValidationState {
+export type PorterFormFieldValidationState = {
   validated: boolean;
   validated: boolean;
 }
 }
 
 
-export interface PorterFormVariableList {
-  [key: string]: any;
-}
+export type PorterFormVariableList = Record<string, any>
 
 
-export interface PorterFormState {
-  components: {
-    [key: string]: {
+export type PorterFormState = {
+  components: Record<string, {
       state: PorterFormFieldFieldState;
       state: PorterFormFieldFieldState;
-    };
-  };
-  validation: {
-    [key: string]: PorterFormFieldValidationState;
-  };
+    }>;
+  validation: Record<string, PorterFormFieldValidationState>;
   variables: PorterFormVariableList;
   variables: PorterFormVariableList;
 }
 }
 
 
-export interface PorterFormInitFieldAction {
+export type PorterFormInitFieldAction = {
   type: "init-field";
   type: "init-field";
   id: string;
   id: string;
   initValue: PorterFormFieldFieldState;
   initValue: PorterFormFieldFieldState;
@@ -311,7 +300,7 @@ export interface PorterFormInitFieldAction {
   initVars?: PorterFormVariableList;
   initVars?: PorterFormVariableList;
 }
 }
 
 
-export interface PorterFormUpdateFieldAction {
+export type PorterFormUpdateFieldAction = {
   type: "update-field";
   type: "update-field";
   id: string;
   id: string;
   updateFunc: (
   updateFunc: (
@@ -319,7 +308,7 @@ export interface PorterFormUpdateFieldAction {
   ) => Partial<PorterFormFieldFieldState>;
   ) => Partial<PorterFormFieldFieldState>;
 }
 }
 
 
-export interface PorterFormUpdateValidationAction {
+export type PorterFormUpdateValidationAction = {
   type: "update-validation";
   type: "update-validation";
   id: string;
   id: string;
   updateFunc: (
   updateFunc: (
@@ -327,7 +316,7 @@ export interface PorterFormUpdateValidationAction {
   ) => PorterFormFieldValidationState;
   ) => PorterFormFieldValidationState;
 }
 }
 
 
-export interface PorterFormMutateVariablesAction {
+export type PorterFormMutateVariablesAction = {
   type: "mutate-vars";
   type: "mutate-vars";
   mutateFunc: (prev: PorterFormVariableList) => PorterFormVariableList;
   mutateFunc: (prev: PorterFormVariableList) => PorterFormVariableList;
 }
 }

+ 4 - 0
dashboard/src/components/porter/Banner.tsx

@@ -20,6 +20,10 @@ const Banner: React.FC<Props> = ({
   suffix,
   suffix,
 }) => {
 }) => {
   const renderIcon = () => {
   const renderIcon = () => {
+    if (icon === "none") {
+      return null;
+    }
+    
     if (icon) {
     if (icon) {
       return icon;
       return icon;
     }
     }

+ 7 - 0
dashboard/src/components/porter/Button.tsx

@@ -59,6 +59,13 @@ const Button: React.FC<Props> = ({
             {loadingText || "Updating . . ."}
             {loadingText || "Updating . . ."}
           </StatusWrapper>
           </StatusWrapper>
         );
         );
+      case "error":
+        return (
+          <StatusWrapper success={false}>
+            <i className="material-icons">error_outline</i>
+            {errorText}
+          </StatusWrapper>
+        );
       case "":
       case "":
         return (
         return (
           helperText && (
           helperText && (

+ 1 - 0
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -49,6 +49,7 @@ const namespaceBlacklist = [
   "kube-system",
   "kube-system",
   "monitoring",
   "monitoring",
   "porter-agent-system",
   "porter-agent-system",
+  "external-secrets",
 ];
 ];
 
 
 const templateBlacklist = [
 const templateBlacklist = [

+ 29 - 31
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext } from "react";
 import React, { useEffect, useState, useContext } from "react";
-import { RouteComponentProps, useHistory, useLocation, useParams, withRouter } from "react-router";
+import { type RouteComponentProps, useHistory, useLocation, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import styled from "styled-components";
 import yaml from "js-yaml";
 import yaml from "js-yaml";
 
 
@@ -25,15 +25,14 @@ import Link from "components/porter/Link";
 import Back from "components/porter/Back";
 import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
 import Icon from "components/porter/Icon";
 import Icon from "components/porter/Icon";
-import { ChartType, CreateUpdatePorterAppOptions } from "shared/types";
+import { type ChartType, type CreateUpdatePorterAppOptions } from "shared/types";
 import BuildSettingsTab from "../build-settings/BuildSettingsTab";
 import BuildSettingsTab from "../build-settings/BuildSettingsTab";
 import Button from "components/porter/Button";
 import Button from "components/porter/Button";
 import Services from "../new-app-flow/Services";
 import Services from "../new-app-flow/Services";
 import { ImageInfo, Service } from "../new-app-flow/serviceTypes";
 import { ImageInfo, Service } from "../new-app-flow/serviceTypes";
 import Fieldset from "components/porter/Fieldset";
 import Fieldset from "components/porter/Fieldset";
-import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
-import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
-import { PorterYamlSchema } from "../new-app-flow/schema";
+import { type PorterJson, createFinalPorterYaml , PorterYamlSchema } from "../new-app-flow/schema";
+import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { EnvVariablesTab } from "./env-vars/EnvVariablesTab";
 import { EnvVariablesTab } from "./env-vars/EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import GHABanner from "./GHABanner";
 import LogSection from "./logs/LogSection";
 import LogSection from "./logs/LogSection";
@@ -43,8 +42,8 @@ import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import _ from "lodash";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
 import AnimateHeight from "react-animate-height";
-import { NewPopulatedEnvGroup } from "../../../../components/porter-form/types";
-import { BuildMethod, PorterApp } from "../types/porterApp";
+import { type NewPopulatedEnvGroup } from "../../../../components/porter-form/types";
+import { type BuildMethod, PorterApp } from "../types/porterApp";
 import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
 import HelmValuesTab from "./HelmValuesTab";
 import SettingsTab from "./SettingsTab";
 import SettingsTab from "./SettingsTab";
@@ -77,7 +76,7 @@ const validTabs = [
 ] as const;
 ] as const;
 const DEFAULT_TAB = "activity";
 const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
 type ValidTab = typeof validTabs[number];
-interface Params {
+type Params = {
   eventId?: string;
   eventId?: string;
   tab?: ValidTab;
   tab?: ValidTab;
 }
 }
@@ -175,7 +174,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           namespace: `porter-stack-${appName}`,
           namespace: `porter-stack-${appName}`,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
           name: appName,
           name: appName,
-          revision: revision,
+          revision,
         }
         }
       );
       );
       let preDeployChartData;
       let preDeployChartData;
@@ -216,7 +215,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             cluster_id: currentCluster?.id,
             cluster_id: currentCluster?.id,
           }
           }
         )
         )
-        .then((res) => res?.data?.environment_groups)
+        .then((res) => res?.data?.environmentGroups)
         .catch((error) => {
         .catch((error) => {
           console.error("Failed to fetch environment groups:", error);
           console.error("Failed to fetch environment groups:", error);
           return [];
           return [];
@@ -233,8 +232,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       setPorterJson(porterJson);
       setPorterJson(porterJson);
       setAppData(newAppData);
       setAppData(newAppData);
       const globalImage = resChartData.data.config?.global?.image
       const globalImage = resChartData.data.config?.global?.image
-      const hasBuiltImage = globalImage != null &&
-        globalImage.repository != null &&
+      const hasBuiltImage = globalImage?.repository != null &&
         globalImage.tag != null &&
         globalImage.tag != null &&
         !(globalImage.repository === ImageInfo.BASE_IMAGE.repository &&
         !(globalImage.repository === ImageInfo.BASE_IMAGE.repository &&
           globalImage.tag === ImageInfo.BASE_IMAGE.tag)
           globalImage.tag === ImageInfo.BASE_IMAGE.tag)
@@ -256,7 +254,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         newEnvVars,
         newEnvVars,
         porterJson,
         porterJson,
         // if we are using a heroku buildpack, inject a PORT env variable
         // if we are using a heroku buildpack, inject a PORT env variable
-        newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
+        newAppData.app.builder?.includes("heroku")
       );
       );
       setPorterYaml(finalPorterYaml);
       setPorterYaml(finalPorterYaml);
       // Only check GHA status if no built image is set
       // Only check GHA status if no built image is set
@@ -403,7 +401,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           envVars,
           envVars,
           porterJson,
           porterJson,
           // if we are using a heroku buildpack, inject a PORT env variable
           // if we are using a heroku buildpack, inject a PORT env variable
-          appData.app.builder != null && appData.app.builder.includes("heroku")
+          appData.app.builder?.includes("heroku")
         );
         );
         const yamlString = yaml.dump(finalPorterYaml);
         const yamlString = yaml.dump(finalPorterYaml);
         const base64Encoded = btoa(yamlString);
         const base64Encoded = btoa(yamlString);
@@ -415,7 +413,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           repo_name: tempPorterApp.repo_name,
           repo_name: tempPorterApp.repo_name,
           git_branch: tempPorterApp.git_branch,
           git_branch: tempPorterApp.git_branch,
           buildpacks: "",
           buildpacks: "",
-          environment_groups: syncedEnvGroups?.map((env) => env.name),
+          environmentGroups: syncedEnvGroups?.map((env) => env.name),
           user_update: true,
           user_update: true,
           ...options,
           ...options,
         }
         }
@@ -487,7 +485,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       }
       }
       const parsedYaml = yaml.load(atob(res.data));
       const parsedYaml = yaml.load(atob(res.data));
       const parsedData = PorterYamlSchema.parse(parsedYaml);
       const parsedData = PorterYamlSchema.parse(parsedYaml);
-      const porterYamlToJson = parsedData as PorterJson;
+      const porterYamlToJson = parsedData ;
       return porterYamlToJson;
       return porterYamlToJson;
     } catch (err) {
     } catch (err) {
       // TODO: handle error
       // TODO: handle error
@@ -495,7 +493,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   };
   };
 
 
   const renderIcon = (b: string, size?: string) => {
   const renderIcon = (b: string, size?: string) => {
-    var src = box;
+    let src = box;
     if (b) {
     if (b) {
       const bp = b.split(",")[0]?.split("/")[1];
       const bp = b.split(",")[0]?.split("/")[1];
       switch (bp) {
       switch (bp) {
@@ -577,7 +575,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       envVars,
       envVars,
       porterJson,
       porterJson,
       // if we are using a heroku buildpack, inject a PORT env variable
       // if we are using a heroku buildpack, inject a PORT env variable
-      appData.app.builder != null && appData.app.builder.includes("heroku")
+      appData.app.builder?.includes("heroku")
     );
     );
     if (!_.isEqual(porterYaml, newPorterYaml)) {
     if (!_.isEqual(porterYaml, newPorterYaml)) {
       setButtonStatus("");
       setButtonStatus("");
@@ -661,12 +659,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               services={services.filter(Service.isNonRelease)}
               services={services.filter(Service.isNonRelease)}
               chart={appData.chart}
               chart={appData.chart}
               addNewText={"Add a new service"}
               addNewText={"Add a new service"}
-              setExpandedJob={(x: string) => setExpandedJob(x)}
+              setExpandedJob={(x: string) => { setExpandedJob(x); }}
               appName={appData.app.name}
               appName={appData.app.name}
             />
             />
             <Spacer y={0.75} />
             <Spacer y={0.75} />
             <Button
             <Button
-              onClick={async () => await updatePorterApp({})}
+              onClick={async () => { await updatePorterApp({}); }}
               status={buttonStatus}
               status={buttonStatus}
               loadingText={"Updating..."}
               loadingText={"Updating..."}
               disabled={services.length === 0}
               disabled={services.length === 0}
@@ -679,8 +677,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return (
         return (
           <BuildSettingsTab
           <BuildSettingsTab
             porterApp={tempPorterApp}
             porterApp={tempPorterApp}
-            setTempPorterApp={(attrs: Partial<PorterApp>) => setTempPorterApp(PorterApp.setAttributes(tempPorterApp, attrs))}
-            clearStatus={() => setButtonStatus("")}
+            setTempPorterApp={(attrs: Partial<PorterApp>) => { setTempPorterApp(PorterApp.setAttributes(tempPorterApp, attrs)); }}
+            clearStatus={() => { setButtonStatus(""); }}
             updatePorterApp={updatePorterApp}
             updatePorterApp={updatePorterApp}
             buildView={buildView}
             buildView={buildView}
             setBuildView={setBuildView}
             setBuildView={setBuildView}
@@ -690,7 +688,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return (
         return (
           <ImageSettingsTab
           <ImageSettingsTab
             porterApp={tempPorterApp}
             porterApp={tempPorterApp}
-            setTempPorterApp={(attrs: Partial<PorterApp>) => setTempPorterApp(PorterApp.setAttributes(tempPorterApp, attrs))}
+            setTempPorterApp={(attrs: Partial<PorterApp>) => { setTempPorterApp(PorterApp.setAttributes(tempPorterApp, attrs)); }}
             updatePorterApp={updatePorterApp}
             updatePorterApp={updatePorterApp}
           />
           />
         )
         )
@@ -717,13 +715,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             envVars={envVars}
             envVars={envVars}
             setEnvVars={(envVars: KeyValueType[]) => {
             setEnvVars={(envVars: KeyValueType[]) => {
               setEnvVars(envVars);
               setEnvVars(envVars);
-              //onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
+              // onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
             }}
             }}
             setShowUnsavedChangesBanner={setShowUnsavedChangesBanner}
             setShowUnsavedChangesBanner={setShowUnsavedChangesBanner}
             syncedEnvGroups={syncedEnvGroups}
             syncedEnvGroups={syncedEnvGroups}
             status={buttonStatus}
             status={buttonStatus}
             updatePorterApp={updatePorterApp}
             updatePorterApp={updatePorterApp}
-            clearStatus={() => setButtonStatus("")}
+            clearStatus={() => { setButtonStatus(""); }}
             setSyncedEnvGroups={setSyncedEnvGroups}
             setSyncedEnvGroups={setSyncedEnvGroups}
             appData={appData}
             appData={appData}
             deletedEnvGroups={deletedEnvGroups}
             deletedEnvGroups={deletedEnvGroups}
@@ -740,7 +738,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return <ExpandedJob
         return <ExpandedJob
           appName={appData.app.name}
           appName={appData.app.name}
           jobName={queryParamOpts.service}
           jobName={queryParamOpts.service}
-          goBack={() => setExpandedJob(null)}
+          goBack={() => { setExpandedJob(null); }}
         />;
         />;
       default:
       default:
         return <ActivityFeed
         return <ActivityFeed
@@ -767,7 +765,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           <Link to="/apps">Return to dashboard</Link>
           <Link to="/apps">Return to dashboard</Link>
         </Placeholder>
         </Placeholder>
       )}
       )}
-      {!isLoading && appData != null && appData.app != null && (
+      {!isLoading && appData?.app != null && (
         <StyledExpandedApp>
         <StyledExpandedApp>
           <Back to="/apps" />
           <Back to="/apps" />
           <Container row>
           <Container row>
@@ -820,7 +818,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             <>
             <>
               <Container>
               <Container>
                 <Text>
                 <Text>
-                  <a href={Service.prefixSubdomain(subdomain)} target="_blank">
+                  <a href={Service.prefixSubdomain(subdomain)} target="_blank" rel="noreferrer">
                     {subdomain}
                     {subdomain}
                   </a>
                   </a>
                 </Text>
                 </Text>
@@ -869,7 +867,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     suffix={
                     suffix={
                       <>
                       <>
                         <RefreshButton
                         <RefreshButton
-                          onClick={() => window.location.reload()}
+                          onClick={() => { window.location.reload(); }}
                         >
                         >
                           <img src={refresh} />
                           <img src={refresh} />
                           Refresh
                           Refresh
@@ -899,7 +897,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     chart={appData.chart}
                     chart={appData.chart}
                     setRevision={setRevision}
                     setRevision={setRevision}
                     forceRefreshRevisions={forceRefreshRevisions}
                     forceRefreshRevisions={forceRefreshRevisions}
-                    refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                    refreshRevisionsOff={() => { setForceRefreshRevisions(false); }}
                     shouldUpdate={
                     shouldUpdate={
                       appData.chart.latest_version &&
                       appData.chart.latest_version &&
                       appData.chart.latest_version !==
                       appData.chart.latest_version !==
@@ -919,7 +917,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                   suffix={
                   suffix={
                     <>
                     <>
                       <Button
                       <Button
-                        onClick={async () => await updatePorterApp({})}
+                        onClick={async () => { await updatePorterApp({}); }}
                         status={buttonStatus}
                         status={buttonStatus}
                         loadingText={"Updating..."}
                         loadingText={"Updating..."}
                         height={"10px"}
                         height={"10px"}

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

@@ -1,4 +1,4 @@
-import { RouteComponentProps, withRouter } from "react-router";
+import { type RouteComponentProps, withRouter } from "react-router";
 import styled, { css } from "styled-components";
 import styled, { css } from "styled-components";
 import React, { useContext, useEffect, useState } from "react";
 import React, { useContext, useEffect, useState } from "react";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
@@ -22,11 +22,11 @@ import {
   formattedEnvironmentValue,
   formattedEnvironmentValue,
 } from "../../../cluster-dashboard/env-groups/EnvGroup";
 } from "../../../cluster-dashboard/env-groups/EnvGroup";
 import {
 import {
-  PartialEnvGroup,
-  PopulatedEnvGroup,
-  NewPopulatedEnvGroup,
+  type PartialEnvGroup,
+  type PopulatedEnvGroup,
+  type NewPopulatedEnvGroup,
 } from "components/porter-form/types";
 } from "components/porter-form/types";
-import { KeyValueType } from "../../../cluster-dashboard/env-groups/EnvGroupArray";
+import { type KeyValueType } from "../../../cluster-dashboard/env-groups/EnvGroupArray";
 import { set } from "zod";
 import { set } from "zod";
 
 
 type Props = RouteComponentProps & {
 type Props = RouteComponentProps & {
@@ -70,7 +70,7 @@ const EnvGroupModal: React.FC<Props> = ({
             cluster_id: currentCluster?.id,
             cluster_id: currentCluster?.id,
           }
           }
         )
         )
-        .then((res) => res.data?.environment_groups);
+        .then((res) => res.data?.environmentGroups);
     } catch (error) {
     } catch (error) {
       setLoading(false)
       setLoading(false)
       setError(true);
       setError(true);
@@ -130,7 +130,7 @@ const EnvGroupModal: React.FC<Props> = ({
               key={i}
               key={i}
               isSelected={selectedEnvGroup === envGroup}
               isSelected={selectedEnvGroup === envGroup}
               lastItem={i === envGroups?.length - 1}
               lastItem={i === envGroups?.length - 1}
-              onClick={() => setSelectedEnvGroup(envGroup)}
+              onClick={() => { setSelectedEnvGroup(envGroup); }}
             >
             >
               <img src={sliders} />
               <img src={sliders} />
               {envGroup?.name}
               {envGroup?.name}
@@ -154,7 +154,7 @@ const EnvGroupModal: React.FC<Props> = ({
           ([key, value]) =>
           ([key, value]) =>
             _values.push({
             _values.push({
               key,
               key,
-              value: value as string,
+              value ,
               hidden: false,
               hidden: false,
               locked: false,
               locked: false,
               deleted: false,
               deleted: false,

+ 8 - 8
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx

@@ -11,14 +11,14 @@ import ExpandableEnvGroup from "./ExpandableEnvGroup";
 import {
 import {
   PopulatedEnvGroup,
   PopulatedEnvGroup,
   PartialEnvGroup,
   PartialEnvGroup,
-  NewPopulatedEnvGroup,
+  type NewPopulatedEnvGroup,
 } from "../../../../../components/porter-form/types";
 } from "../../../../../components/porter-form/types";
 import _, { isObject, differenceBy, omit } from "lodash";
 import _, { isObject, differenceBy, omit } from "lodash";
 import api from "../../../../../shared/api";
 import api from "../../../../../shared/api";
 import { Context } from "../../../../../shared/Context";
 import { Context } from "../../../../../shared/Context";
 import yaml from "js-yaml";
 import yaml from "js-yaml";
 
 
-interface EnvVariablesTabProps {
+type EnvVariablesTabProps = {
   envVars: any;
   envVars: any;
   setEnvVars: (x: any) => void;
   setEnvVars: (x: any) => void;
   status: React.ReactNode;
   status: React.ReactNode;
@@ -80,7 +80,7 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
             cluster_id: currentCluster.id,
             cluster_id: currentCluster.id,
           }
           }
         )
         )
-        .then((res) => res?.data?.environment_groups);
+        .then((res) => res?.data?.environmentGroups);
     } catch (error) {
     } catch (error) {
       return;
       return;
     }
     }
@@ -95,7 +95,7 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
       );
       );
       setSyncedEnvGroups(filteredEnvGroups);
       setSyncedEnvGroups(filteredEnvGroups);
     } catch (error) {
     } catch (error) {
-      return;
+      
     }
     }
   };
   };
 
 
@@ -127,12 +127,12 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
 
 
       <>
       <>
         <TooltipWrapper
         <TooltipWrapper
-          onMouseOver={() => setHovered(true)}
-          onMouseOut={() => setHovered(false)}
+          onMouseOver={() => { setHovered(true); }}
+          onMouseOut={() => { setHovered(false); }}
         >
         >
           <LoadButton
           <LoadButton
             disabled={maxEnvGroupsReached}
             disabled={maxEnvGroupsReached}
-            onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+            onClick={() => { !maxEnvGroupsReached && setShowEnvModal(true); }}
           >
           >
             <img src={sliders} /> Load from Env Group
             <img src={sliders} /> Load from Env Group
           </LoadButton>
           </LoadButton>
@@ -150,7 +150,7 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
               setEnvVars(x);
               setEnvVars(x);
             }}
             }}
             values={envVars}
             values={envVars}
-            closeModal={() => setShowEnvModal(false)}
+            closeModal={() => { setShowEnvModal(false); }}
             syncedEnvGroups={syncedEnvGroups}
             syncedEnvGroups={syncedEnvGroups}
             setSyncedEnvGroups={setSyncedEnvGroups}
             setSyncedEnvGroups={setSyncedEnvGroups}
             namespace={appData.chart.namespace}
             namespace={appData.chart.namespace}

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

@@ -1,6 +1,6 @@
 import React, { useState, useContext, useEffect } from "react";
 import React, { useState, useContext, useEffect } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
-import { RouteComponentProps, withRouter } from "react-router";
+import { type RouteComponentProps, withRouter } from "react-router";
 import _ from "lodash";
 import _ from "lodash";
 import yaml from "js-yaml";
 import yaml from "js-yaml";
 
 
@@ -16,27 +16,27 @@ import Spacer from "components/porter/Spacer";
 import Input from "components/porter/Input";
 import Input from "components/porter/Input";
 import VerticalSteps from "components/porter/VerticalSteps";
 import VerticalSteps from "components/porter/VerticalSteps";
 import Button from "components/porter/Button";
 import Button from "components/porter/Button";
-import SourceSelector, { SourceType } from "./SourceSelector";
+import SourceSelector, { type SourceType } from "./SourceSelector";
 import Container from "components/porter/Container";
 import Container from "components/porter/Container";
 
 
 import SourceSettings from "./SourceSettings";
 import SourceSettings from "./SourceSettings";
 import Services from "./Services";
 import Services from "./Services";
-import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import GithubActionModal from "./GithubActionModal";
 import GithubActionModal from "./GithubActionModal";
 import Error from "components/porter/Error";
 import Error from "components/porter/Error";
-import { PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
+import { type PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
 import { ImageInfo, Service } from "./serviceTypes";
 import { ImageInfo, Service } from "./serviceTypes";
 import GithubConnectModal from "./GithubConnectModal";
 import GithubConnectModal from "./GithubConnectModal";
 import Link from "components/porter/Link";
 import Link from "components/porter/Link";
-import { BuildMethod, PorterApp } from "../types/porterApp";
-import { NewPopulatedEnvGroup, PartialEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
+import { type BuildMethod, PorterApp } from "../types/porterApp";
+import { type NewPopulatedEnvGroup, PartialEnvGroup, type PopulatedEnvGroup } from "components/porter-form/types";
 import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
 import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
 import EnvGroupModal from "../expanded-app/env-vars/EnvGroupModal";
 import EnvGroupModal from "../expanded-app/env-vars/EnvGroupModal";
 import ExpandableEnvGroup from "../expanded-app/env-vars/ExpandableEnvGroup";
 import ExpandableEnvGroup from "../expanded-app/env-vars/ExpandableEnvGroup";
 
 
 type Props = RouteComponentProps & {};
 type Props = RouteComponentProps & {};
 
 
-interface FormState {
+type FormState = {
   applicationName: string;
   applicationName: string;
   selectedSourceType: SourceType | undefined;
   selectedSourceType: SourceType | undefined;
   serviceList: Service[];
   serviceList: Service[];
@@ -63,12 +63,12 @@ type Detected = {
   detected: boolean;
   detected: boolean;
   message: string;
   message: string;
 };
 };
-interface GithubAppAccessData {
+type GithubAppAccessData = {
   username?: string;
   username?: string;
   accounts?: string[];
   accounts?: string[];
 }
 }
 
 
-interface PorterJsonWithPath {
+type PorterJsonWithPath = {
   porterYamlPath: string;
   porterYamlPath: string;
   porterJson: PorterJson;
   porterJson: PorterJson;
 }
 }
@@ -134,7 +134,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
 
         if (!Array.isArray(data)) {
         if (!Array.isArray(data)) {
           setHasProviders(false);
           setHasProviders(false);
-          return;
+          
         }
         }
       })
       })
       .catch((err) => {
       .catch((err) => {
@@ -214,7 +214,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
     try {
     try {
       parsedYaml = yaml.load(yamlString);
       parsedYaml = yaml.load(yamlString);
       const parsedData = PorterYamlSchema.parse(parsedYaml);
       const parsedData = PorterYamlSchema.parse(parsedYaml);
-      const porterYamlToJson = parsedData as PorterJson;
+      const porterYamlToJson = parsedData ;
       setPorterJsonWithPath({ porterJson: porterYamlToJson, porterYamlPath: filename });
       setPorterJsonWithPath({ porterJson: porterYamlToJson, porterYamlPath: filename });
       const newServices = [];
       const newServices = [];
       const existingServices = formState.serviceList.map((s) => s.name);
       const existingServices = formState.serviceList.map((s) => s.name);
@@ -338,7 +338,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         git_repo_id: porterApp.git_repo_id,
         git_repo_id: porterApp.git_repo_id,
         build_context: porterApp.build_context,
         build_context: porterApp.build_context,
         image_repo_uri: porterApp.image_repo_uri,
         image_repo_uri: porterApp.image_repo_uri,
-        environment_groups: syncedEnvGroups?.map((env: NewPopulatedEnvGroup) => env.name),
+        environmentGroups: syncedEnvGroups?.map((env: NewPopulatedEnvGroup) => env.name),
         user_update: true,
         user_update: true,
       }
       }
       if (porterApp.image_repo_uri && imageTag) {
       if (porterApp.image_repo_uri && imageTag) {
@@ -403,7 +403,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       <Div>
       <Div>
         {showConnectModal && !hasProviders && (
         {showConnectModal && !hasProviders && (
           <GithubConnectModal
           <GithubConnectModal
-            closeModal={() => setConnectModal(false)}
+            closeModal={() => { setConnectModal(false); }}
             hasClickedDoNotConnect={hasClickedDoNotConnect}
             hasClickedDoNotConnect={hasClickedDoNotConnect}
             handleDoNotConnect={handleDoNotConnect}
             handleDoNotConnect={handleDoNotConnect}
             accessData={accessData}
             accessData={accessData}
@@ -536,11 +536,11 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
 
                 <>
                 <>
                   <TooltipWrapper
                   <TooltipWrapper
-                    onMouseOver={() => setHovered(true)}
-                    onMouseOut={() => setHovered(false)}>
+                    onMouseOver={() => { setHovered(true); }}
+                    onMouseOut={() => { setHovered(false); }}>
                     <LoadButton
                     <LoadButton
                       disabled={maxEnvGroupsReached}
                       disabled={maxEnvGroupsReached}
-                      onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+                      onClick={() => { !maxEnvGroupsReached && setShowEnvModal(true); }}
                     >
                     >
                       <img src={sliders} /> Load from Env Group
                       <img src={sliders} /> Load from Env Group
                     </LoadButton>
                     </LoadButton>
@@ -552,7 +552,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                       setFormState({ ...formState, envVariables: x });
                       setFormState({ ...formState, envVariables: x });
                     }}
                     }}
                     values={formState.envVariables}
                     values={formState.envVariables}
-                    closeModal={() => setShowEnvModal(false)}
+                    closeModal={() => { setShowEnvModal(false); }}
                     syncedEnvGroups={syncedEnvGroups}
                     syncedEnvGroups={syncedEnvGroups}
                     setSyncedEnvGroups={setSyncedEnvGroups}
                     setSyncedEnvGroups={setSyncedEnvGroups}
                     namespace={"porter-stack-" + porterApp.name}
                     namespace={"porter-stack-" + porterApp.name}
@@ -628,7 +628,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       </Div>
       </Div>
       {showGHAModal && currentCluster != null && currentProject != null && (
       {showGHAModal && currentCluster != null && currentProject != null && (
         <GithubActionModal
         <GithubActionModal
-          closeModal={() => setShowGHAModal(false)}
+          closeModal={() => { setShowGHAModal(false); }}
           githubAppInstallationID={porterApp.git_repo_id}
           githubAppInstallationID={porterApp.git_repo_id}
           githubRepoOwner={porterApp.repo_name.split("/")[0]}
           githubRepoOwner={porterApp.repo_name.split("/")[0]}
           githubRepoName={porterApp.repo_name.split("/")[1]}
           githubRepoName={porterApp.repo_name.split("/")[1]}

+ 19 - 19
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx

@@ -1,22 +1,24 @@
-import { PorterAppFormData } from "lib/porter-apps";
+import { type PorterAppFormData } from "lib/porter-apps";
 import React, {
 import React, {
-  Dispatch,
-  SetStateAction,
+  type Dispatch,
+  type SetStateAction,
   useCallback,
   useCallback,
+  useEffect,
   useMemo,
   useMemo,
   useState,
   useState,
 } from "react";
 } from "react";
 import { UseFieldArrayAppend, useFormContext } from "react-hook-form";
 import { UseFieldArrayAppend, useFormContext } from "react-hook-form";
 
 
 import sliders from "assets/sliders.svg";
 import sliders from "assets/sliders.svg";
+import doppler from "assets/doppler.png";
 
 
-import { PopulatedEnvGroup } from "./types";
+import { type PopulatedEnvGroup } from "./types";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Modal from "components/porter/Modal";
 import Modal from "components/porter/Modal";
 import styled, { css } from "styled-components";
 import styled, { css } from "styled-components";
 import Button from "components/porter/Button";
 import Button from "components/porter/Button";
-import { IterableElement } from "type-fest";
+import { type IterableElement } from "type-fest";
 
 
 type Props = {
 type Props = {
   baseEnvGroups: PopulatedEnvGroup[];
   baseEnvGroups: PopulatedEnvGroup[];
@@ -50,7 +52,7 @@ const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
   }, [envGroups, baseEnvGroups]);
   }, [envGroups, baseEnvGroups]);
 
 
   return (
   return (
-    <Modal closeModal={() => setOpen(false)}>
+    <Modal closeModal={() => { setOpen(false); }}>
       <Text size={16}>Load env group</Text>
       <Text size={16}>Load env group</Text>
       <Spacer height="15px" />
       <Spacer height="15px" />
       <ColumnContainer>
       <ColumnContainer>
@@ -60,7 +62,7 @@ const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
               <Text color="helper">
               <Text color="helper">
                 Select an Env Group to load into your application.
                 Select an Env Group to load into your application.
               </Text>
               </Text>
-              <Spacer y={0.5} />
+              <Spacer y={1} />
               <GroupModalSections>
               <GroupModalSections>
                 <SidebarSection $expanded={!selectedEnvGroup}>
                 <SidebarSection $expanded={!selectedEnvGroup}>
                   <EnvGroupList>
                   <EnvGroupList>
@@ -72,9 +74,15 @@ const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
                           selectedEnvGroup?.name === eg.name
                           selectedEnvGroup?.name === eg.name
                         }
                         }
                         lastItem={i === remainingEnvGroupOptions?.length - 1}
                         lastItem={i === remainingEnvGroupOptions?.length - 1}
-                        onClick={() => setSelectedEnvGroup(eg)}
+                        onClick={() => {
+                          setSelectedEnvGroup(eg);
+                        }}
                       >
                       >
+                        {eg.type === "doppler" ? (
+                          <img src={doppler} />
+                            ) : (
                         <img src={sliders} />
                         <img src={sliders} />
+                        )}
                         {eg.name}
                         {eg.name}
                       </EnvGroupRow>
                       </EnvGroupRow>
                     ))}
                     ))}
@@ -114,11 +122,9 @@ const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
           )}
           )}
         </ScrollableContainer>
         </ScrollableContainer>
       </ColumnContainer>
       </ColumnContainer>
-      <SubmitButtonContainer>
-        <Button onClick={onSubmit} disabled={!selectedEnvGroup}>
-          Load Env Group
-        </Button>
-      </SubmitButtonContainer>
+      <Button onClick={onSubmit} disabled={!selectedEnvGroup}>
+        Load env group
+      </Button>
     </Modal>
     </Modal>
   );
   );
 };
 };
@@ -182,7 +188,6 @@ const GroupEnvPreview = styled.pre`
   }
   }
 `;
 `;
 const GroupModalSections = styled.div`
 const GroupModalSections = styled.div`
-  margin-top: 20px;
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
   display: grid;
   display: grid;
@@ -201,8 +206,3 @@ const ScrollableContainer = styled.div`
   overflow-y: auto;
   overflow-y: auto;
   max-height: 300px;
   max-height: 300px;
 `;
 `;
-
-const SubmitButtonContainer = styled.div`
-  margin-top: 10px;
-  text-align: right;
-`;

+ 11 - 8
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx

@@ -3,16 +3,18 @@ import styled from "styled-components";
 import { useFieldArray, useFormContext } from "react-hook-form";
 import { useFieldArray, useFormContext } from "react-hook-form";
 
 
 import sliders from "assets/sliders.svg";
 import sliders from "assets/sliders.svg";
+import doppler from "assets/doppler.png";
 
 
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
-import { PorterAppFormData } from "lib/porter-apps";
+import { type PorterAppFormData } from "lib/porter-apps";
 import ExpandableEnvGroup from "./ExpandableEnvGroup";
 import ExpandableEnvGroup from "./ExpandableEnvGroup";
-import { PopulatedEnvGroup } from "./types";
+import { type PopulatedEnvGroup } from "./types";
 
 
 import { valueExists } from "shared/util";
 import { valueExists } from "shared/util";
 import EnvGroupModal from "./EnvGroupModal";
 import EnvGroupModal from "./EnvGroupModal";
-import { IterableElement } from "type-fest";
+import { type IterableElement } from "type-fest";
+import Icon from "components/porter/Icon";
 
 
 type Props = {
 type Props = {
   baseEnvGroups?: PopulatedEnvGroup[];
   baseEnvGroups?: PopulatedEnvGroup[];
@@ -76,7 +78,7 @@ const EnvGroups: React.FC<Props> = ({
 
 
   const onAdd = (
   const onAdd = (
     inp: IterableElement<PorterAppFormData["app"]["envGroups"]>
     inp: IterableElement<PorterAppFormData["app"]["envGroups"]>
-  ) => {
+  ): void => {
     const previouslyDeleted = deletedEnvGroups.findIndex(
     const previouslyDeleted = deletedEnvGroups.findIndex(
       (s) => s.name === inp.name
       (s) => s.name === inp.name
     );
     );
@@ -88,7 +90,7 @@ const EnvGroups: React.FC<Props> = ({
     append(inp);
     append(inp);
   };
   };
 
 
-  const onRemove = (index: number) => {
+  const onRemove = (index: number): void => {
     const name = populatedEnvWithFallback[index].envGroup.name;
     const name = populatedEnvWithFallback[index].envGroup.name;
     remove(index);
     remove(index);
 
 
@@ -101,12 +103,12 @@ const EnvGroups: React.FC<Props> = ({
   return (
   return (
     <div>
     <div>
       <TooltipWrapper
       <TooltipWrapper
-        onMouseOver={() => setHovered(true)}
-        onMouseOut={() => setHovered(false)}
+        onMouseOver={() => { setHovered(true); }}
+        onMouseOut={() => { setHovered(false); }}
       >
       >
         <LoadButton
         <LoadButton
           disabled={maxEnvGroupsReached}
           disabled={maxEnvGroupsReached}
-          onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+          onClick={() => { !maxEnvGroupsReached && setShowEnvModal(true); }}
         >
         >
           <img src={sliders} /> Load from Env Group
           <img src={sliders} /> Load from Env Group
         </LoadButton>
         </LoadButton>
@@ -125,6 +127,7 @@ const EnvGroups: React.FC<Props> = ({
                 index={index}
                 index={index}
                 envGroup={envGroup}
                 envGroup={envGroup}
                 remove={onRemove}
                 remove={onRemove}
+                icon={<Icon src={envGroup.type === "doppler" ? doppler : sliders} />}
               />
               />
             );
             );
           })}
           })}

+ 8 - 11
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx

@@ -1,32 +1,36 @@
 import React, { useState } from "react";
 import React, { useState } from "react";
 import styled, { keyframes } from "styled-components";
 import styled, { keyframes } from "styled-components";
-import { PopulatedEnvGroup } from "./types";
+import { type PopulatedEnvGroup } from "./types";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
+import Icon from "components/porter/Icon";
 
 
 type Props = {
 type Props = {
   index: number;
   index: number;
   remove: (index: number) => void;
   remove: (index: number) => void;
   envGroup: PopulatedEnvGroup;
   envGroup: PopulatedEnvGroup;
+  icon: JSX.Element;
 };
 };
 
 
-const ExpandableEnvGroup: React.FC<Props> = ({ index, remove, envGroup }) => {
+const ExpandableEnvGroup: React.FC<Props> = ({ index, remove, envGroup, icon }) => {
   const [isExpanded, setIsExpanded] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
 
   return (
   return (
     <StyledCard>
     <StyledCard>
       <Flex>
       <Flex>
+        {icon}
+        <Spacer inline x={1} />
         <ContentContainer>
         <ContentContainer>
           <EventInformation>
           <EventInformation>
             <EventName>{envGroup.name}</EventName>
             <EventName>{envGroup.name}</EventName>
           </EventInformation>
           </EventInformation>
         </ContentContainer>
         </ContentContainer>
         <ActionContainer>
         <ActionContainer>
-          <ActionButton type="button" onClick={() => remove(index)}>
+          <ActionButton type="button" onClick={() => { remove(index); }}>
             <span className="material-icons">delete</span>
             <span className="material-icons">delete</span>
           </ActionButton>
           </ActionButton>
           <ActionButton
           <ActionButton
             type="button"
             type="button"
-            onClick={() => setIsExpanded((prev) => !prev)}
+            onClick={() => { setIsExpanded((prev) => !prev); }}
           >
           >
             <i className="material-icons">
             <i className="material-icons">
               {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
               {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
@@ -165,13 +169,6 @@ const ActionButton = styled.button`
   }
   }
 `;
 `;
 
 
-const NoVariablesTextWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff99;
-`;
-
 const InputWrapper = styled.div`
 const InputWrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts

@@ -2,6 +2,7 @@ import { z } from "zod";
 
 
 export const populatedEnvGroup = z.object({
 export const populatedEnvGroup = z.object({
   name: z.string(),
   name: z.string(),
+  type: z.string(),
   latest_version: z.coerce.bigint(),
   latest_version: z.coerce.bigint(),
   variables: z.record(z.string()).optional().default({}),
   variables: z.record(z.string()).optional().default({}),
   secret_variables: z.record(z.string()).optional().default({}),
   secret_variables: z.record(z.string()).optional().default({}),

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

@@ -1,7 +1,8 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
-import key from "assets/key.svg";
+import sliders from "assets/sliders.svg";
+import doppler from "assets/doppler.png";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
@@ -10,6 +11,7 @@ import _ from "lodash";
 
 
 export type EnvGroupData = {
 export type EnvGroupData = {
   name: string;
   name: string;
+  type?: string;
   namespace: string;
   namespace: string;
   created_at?: string;
   created_at?: string;
   version: number;
   version: number;
@@ -29,18 +31,18 @@ export default class EnvGroup extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   render() {
-    let { envGroup } = this.props;
-    let name = envGroup?.name;
-    let timestamp = envGroup?.created_at;
-    let namespace = envGroup?.namespace;
-    let version = this.context?.currentProject.simplified_view_enabled ? envGroup?.latest_version : envGroup?.version ;
+    const { envGroup } = this.props;
+    const name = envGroup?.name;
+    const timestamp = envGroup?.created_at;
+    const namespace = envGroup?.namespace;
+    const version = this.context?.currentProject.simplified_view_enabled ? envGroup?.latest_version : envGroup?.version ;
 
 
     return (
     return (
       <Link to={`/env-groups/${name}${window.location.search}`} target="_self">
       <Link to={`/env-groups/${name}${window.location.search}`} target="_self">
         <StyledEnvGroup>
         <StyledEnvGroup>
           <Title>
           <Title>
             <IconWrapper>
             <IconWrapper>
-              <Icon src={key} />
+              <Icon src={envGroup.type === "doppler" ? doppler : sliders} />
             </IconWrapper>
             </IconWrapper>
             {name}
             {name}
           </Title>
           </Title>

+ 18 - 14
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -280,12 +280,13 @@ export const ExpandedEnvGroupFC = ({
   };
   };
 
 
   const deleteEnvGroup = async () => {
   const deleteEnvGroup = async () => {
-    const { name, stack_id } = currentEnvGroup;
+    const { name, stack_id, type } = currentEnvGroup;
     if (currentProject?.simplified_view_enabled) {
     if (currentProject?.simplified_view_enabled) {
       return await api.deleteNewEnvGroup(
       return await api.deleteNewEnvGroup(
         "<token>",
         "<token>",
         {
         {
           name,
           name,
+          type,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
@@ -480,7 +481,7 @@ export const ExpandedEnvGroupFC = ({
           git_branch: newAppData?.git_branch,
           git_branch: newAppData?.git_branch,
           buildpacks: "",
           buildpacks: "",
           // full_helm_values: yaml.dump(values),
           // full_helm_values: yaml.dump(values),
-          environment_groups: filteredEnvGroups?.map((env) => env.name),
+          environmentGroups: filteredEnvGroups?.map((env) => env.name),
           user_update: true,
           user_update: true,
         }
         }
 
 
@@ -608,18 +609,21 @@ export const ExpandedEnvGroupFC = ({
 
 
 
 
           const linkedApp: string[] = currentEnvGroup?.linked_applications;
           const linkedApp: string[] = currentEnvGroup?.linked_applications;
-          await api.createEnvironmentGroups(
-            "<token>",
-            {
-              name,
-              variables: normalVariables,
-              secret_variables: secretVariables,
-            },
-            {
-              id: currentProject.id,
-              cluster_id: currentCluster.id,
-            }
-          );
+          // doppler env groups update themselves, and we don't want to increment the version
+          if (currentEnvGroup?.type !== "doppler") {
+            await api.createEnvironmentGroups(
+                "<token>",
+                {
+                  name,
+                  variables: normalVariables,
+                  secret_variables: secretVariables,
+                },
+                {
+                  id: currentProject.id,
+                  cluster_id: currentCluster.id,
+                }
+            );
+          }
           if (!currentProject.validate_apply_v2) {
           if (!currentProject.validate_apply_v2) {
             if (linkedApp) {
             if (linkedApp) {
               const promises = linkedApp.map(async appName => {
               const promises = linkedApp.map(async appName => {

+ 263 - 0
dashboard/src/main/home/integrations/DopplerIntegrationList.tsx

@@ -0,0 +1,263 @@
+import React, { useContext, useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import { useHistory } from "react-router";
+import { z } from "zod";
+
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import Banner from "components/porter/Banner";
+import Button from "components/porter/Button";
+import Input from "components/porter/Input";
+import Link from "components/porter/Link";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import ToggleRow from "components/porter/ToggleRow";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+const DopplerIntegrationList: React.FC = (_) => {
+  const history = useHistory();
+  const [dopplerToggled, setDopplerToggled] = useState<boolean>(false);
+  const [dopplerEnabled, setDopplerEnabled] = useState<boolean>(false);
+  const [dopplerEnvGroupCreationError, setDopplerEnvGroupCreationError] =
+    useState<string>("");
+  const [dopplerEnvGroupCreationStatus, setDopplerEnvGroupCreationStatus] =
+    useState<string>("");
+  const [showServiceTokenModal, setShowServiceTokenModal] =
+    useState<boolean>(false);
+  const [envGroupName, setEnvGroupName] = useState<string>("");
+  const [dopplerServiceToken, setDopplerServiceToken] = useState<string>("");
+
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const {
+    data: externalProviderStatus,
+    isLoading: isExternalProviderStatusLoading,
+  } = useQuery(
+    [
+      "areExternalEnvGroupProvidersEnabled",
+      currentProject?.id,
+      currentCluster?.id,
+    ],
+    async () => {
+      const res = await api.areExternalEnvGroupProvidersEnabled(
+        "<token>",
+        {},
+        { id: currentProject?.id, cluster_id: currentCluster?.id }
+      );
+      const externalEnvGroupProviderStatus = await z
+        .object({
+          enabled: z.boolean(),
+          reprovision_required: z.boolean(),
+          k8s_upgrade_required: z.boolean(),
+        })
+        .parseAsync(res.data);
+
+      return externalEnvGroupProviderStatus;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  useEffect(() => {
+    if (externalProviderStatus) {
+      setDopplerToggled(externalProviderStatus.enabled);
+      setDopplerEnabled(externalProviderStatus.enabled);
+    }
+  }, [externalProviderStatus]);
+
+  const installDoppler = (): void => {
+    if (!currentCluster || !currentProject) {
+      return;
+    }
+
+    setDopplerToggled(true);
+
+    api
+      .enableExternalEnvGroupProviders(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .catch(() => {
+        setDopplerToggled(false);
+      });
+  };
+
+  // Install the CRD for a new Doppler secret
+  const addDopplerEnvGroup = (): void => {
+    if (!currentCluster || !currentProject) {
+      return;
+    }
+    setDopplerEnvGroupCreationStatus("loading");
+    api
+      .createEnvironmentGroups(
+        "<token>",
+        {
+          name: envGroupName,
+          type: "doppler",
+          auth_token: dopplerServiceToken,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(() => {
+        setShowServiceTokenModal(false);
+        history.push("/env-groups");
+      })
+      .catch((err) => {
+        let message =
+          "Env group creation failed: please try again or contact support@porter.run if the error persists.";
+
+        if (axios.isAxiosError(err)) {
+          const parsed = z
+            .object({ error: z.string() })
+            .safeParse(err.response?.data);
+          if (parsed.success) {
+            message = `Env group creation failed: ${parsed.data.error}`;
+          }
+        }
+        setDopplerEnvGroupCreationError(message);
+        setDopplerEnvGroupCreationStatus("error");
+      });
+  };
+
+  if (!dopplerEnabled) {
+    return (
+      <>
+        {isExternalProviderStatusLoading ? (
+          <Placeholder>
+            <Loading message={"Checking status of Doppler integration..."} />
+          </Placeholder>
+        ) : externalProviderStatus?.k8s_upgrade_required ? (
+          <Placeholder>
+            Cluster must be upgraded to Kubernetes v1.27 to integrate with
+            Doppler.
+          </Placeholder>
+        ) : externalProviderStatus?.reprovision_required ? (
+          <Placeholder>
+            To enable integration with Doppler, <Spacer inline x={0.5} />
+            <Link to={`/cluster-dashboard`} hasunderline>
+              re-provision your cluster
+            </Link>
+            .
+          </Placeholder>
+        ) : (
+          <>
+            <Banner icon="none">
+              <ToggleRow
+                isToggled={dopplerToggled}
+                onToggle={installDoppler}
+                disabled={dopplerToggled}
+              >
+                {dopplerToggled
+                  ? "Enabling Doppler integration . . ."
+                  : "Enable Doppler integration"}
+              </ToggleRow>
+            </Banner>
+            <Spacer y={1} />
+            <Placeholder>
+              Enable the Doppler integration to add environment groups from
+              Doppler.
+            </Placeholder>
+          </>
+        )}
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Banner icon="none">
+        <ToggleRow
+          isToggled={dopplerToggled}
+          onToggle={installDoppler}
+          disabled={dopplerToggled}
+        >
+          {dopplerToggled
+            ? dopplerEnabled
+              ? "Doppler integration enabled"
+              : "Enabling Doppler integration . . ."
+            : "Enable Doppler integration"}
+        </ToggleRow>
+      </Banner>
+      <Spacer y={1} />
+      <Button
+        onClick={() => {
+          setShowServiceTokenModal(true);
+        }}
+      >
+        + Add Doppler env group
+      </Button>
+
+      {showServiceTokenModal && (
+        <Modal
+          closeModal={() => {
+            setShowServiceTokenModal(false);
+            setDopplerEnvGroupCreationError("");
+            setDopplerEnvGroupCreationStatus("");
+            setEnvGroupName("");
+            setDopplerServiceToken("");
+          }}
+        >
+          <Text size={16}>Add a new Doppler service token</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Your Doppler secrets will be made available to Porter apps as an
+            environment group.
+          </Text>
+          <Spacer y={1} />
+          <Input
+            placeholder="ex: my-doppler-env"
+            label="Env group name (vanity name for Porter)"
+            value={envGroupName}
+            setValue={(x) => {
+              setEnvGroupName(x);
+            }}
+            width="100%"
+            height="40px"
+          />
+          <Spacer y={1} />
+          <Input
+            type="password"
+            placeholder="ex: dp.st...abcdef"
+            label="Doppler service token"
+            value={dopplerServiceToken}
+            setValue={(x) => {
+              setDopplerServiceToken(x);
+            }}
+            width="100%"
+            height="40px"
+          />
+          <Spacer y={1} />
+          <Button
+            onClick={addDopplerEnvGroup}
+            disabled={
+              envGroupName === "" ||
+              dopplerServiceToken === "" ||
+              dopplerEnvGroupCreationStatus === "loading"
+            }
+            status={dopplerEnvGroupCreationStatus}
+            errorText={dopplerEnvGroupCreationError}
+            width="180px"
+          >
+            Add Doppler env group
+          </Button>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default DopplerIntegrationList;

+ 36 - 25
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -5,6 +5,7 @@ import { Context } from "shared/Context";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import IntegrationList from "./IntegrationList";
+import DopplerIntegrationList from "./DopplerIntegrationList";
 import api from "shared/api";
 import api from "shared/api";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
 import Loading from "../../../components/Loading";
 import Loading from "../../../components/Loading";
@@ -102,6 +103,9 @@ const IntegrationCategories: React.FC<Props> = (props) => {
 
 
   useEffect(() => {
   useEffect(() => {
     getIntegrationsForCategory(props.category);
     getIntegrationsForCategory(props.category);
+    if (props.category === "doppler") {
+      setLoading(false);
+    }
   }, [props.category]);
   }, [props.category]);
 
 
   const { category: currentCategory } = props;
   const { category: currentCategory } = props;
@@ -127,30 +131,35 @@ const IntegrationCategories: React.FC<Props> = (props) => {
         <TitleSection icon={icon} iconWidth="32px">
         <TitleSection icon={icon} iconWidth="32px">
           {label}
           {label}
         </TitleSection>
         </TitleSection>
-        <Button
-          onClick={() => {
-            if (props.category === "gitlab") {
-              pushFiltered(props, `/integrations/gitlab/create/gitlab`, [
-                "project_id",
-              ]);
-            } else if (props.category != "slack") {
-              setCurrentModal("IntegrationsModal", {
-                category: currentCategory,
-                setCurrentIntegration: (x: string) =>
-                  pushFiltered(
-                    props,
-                    `/integrations/${props.category}/create/${x}`,
-                    ["project_id"]
-                  ),
-              });
-            } else {
-              window.location.href = `/api/projects/${currentProject.id}/oauth/slack`;
-            }
-          }}
-        >
-          <i className="material-icons">add</i>
-          {buttonText}
-        </Button>
+        {props.category === "doppler" ? null : (
+          <Button
+            onClick={() => {
+              if (props.category === "gitlab") {
+                pushFiltered(props, `/integrations/gitlab/create/gitlab`, [
+                  "project_id",
+                ]);
+              } else if (props.category === "doppler") {
+                // ret2
+              } else if (props.category !== "slack") {
+                setCurrentModal("IntegrationsModal", {
+                  category: currentCategory,
+                  setCurrentIntegration: (x: string) => {
+                    pushFiltered(
+                      props,
+                      `/integrations/${props.category}/create/${x}`,
+                      ["project_id"]
+                    )
+                  }
+                });
+              } else {
+                window.location.href = `/api/projects/${currentProject.id}/oauth/slack`;
+              }
+            }}
+          >
+            <i className="material-icons">add</i>
+            {buttonText}
+          </Button>
+        )}
       </Flex>
       </Flex>
       <Spacer y={1} />
       <Spacer y={1} />
       {loading ? (
       {loading ? (
@@ -162,8 +171,10 @@ const IntegrationCategories: React.FC<Props> = (props) => {
             getIntegrationsForCategory(props.category)
             getIntegrationsForCategory(props.category)
           }
           }
         />
         />
-      ) : props.category == "slack" ? (
+      ) : props.category === "slack" ? (
         <SlackIntegrationList slackData={slackData} />
         <SlackIntegrationList slackData={slackData} />
+      ) : props.category === "doppler" ? (
+        <DopplerIntegrationList />
       ) : (
       ) : (
         <IntegrationList
         <IntegrationList
           currentCategory={props.category}
           currentCategory={props.category}

+ 2 - 2
dashboard/src/main/home/integrations/Integrations.tsx

@@ -21,10 +21,10 @@ const Integrations: React.FC<PropsType> = (props) => {
 
 
   const IntegrationCategoryStrings = useMemo(() => {
   const IntegrationCategoryStrings = useMemo(() => {
     if (!enableGitlab) {
     if (!enableGitlab) {
-      return ["registry", "slack"];
+      return ["registry", "slack", "doppler"];
     }
     }
 
 
-    return ["registry", "slack", "gitlab"];
+    return ["registry", "slack", "doppler", "gitlab"];
   }, [enableGitlab]);
   }, [enableGitlab]);
 
 
   return (
   return (

+ 240 - 0
dashboard/src/main/home/integrations/edit-integration/GitlabIntegrationList.tsx

@@ -0,0 +1,240 @@
+import React, { useContext, useRef, useState } from "react";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import styled from "styled-components";
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import { integrationList } from "shared/common";
+import DynamicLink from "components/DynamicLink";
+
+interface Props {
+  gitlabData: any[];
+  updateIntegrationList: () => void;
+}
+
+type StateType = {
+  isDelete: boolean;
+  deleteName: string;
+  deleteID: number;
+};
+
+const GitlabIntegrationList: React.FC<Props> = (props) => {
+  const [currentState, setCurrentState] = useState<StateType>({
+    isDelete: false,
+    deleteName: "",
+    deleteID: 0,
+  });
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const handleDeleteIntegration = () => {
+    api
+      .deleteGitlabIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          integration_id: currentState.deleteID,
+        }
+      )
+      .then(() => {
+        setCurrentState({
+          isDelete: false,
+          deleteName: "",
+          deleteID: 0,
+        });
+        props.updateIntegrationList();
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <>
+      <ConfirmOverlay
+        show={currentState.isDelete}
+        message={`Are you sure you want to delete the GitLab integration for instance ${currentState.deleteName}?`}
+        onYes={handleDeleteIntegration}
+        onNo={() =>
+          setCurrentState({
+            isDelete: false,
+            deleteName: "",
+            deleteID: 0,
+          })
+        }
+      />
+      <StyledIntegrationList>
+        {props.gitlabData?.length > 0 ? (
+          props.gitlabData.map((inst, idx) => {
+            return (
+              <Integration onClick={() => {}} disabled={false} key={inst.id}>
+                <MainRow disabled={false}>
+                  <Flex>
+                    <Icon src={integrationList.gitlab.icon} />
+                    <Label>{inst.instance_url}</Label>
+                    {inst.username.includes("Unable") ? (
+                      <ErrorLabel>[{inst.username}]</ErrorLabel>
+                    ) : (
+                      <UsernameLabel>({inst.username})</UsernameLabel>
+                    )}
+                  </Flex>
+                  <MaterialIconTray disabled={false}>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        setCurrentState({
+                          isDelete: true,
+                          deleteName: inst.instance_url,
+                          deleteID: inst.id,
+                        });
+                      }}
+                    >
+                      delete
+                    </i>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        window.open(inst.instance_url, "_blank");
+                      }}
+                    >
+                      launch
+                    </i>
+                  </MaterialIconTray>
+                </MainRow>
+              </Integration>
+            );
+          })
+        ) : (
+          <Placeholder>No GitLab instances found</Placeholder>
+        )}
+      </StyledIntegrationList>
+    </>
+  );
+};
+
+export default GitlabIntegrationList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 250px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  justify-content: center;
+  margin-top: 30px;
+  background: #ffffff11;
+  color: #ffffff44;
+  border-radius: 5px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const UsernameLabel = styled.div`
+  color: #ffffff66;
+  font-size: 14px;
+  font-weight: 500;
+  padding: 10px;
+`;
+
+const ErrorLabel = styled.div`
+  color: #f6685e;
+  font-size: 14px;
+  font-weight: 500;
+  padding: 10px;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+  margin-bottom: 80px;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;

+ 29 - 2
dashboard/src/shared/api.tsx

@@ -2072,7 +2072,9 @@ const upgradeChartValues = baseApi<
 });
 });
 
 
 const getAllEnvGroups = baseApi<
 const getAllEnvGroups = baseApi<
-  {},
+  {
+    type?: string;
+  },
   {
   {
     id: number;
     id: number;
     cluster_id: number;
     cluster_id: number;
@@ -2178,8 +2180,10 @@ const createEnvGroup = baseApi<
 const createEnvironmentGroups = baseApi<
 const createEnvironmentGroups = baseApi<
   {
   {
     name: string;
     name: string;
-    variables: Record<string, string>;
+    variables?: Record<string, string>;
     secret_variables?: Record<string, string>;
     secret_variables?: Record<string, string>;
+    type?: string;
+    auth_token?: string;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -2189,6 +2193,26 @@ const createEnvironmentGroups = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
 });
 });
 
 
+const enableExternalEnvGroupProviders = baseApi<
+  {},
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups/enable-external-providers`;
+});
+
+const areExternalEnvGroupProvidersEnabled = baseApi<
+  {},
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups/are-external-providers-enabled`;
+});
+
 const cloneEnvGroup = baseApi<
 const cloneEnvGroup = baseApi<
   {
   {
     name: string;
     name: string;
@@ -2301,6 +2325,7 @@ const deleteEnvGroup = baseApi<
 const deleteNewEnvGroup = baseApi<
 const deleteNewEnvGroup = baseApi<
   {
   {
     name: string;
     name: string;
+    type?: string;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -3246,6 +3271,8 @@ export default {
   createEmailVerification,
   createEmailVerification,
   createEnvironment,
   createEnvironment,
   createEnvironmentGroups,
   createEnvironmentGroups,
+  enableExternalEnvGroupProviders,
+  areExternalEnvGroupProvidersEnabled,
   updateEnvironment,
   updateEnvironment,
   deleteEnvironment,
   deleteEnvironment,
   createPreviewEnvironmentDeployment,
   createPreviewEnvironmentDeployment,

+ 8 - 2
dashboard/src/shared/common.tsx

@@ -3,6 +3,7 @@ import digitalOcean from "../assets/do.png";
 import gcp from "../assets/gcp.png";
 import gcp from "../assets/gcp.png";
 import github from "../assets/github.png";
 import github from "../assets/github.png";
 import azure from "assets/azure.png";
 import azure from "assets/azure.png";
+import doppler from "assets/doppler.png";
 
 
 export const infraNames: any = {
 export const infraNames: any = {
   ecr: "Elastic Container Registry (ECR)",
   ecr: "Elastic Container Registry (ECR)",
@@ -14,16 +15,21 @@ export const infraNames: any = {
 };
 };
 
 
 export const integrationList: any = {
 export const integrationList: any = {
+  doppler: {
+    icon: doppler,
+    label: "Doppler",
+    buttonText: "Add a service token",
+  },
   kubernetes: {
   kubernetes: {
     icon:
     icon:
       "https://upload.wikimedia.org/wikipedia/labs/thumb/b/ba/Kubernetes-icon-color.svg/2110px-Kubernetes-icon-color.svg.png",
       "https://upload.wikimedia.org/wikipedia/labs/thumb/b/ba/Kubernetes-icon-color.svg/2110px-Kubernetes-icon-color.svg.png",
     label: "Kubernetes",
     label: "Kubernetes",
-    buttonText: "Add a Cluster",
+    buttonText: "Add a cluster",
   },
   },
   repo: {
   repo: {
     icon: "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png",
     icon: "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png",
     label: "Git Repository",
     label: "Git Repository",
-    buttonText: "Link a Github Account",
+    buttonText: "Link a GitHub account",
   },
   },
   slack: {
   slack: {
     icon:
     icon:

+ 0 - 2
go.sum

@@ -1520,8 +1520,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.59 h1:EV2xr9a5FpPHnTsz77W3dV/qWC8MnE0kOD+tBZuwhvE=
-github.com/porter-dev/api-contracts v0.2.59/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.64 h1:qLRomQRoOWqSMo8lx/rY+jACiIeCAExog04KfgPlXxY=
 github.com/porter-dev/api-contracts v0.2.64 h1:qLRomQRoOWqSMo8lx/rY+jACiIeCAExog04KfgPlXxY=
 github.com/porter-dev/api-contracts v0.2.64/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.64/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=

+ 5 - 0
internal/kubernetes/environment_groups/list.go

@@ -18,6 +18,7 @@ const (
 	LabelKey_LinkedEnvironmentGroup  = "porter.run/linked-environment-group"
 	LabelKey_LinkedEnvironmentGroup  = "porter.run/linked-environment-group"
 	LabelKey_EnvironmentGroupVersion = "porter.run/environment-group-version"
 	LabelKey_EnvironmentGroupVersion = "porter.run/environment-group-version"
 	LabelKey_EnvironmentGroupName    = "porter.run/environment-group-name"
 	LabelKey_EnvironmentGroupName    = "porter.run/environment-group-name"
+	LabelKey_EnvironmentGroupType    = "porter.run/environment-group-type"
 	// LabelKey_PorterManaged is the label key signifying the resource is managed by porter
 	// LabelKey_PorterManaged is the label key signifying the resource is managed by porter
 	LabelKey_PorterManaged = "porter.run/managed"
 	LabelKey_PorterManaged = "porter.run/managed"
 
 
@@ -33,6 +34,8 @@ const (
 
 
 // EnvironmentGroup represents a ConfigMap in the porter-env-group namespace
 // EnvironmentGroup represents a ConfigMap in the porter-env-group namespace
 type EnvironmentGroup struct {
 type EnvironmentGroup struct {
+	// Type is the type of environment group
+	Type string `json:"type"`
 	// Name is the environment group name which can be found in the labels (LabelKey_EnvironmentGroupName) of the ConfigMap. This is NOT the configmap name
 	// Name is the environment group name which can be found in the labels (LabelKey_EnvironmentGroupName) of the ConfigMap. This is NOT the configmap name
 	Name string `json:"name"`
 	Name string `json:"name"`
 	// Version is the environment group version which can be found in the labels (LabelKey_EnvironmentGroupVersion) of the ConfigMap. This is NOT included in the configmap name
 	// Version is the environment group version which can be found in the labels (LabelKey_EnvironmentGroupVersion) of the ConfigMap. This is NOT included in the configmap name
@@ -153,6 +156,7 @@ func listEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ..
 			envGroupSet[cm.Name] = EnvironmentGroup{}
 			envGroupSet[cm.Name] = EnvironmentGroup{}
 		}
 		}
 		envGroupSet[cm.Name] = EnvironmentGroup{
 		envGroupSet[cm.Name] = EnvironmentGroup{
+			Type:                  cm.Labels[LabelKey_EnvironmentGroupType],
 			Name:                  name,
 			Name:                  name,
 			Version:               version,
 			Version:               version,
 			Variables:             cm.Data,
 			Variables:             cm.Data,
@@ -192,6 +196,7 @@ func listEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ..
 			envGroupSet[secret.Name] = EnvironmentGroup{}
 			envGroupSet[secret.Name] = EnvironmentGroup{}
 		}
 		}
 		envGroupSet[secret.Name] = EnvironmentGroup{
 		envGroupSet[secret.Name] = EnvironmentGroup{
+			Type:                  secret.Labels[LabelKey_EnvironmentGroupType],
 			Name:                  name,
 			Name:                  name,
 			Version:               version,
 			Version:               version,
 			SecretVariables:       stringSecret,
 			SecretVariables:       stringSecret,

+ 1 - 1
internal/porter_app/environment.go

@@ -56,7 +56,7 @@ func AppEnvironmentFromProto(ctx context.Context, inp AppEnvironmentFromProtoInp
 	ctx, span := telemetry.NewSpan(ctx, "porter-app-env-from-proto")
 	ctx, span := telemetry.NewSpan(ctx, "porter-app-env-from-proto")
 	defer span.End()
 	defer span.End()
 
 
-	envGroups := []environment_groups.EnvironmentGroup{}
+	var envGroups []environment_groups.EnvironmentGroup
 
 
 	if inp.ProjectID == 0 {
 	if inp.ProjectID == 0 {
 		return nil, telemetry.Error(ctx, span, nil, "must provide a project id")
 		return nil, telemetry.Error(ctx, span, nil, "must provide a project id")