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

Merge branch 'master' into gpu-fix

sdess09 2 лет назад
Родитель
Сommit
8ac80a07a2
35 измененных файлов с 757 добавлено и 554 удалено
  1. 28 2
      api/server/handlers/deployment_target/get.go
  2. 31 1
      api/server/handlers/porter_app/default_deployment_target.go
  3. 1 1
      api/server/handlers/porter_app/get_app_env.go
  4. 1 1
      api/server/handlers/porter_app/get_build.go
  5. 1 1
      api/server/handlers/porter_app/get_build_env.go
  6. 46 13
      api/server/handlers/porter_app/latest_app_revisions.go
  7. 2 2
      api/server/handlers/porter_app/report_status.go
  8. 54 3
      api/server/handlers/porter_app/yaml_from_revision.go
  9. 6 4
      api/types/deployment_target.go
  10. 37 35
      api/types/project.go
  11. 7 7
      dashboard/package-lock.json
  12. 1 1
      dashboard/package.json
  13. 5 0
      dashboard/src/assets/dot-vertical.svg
  14. 15 27
      dashboard/src/components/AzureCostConsent.tsx
  15. 78 26
      dashboard/src/components/AzureProvisionerSettings.tsx
  16. 89 18
      dashboard/src/lib/hooks/useDeploymentTarget.ts
  17. 6 1
      dashboard/src/lib/revisions/types.ts
  18. 25 22
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  19. 5 5
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  20. 3 47
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  21. 29 21
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  22. 57 96
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  23. 50 9
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  24. 7 5
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  25. 8 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  26. 15 109
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  27. 70 20
      dashboard/src/shared/DeploymentTargetContext.tsx
  28. 2 1
      dashboard/src/shared/api.tsx
  29. 1 0
      dashboard/src/shared/types.tsx
  30. 1 1
      go.mod
  31. 2 6
      go.sum
  32. 4 4
      internal/kubernetes/prometheus/metrics.go
  33. 9 7
      internal/models/deployment_target.go
  34. 45 40
      internal/models/project.go
  35. 16 10
      internal/porter_app/revisions.go

+ 28 - 2
api/server/handlers/deployment_target/get.go

@@ -2,6 +2,9 @@ package deployment_target
 
 import (
 	"net/http"
+	"time"
+
+	"github.com/google/uuid"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -37,7 +40,7 @@ type GetDeploymentTargetRequest struct {
 
 // GetDeploymentTargetResponse is the response object for the /deployment-targets/{deployment_target_id} GET endpoint
 type GetDeploymentTargetResponse struct {
-	DeploymentTarget deployment_target.DeploymentTarget `json:"deployment_target"`
+	DeploymentTarget types.DeploymentTarget `json:"deployment_target"`
 }
 
 func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -77,8 +80,31 @@ func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		return
 	}
 
+	id, err := uuid.Parse(deploymentTarget.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if id == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "deployment target id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	res := &GetDeploymentTargetResponse{
-		DeploymentTarget: deploymentTarget,
+		DeploymentTarget: types.DeploymentTarget{
+			ID:        id,
+			ProjectID: project.ID,
+			ClusterID: cluster.ID,
+			Name:      deploymentTarget.Name,
+			Namespace: deploymentTarget.Namespace,
+			IsPreview: deploymentTarget.IsPreview,
+			IsDefault: deploymentTarget.IsDefault,
+			CreatedAt: time.Time{}, // not provided by deployment target details response
+			UpdatedAt: time.Time{}, // not provided by deployment target details response
+		},
 	}
 
 	c.WriteResult(w, r, res)

+ 31 - 1
api/server/handlers/porter_app/default_deployment_target.go

@@ -2,6 +2,9 @@ package porter_app
 
 import (
 	"net/http"
+	"time"
+
+	"github.com/google/uuid"
 
 	"connectrpc.com/connect"
 
@@ -37,7 +40,9 @@ type DefaultDeploymentTargetRequest struct{}
 
 // DefaultDeploymentTargetResponse is the response object for the /default-deployment-target endpoint
 type DefaultDeploymentTargetResponse struct {
-	DeploymentTargetID string `json:"deployment_target_id"`
+	// Deprecated: use inline types.DeploymentTarget fields instead
+	DeploymentTargetID     string `json:"deployment_target_id"`
+	types.DeploymentTarget `json:"deployment_target"`
 }
 
 const (
@@ -79,8 +84,33 @@ func (c *DefaultDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *htt
 	}
 
 	defaultDeploymentTarget := defaultDeploymentTargetResp.Msg.DeploymentTarget
+
+	id, err := uuid.Parse(defaultDeploymentTarget.Id)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing default deployment target id")
+		c.WriteResult(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if id == uuid.Nil {
+		err := telemetry.Error(ctx, span, nil, "default deployment target id is nil")
+		c.WriteResult(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	response := &DefaultDeploymentTargetResponse{
 		DeploymentTargetID: defaultDeploymentTarget.Id,
+		DeploymentTarget: types.DeploymentTarget{
+			ID:        id,
+			ProjectID: uint(defaultDeploymentTarget.ProjectId),
+			ClusterID: uint(defaultDeploymentTarget.ClusterId),
+			Name:      defaultDeploymentTarget.Name,
+			Namespace: defaultDeploymentTarget.Namespace,
+			IsPreview: defaultDeploymentTarget.IsPreview,
+			IsDefault: defaultDeploymentTarget.IsDefault,
+			CreatedAt: time.Time{}, // not provided by default deployment target response
+			UpdatedAt: time.Time{}, // not provided by default deployment target response
+		},
 	}
 
 	c.WriteResult(w, r, response)

+ 1 - 1
api/server/handlers/porter_app/get_app_env.go

@@ -128,7 +128,7 @@ func (c *GetAppEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	if err != nil {

+ 1 - 1
api/server/handlers/porter_app/get_build.go

@@ -163,7 +163,7 @@ func (c *GetBuildFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	if err != nil {

+ 1 - 1
api/server/handlers/porter_app/get_build_env.go

@@ -118,7 +118,7 @@ func (c *GetBuildEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	if err != nil {

+ 46 - 13
api/server/handlers/porter_app/latest_app_revisions.go

@@ -4,7 +4,6 @@ import (
 	"net/http"
 
 	"connectrpc.com/connect"
-	"github.com/google/uuid"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -35,6 +34,8 @@ func NewLatestAppRevisionsHandler(
 // LatestAppRevisionsRequest represents the request for the /apps/revisions endpoint
 type LatestAppRevisionsRequest struct {
 	DeploymentTargetID string `schema:"deployment_target_id"`
+	// if true, apps in a preview deployment target will be filtered out
+	IgnorePreviewApps bool `schema:"ignore_preview_apps"`
 }
 
 // LatestRevisionWithSource is an app revision and its source porter app
@@ -62,21 +63,23 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if deploymentTargetID == uuid.Nil {
-		err := telemetry.Error(ctx, span, nil, "deployment target id is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
+		telemetry.AttributeKV{Key: "ignore-preview-apps", Value: request.IgnorePreviewApps},
+	)
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if request.DeploymentTargetID != "" {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: request.DeploymentTargetID,
+		}
 	}
 
 	listAppRevisionsReq := connect.NewRequest(&porterv1.LatestAppRevisionsRequest{
-		ProjectId:          int64(project.ID),
-		DeploymentTargetId: deploymentTargetID.String(),
+		ProjectId:                  int64(project.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
 	})
 
 	latestAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.LatestAppRevisions(ctx, listAppRevisionsReq)
@@ -101,6 +104,8 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		AppRevisions: make([]LatestRevisionWithSource, 0),
 	}
 
+	deploymentTargets := map[string]*porterv1.DeploymentTarget{}
+
 	for _, revision := range appRevisions {
 		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
 		if err != nil {
@@ -121,6 +126,34 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			return
 		}
 
+		deploymentTarget, ok := deploymentTargets[encodedRevision.DeploymentTarget.ID]
+		if !ok {
+			details, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+				ProjectId:          int64(project.ID),
+				DeploymentTargetId: encodedRevision.DeploymentTarget.ID,
+			}))
+			if err != nil {
+				err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			if details == nil || details.Msg == nil || details.Msg.DeploymentTarget == nil {
+				err := telemetry.Error(ctx, span, err, "deployment target details are nil")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			deploymentTarget = details.Msg.DeploymentTarget
+			deploymentTargets[encodedRevision.DeploymentTarget.ID] = deploymentTarget
+		}
+
+		if request.IgnorePreviewApps && deploymentTarget.IsPreview {
+			continue
+		}
+
+		encodedRevision.DeploymentTarget.Name = deploymentTarget.Name
+
 		res.AppRevisions = append(res.AppRevisions, LatestRevisionWithSource{
 			AppRevision: encodedRevision,
 			Source:      *porterApp.ToPorterAppType(),

+ 2 - 2
api/server/handlers/porter_app/report_status.go

@@ -127,7 +127,7 @@ func (c *ReportRevisionStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	if err != nil {
@@ -237,7 +237,7 @@ func writePRComment(ctx context.Context, inp writePRCommentInput) error {
 	}
 
 	body := "## Porter Preview Environments\n"
-	porterURL := fmt.Sprintf("%s/preview-environments/apps/%s?target=%s", inp.serverURL, inp.porterApp.Name, inp.revision.DeploymentTargetID)
+	porterURL := fmt.Sprintf("%s/preview-environments/apps/%s?target=%s", inp.serverURL, inp.porterApp.Name, inp.revision.DeploymentTarget.ID)
 
 	switch inp.revision.Status {
 	case models.AppRevisionStatus_BuildFailed:

+ 54 - 3
api/server/handlers/porter_app/yaml_from_revision.go

@@ -164,7 +164,7 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 	app = zeroOutValues(app)
 
 	if request.ShouldFormatForExport {
-		app = formatForExport(app)
+		app = formatForExport(app, c.Config().ServerConf.AppRootDomain)
 	}
 
 	porterYAMLString, err := yaml.Marshal(app)
@@ -223,7 +223,7 @@ func defaultEnvGroup(ctx context.Context, input formatDefaultEnvGroupInput) (map
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(input.ProjectID),
 		ClusterID:          int64(input.Cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          input.ClusterControlPlaneClient,
 	})
 	if err != nil {
@@ -252,7 +252,27 @@ func defaultEnvGroup(ctx context.Context, input formatDefaultEnvGroupInput) (map
 	return env, revisionWithEnv.Env.Name, nil
 }
 
-func formatForExport(app v2.PorterApp) v2.PorterApp {
+func formatForExport(app v2.PorterApp, appRootDomain string) v2.PorterApp {
+	for i := range app.Services {
+		app.Services[i] = filterNewServiceValues(app.Services[i])
+
+		if app.Services[i].Type == v2.ServiceType_Web {
+			// remove porter domains
+			var filteredDomains []v2.Domains
+			for _, domain := range app.Services[i].Domains {
+				if !strings.HasSuffix(domain.Name, appRootDomain) {
+					filteredDomains = append(filteredDomains, domain)
+				}
+			}
+			app.Services[i].Domains = filteredDomains
+		}
+	}
+
+	if app.Predeploy != nil {
+		predeploy := filterNewServiceValues(*app.Predeploy)
+		app.Predeploy = &predeploy
+	}
+
 	// don't show image or commit sha if build is present
 	if app.Build != nil {
 		app.Image = nil
@@ -274,11 +294,42 @@ func formatForExport(app v2.PorterApp) v2.PorterApp {
 	return app
 }
 
+// this "no-op" ensures that new fields are always zero-ed out in the exported yaml, until we specifically add it here
+func filterNewServiceValues(service v2.Service) v2.Service {
+	return v2.Service{
+		Name:                          service.Name,
+		Run:                           service.Run,
+		Type:                          service.Type,
+		Instances:                     service.Instances,
+		CpuCores:                      service.CpuCores,
+		RamMegabytes:                  service.RamMegabytes,
+		GpuCoresNvidia:                service.GpuCoresNvidia,
+		GPU:                           service.GPU,
+		SmartOptimization:             service.SmartOptimization,
+		TerminationGracePeriodSeconds: service.TerminationGracePeriodSeconds,
+		Port:                          service.Port,
+		Autoscaling:                   service.Autoscaling,
+		Domains:                       service.Domains,
+		HealthCheck:                   service.HealthCheck,
+		AllowConcurrent:               service.AllowConcurrent,
+		Cron:                          service.Cron,
+		SuspendCron:                   service.SuspendCron,
+		TimeoutSeconds:                service.TimeoutSeconds,
+		Private:                       service.Private,
+		IngressAnnotations:            service.IngressAnnotations,
+		DisableTLS:                    service.DisableTLS,
+	}
+}
+
 func zeroOutValues(app v2.PorterApp) v2.PorterApp {
 	for i := range app.Services {
 		// remove smart optimization
 		app.Services[i].SmartOptimization = nil
 
+		if app.Services[i].GPU != nil && !app.Services[i].GPU.Enabled {
+			app.Services[i].GPU = nil
+		}
+
 		// remove launcher
 		if app.Services[i].Run != nil {
 			launcherLess := strings.TrimPrefix(*app.Services[i].Run, "launcher ")

+ 6 - 4
api/types/deployment_target.go

@@ -12,8 +12,10 @@ type DeploymentTarget struct {
 	ProjectID uint      `json:"project_id"`
 	ClusterID uint      `json:"cluster_id"`
 
-	Selector     string    `json:"selector"`
-	SelectorType string    `json:"selector_type"`
-	CreatedAt    time.Time `json:"created_at"`
-	UpdatedAt    time.Time `json:"updated_at"`
+	Name      string    `json:"name"`
+	Namespace string    `json:"namespace"`
+	IsPreview bool      `json:"is_preview"`
+	IsDefault bool      `json:"is_default"`
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
 }

+ 37 - 35
api/types/project.go

@@ -26,29 +26,30 @@ type ProjectList struct {
 
 // Project type for entries in api responses for everything other than `GET /projects`
 type Project struct {
-	ID                     uint    `json:"id"`
-	Name                   string  `json:"name"`
-	Roles                  []*Role `json:"roles"`
-	APITokensEnabled       bool    `json:"api_tokens_enabled"`
-	AWSACKAuthEnabled      bool    `json:"aws_ack_auth_enabled"`
-	AzureEnabled           bool    `json:"azure_enabled"`
-	BetaFeaturesEnabled    bool    `json:"beta_features_enabled"`
-	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
-	DBEnabled              bool    `json:"db_enabled"`
-	EFSEnabled             bool    `json:"efs_enabled"`
-	EnableReprovision      bool    `json:"enable_reprovision"`
-	FullAddOns             bool    `json:"full_add_ons"`
-	GPUEnabled             bool    `json:"gpu_enabled"`
-	HelmValuesEnabled      bool    `json:"helm_values_enabled"`
-	ManagedInfraEnabled    bool    `json:"managed_infra_enabled"`
-	MultiCluster           bool    `json:"multi_cluster"`
-	PreviewEnvsEnabled     bool    `json:"preview_envs_enabled"`
-	QuotaIncrease          bool    `json:"quota_increase"`
-	RDSDatabasesEnabled    bool    `json:"enable_rds_databases"`
-	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
-	SOC2ControlsEnabled    bool    `json:"soc2_controls_enabled"`
-	StacksEnabled          bool    `json:"stacks_enabled"`
-	ValidateApplyV2        bool    `json:"validate_apply_v2"`
+	ID                              uint    `json:"id"`
+	Name                            string  `json:"name"`
+	Roles                           []*Role `json:"roles"`
+	APITokensEnabled                bool    `json:"api_tokens_enabled"`
+	AWSACKAuthEnabled               bool    `json:"aws_ack_auth_enabled"`
+	AzureEnabled                    bool    `json:"azure_enabled"`
+	BetaFeaturesEnabled             bool    `json:"beta_features_enabled"`
+	CapiProvisionerEnabled          bool    `json:"capi_provisioner_enabled"`
+	DBEnabled                       bool    `json:"db_enabled"`
+	EFSEnabled                      bool    `json:"efs_enabled"`
+	EnableReprovision               bool    `json:"enable_reprovision"`
+	FullAddOns                      bool    `json:"full_add_ons"`
+	GPUEnabled                      bool    `json:"gpu_enabled"`
+	HelmValuesEnabled               bool    `json:"helm_values_enabled"`
+	ManagedInfraEnabled             bool    `json:"managed_infra_enabled"`
+	MultiCluster                    bool    `json:"multi_cluster"`
+	PreviewEnvsEnabled              bool    `json:"preview_envs_enabled"`
+	QuotaIncrease                   bool    `json:"quota_increase"`
+	RDSDatabasesEnabled             bool    `json:"enable_rds_databases"`
+	SimplifiedViewEnabled           bool    `json:"simplified_view_enabled"`
+	SOC2ControlsEnabled             bool    `json:"soc2_controls_enabled"`
+	StacksEnabled                   bool    `json:"stacks_enabled"`
+	ValidateApplyV2                 bool    `json:"validate_apply_v2"`
+	ManagedDeploymentTargetsEnabled bool    `json:"managed_deployment_targets_enabled"`
 }
 
 // FeatureFlags is a struct that contains old feature flag representations
@@ -56,18 +57,19 @@ type Project struct {
 // Deprecated: Add the feature flag to the `Project` struct instead and
 // retrieve feature flags from the `GET /projects/{project_id}` response instead
 type FeatureFlags struct {
-	AzureEnabled               bool   `json:"azure_enabled,omitempty"`
-	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
-	EnableReprovision          bool   `json:"enable_reprovision,omitempty"`
-	FullAddOns                 bool   `json:"full_add_ons,omitempty"`
-	HelmValuesEnabled          bool   `json:"helm_values_enabled,omitempty"`
-	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
-	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
-	MultiCluster               bool   `json:"multi_cluster,omitempty"`
-	PreviewEnvironmentsEnabled string `json:"preview_environments_enabled,omitempty"`
-	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
-	StacksEnabled              string `json:"stacks_enabled,omitempty"`
-	ValidateApplyV2            bool   `json:"validate_apply_v2"`
+	AzureEnabled                    bool   `json:"azure_enabled,omitempty"`
+	CapiProvisionerEnabled          string `json:"capi_provisioner_enabled,omitempty"`
+	EnableReprovision               bool   `json:"enable_reprovision,omitempty"`
+	FullAddOns                      bool   `json:"full_add_ons,omitempty"`
+	HelmValuesEnabled               bool   `json:"helm_values_enabled,omitempty"`
+	ManagedDatabasesEnabled         string `json:"managed_databases_enabled,omitempty"`
+	ManagedInfraEnabled             string `json:"managed_infra_enabled,omitempty"`
+	MultiCluster                    bool   `json:"multi_cluster,omitempty"`
+	PreviewEnvironmentsEnabled      string `json:"preview_environments_enabled,omitempty"`
+	SimplifiedViewEnabled           string `json:"simplified_view_enabled,omitempty"`
+	StacksEnabled                   string `json:"stacks_enabled,omitempty"`
+	ValidateApplyV2                 bool   `json:"validate_apply_v2"`
+	ManagedDeploymentTargetsEnabled bool   `json:"managed_deployment_targets_enabled"`
 }
 
 // CreateProjectRequest is a struct that contains the information

+ 7 - 7
dashboard/package-lock.json

@@ -91,7 +91,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.52",
+        "@porter-dev/api-contracts": "^0.2.59",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2658,9 +2658,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.52",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.52.tgz",
-      "integrity": "sha512-mzRnBLm56vbX5wwgLnPqGHu9DpMacuESRYwQGszdWzK0T8xZiGEApHYCU1m+G9yYUUXaGUAGYgMrEBbkYwkj2g==",
+      "version": "0.2.59",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.59.tgz",
+      "integrity": "sha512-gYo+kO70nENeeovdQOo+hWd49r3X5LCcmc4dQFuSBpjRtpflCaDhPlW10Eq9ICyU7+gt2GRTv9PN7KWXhbcaiQ==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -19826,9 +19826,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.52",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.52.tgz",
-      "integrity": "sha512-mzRnBLm56vbX5wwgLnPqGHu9DpMacuESRYwQGszdWzK0T8xZiGEApHYCU1m+G9yYUUXaGUAGYgMrEBbkYwkj2g==",
+      "version": "0.2.59",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.59.tgz",
+      "integrity": "sha512-gYo+kO70nENeeovdQOo+hWd49r3X5LCcmc4dQFuSBpjRtpflCaDhPlW10Eq9ICyU7+gt2GRTv9PN7KWXhbcaiQ==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -98,7 +98,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.52",
+    "@porter-dev/api-contracts": "^0.2.59",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 5 - 0
dashboard/src/assets/dot-vertical.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.0001 7.1999C10.6746 7.1999 9.6001 6.12539 9.6001 4.7999C9.6001 3.47442 10.6746 2.3999 12.0001 2.3999C13.3256 2.3999 14.4001 3.47442 14.4001 4.7999C14.4001 6.12539 13.3256 7.1999 12.0001 7.1999Z" stroke="white" stroke-width="2"/>
+<path d="M12.0001 14.3999C10.6746 14.3999 9.6001 13.3254 9.6001 11.9999C9.6001 10.6744 10.6746 9.5999 12.0001 9.5999C13.3256 9.5999 14.4001 10.6744 14.4001 11.9999C14.4001 13.3254 13.3256 14.3999 12.0001 14.3999Z" stroke="white" stroke-width="2"/>
+<path d="M12.0001 21.5999C10.6746 21.5999 9.6001 20.5254 9.6001 19.1999C9.6001 17.8744 10.6746 16.7999 12.0001 16.7999C13.3256 16.7999 14.4001 17.8744 14.4001 19.1999C14.4001 20.5254 13.3256 21.5999 12.0001 21.5999Z" stroke="white" stroke-width="2"/>
+</svg>

+ 15 - 27
dashboard/src/components/AzureCostConsent.tsx

@@ -1,24 +1,14 @@
-import React, { useState, useContext, useMemo } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
-import { integrationList } from "shared/common";
-import { Context } from "shared/Context";
-import api from "shared/api";
-
-import ProvisionerForm from "components/ProvisionerForm";
-import CloudFormationForm from "components/CloudFormationForm";
-import CredentialsForm from "components/CredentialsForm";
-import Helper from "components/form-components/Helper";
-import Modal from "./porter/Modal";
-import Text from "./porter/Text";
-import Spacer from "./porter/Spacer";
-import Fieldset from "./porter/Fieldset";
-import Checkbox from "./porter/Checkbox";
 import Button from "./porter/Button";
 import ExpandableSection from "./porter/ExpandableSection";
+import Fieldset from "./porter/Fieldset";
 import Input from "./porter/Input";
 import Link from "./porter/Link";
-import AzureCredentialForm from "components/AzureCredentialForm";
+import Modal from "./porter/Modal";
+import Spacer from "./porter/Spacer";
+import Text from "./porter/Text";
 
 type Props = {
   setCurrentStep: (step: string) => void;
@@ -53,20 +43,18 @@ const AzureCostConsent: React.FC<Props> = ({
           noWrapper
           expandText="[+] Show details"
           collapseText="[-] Hide details"
-          Header={<Cost>$210.24 / mo</Cost>}
+          Header={<Cost>$164.69 / mo</Cost>}
           ExpandedSection={
             <>
               <Spacer height="15px" />
               <Fieldset background="#1b1d2688">
-                • Azure Kubernetes Service (AKS) = $73/mo
-                <Spacer height="15px" />
                 • Azure virtual machines:
                 <Spacer height="15px" />
                 <Tab />+ System workloads: Standard_B2als_v2 instance (3) =
                 $82.34/mo
                 <Spacer height="15px" />
-                <Tab />+ Monitoring workloads: Standard_B2als_v2 instance (1) =
-                $27.45/mo
+                <Tab />+ Monitoring workloads: Standard_B2as_v2 instance (1) =
+                $54.90/mo
                 <Spacer height="15px" />
                 <Tab />+ Application workloads: Standard_B2als_v2 instance (1) =
                 $27.45/mo
@@ -76,9 +64,9 @@ const AzureCostConsent: React.FC<Props> = ({
         />
         <Spacer y={1} />
         <Text color="helper">
-          The base Azure infrastructure covers up to 2 vCPU and 4GB of RAM for application workloads.
-          Separate from the Azure cost, Porter charges based on your resource
-          usage.
+          The base Azure infrastructure covers up to 2 vCPU and 4GB of RAM for
+          application workloads. Separate from the Azure cost, Porter charges
+          based on your resource usage.
         </Text>
         <Spacer inline width="5px" />
         <Spacer y={0.5} />
@@ -103,12 +91,12 @@ const AzureCostConsent: React.FC<Props> = ({
         <Spacer y={0.5} />
         <Text color="helper">
           All Azure resources will be automatically deleted when you delete your
-          Porter project. Please enter the Azure base cost ("210.24") below to
-          proceed:
+          Porter project. Please enter the Azure base cost (&quot;164.69&quot;)
+          below to proceed:
         </Text>
         <Spacer y={1} />
         <Input
-          placeholder="210.24"
+          placeholder="164.69"
           value={confirmCost}
           setValue={setConfirmCost}
           width="100%"
@@ -116,7 +104,7 @@ const AzureCostConsent: React.FC<Props> = ({
         />
         <Spacer y={1} />
         <Button
-          disabled={confirmCost !== "210.24"}
+          disabled={confirmCost !== "164.69"}
           onClick={() => {
             setShowCostConfirmModal(false);
             setConfirmCost("");

+ 78 - 26
dashboard/src/components/AzureProvisionerSettings.tsx

@@ -1,33 +1,36 @@
-import React, { useEffect, useState, useContext } from "react";
+import React, {useContext, useEffect, useState} from "react";
 import styled from "styled-components";
-import { type RouteComponentProps, withRouter } from "react-router";
+import {type RouteComponentProps, withRouter} from "react-router";
 
-import { OFState } from "main/home/onboarding/state";
+import {OFState} from "main/home/onboarding/state";
 import api from "shared/api";
-import { Context } from "shared/Context";
-import { pushFiltered } from "shared/routing";
+import {Context} from "shared/Context";
+import {pushFiltered} from "shared/routing";
 
 import SelectRow from "components/form-components/SelectRow";
 import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
 import InputRow from "./form-components/InputRow";
 import {
-  Contract,
-  EnumKubernetesKind,
-  EnumCloudProvider,
-  Cluster,
   AKS,
   AKSNodePool,
-  NodePoolType,
+  AksSkuTier,
+  Cluster,
+  Contract,
+  EnumCloudProvider,
+  EnumKubernetesKind,
+  NodePoolType
 } from "@porter-dev/api-contracts";
-import { type ClusterType } from "shared/types";
+import {type ClusterType} from "shared/types";
 import Button from "./porter/Button";
 import Error from "./porter/Error";
 import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Text from "./porter/Text";
-import { useIntercom } from "lib/hooks/useIntercom";
+import {useIntercom} from "lib/hooks/useIntercom";
+import Icon from "./porter/Icon";
+import dotVertical from "assets/dot-vertical.svg";
+import {Label} from "@tanstack/react-query-devtools/build/lib/Explorer";
 
 const locationOptions = [
   { value: "eastus", label: "East US" },
@@ -56,6 +59,11 @@ const machineTypeOptions = [
   { value: "Standard_A4_v2", label: "Standard_A4_v2" },
 ];
 
+const skuTierOptions = [
+  { value: AksSkuTier.FREE, label: "Free" },
+  { value: AksSkuTier.STANDARD, label: "Standard (for production workloads, +$73/month)" },
+];
+
 const clusterVersionOptions = [{ value: "v1.27.3", label: "v1.27" }, { value: "v1.24.9", label: "v1.24" }];
 
 type Props = RouteComponentProps & {
@@ -86,6 +94,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const [cidrRange, setCidrRange] = useState("10.78.0.0/16");
   const [clusterVersion, setClusterVersion] = useState("v1.27.3");
   const [isReadOnly, setIsReadOnly] = useState(false);
+  const [skuTier, setSkuTier] = useState(AksSkuTier.FREE)
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
@@ -198,7 +207,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
                 mode: "User",
               }),
               new AKSNodePool({
-                instanceType: "Standard_B2als_v2",
+                instanceType: "Standard_B2as_v2",
                 minInstances: 1,
                 maxInstances: 3,
                 nodePoolType: NodePoolType.MONITORING,
@@ -212,6 +221,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
                 mode: "User",
               }),
             ],
+            skuTier,
           }),
         },
       }),
@@ -295,20 +305,29 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   }, []);
 
   useEffect(() => {
-    const contract = props.selectedClusterVersion as any;
-    if (contract?.cluster) {
-      contract.cluster.aksKind.nodePools.map((nodePool: any) => {
-        if (nodePool.nodePoolType === "NODE_POOL_TYPE_APPLICATION") {
+
+    if (!props.selectedClusterVersion) return;
+
+    // TODO: pass in contract as the already parsed object, rather than JSON (requires changes to AWS/GCP provisioning)
+    const contract = Contract.fromJsonString(JSON.stringify(props.selectedClusterVersion))
+
+    if (contract?.cluster?.kindValues && contract.cluster.kindValues.case === "aksKind") {
+      const aksValues = contract.cluster.kindValues.value
+      aksValues.nodePools.map((nodePool: AKSNodePool) => {
+        if (nodePool.nodePoolType === NodePoolType.APPLICATION) {
           setMachineType(nodePool.instanceType);
           setMinInstances(nodePool.minInstances);
           setMaxInstances(nodePool.maxInstances);
         }
       });
       setCreateStatus("");
-      setClusterName(contract.cluster.aksKind.clusterName);
-      setAzureLocation(contract.cluster.aksKind.location);
-      setClusterVersion(contract.cluster.aksKind.clusterVersion);
-      setCidrRange(contract.cluster.aksKind.cidrRange);
+      setClusterName(aksValues.clusterName);
+      setAzureLocation(aksValues.location);
+      setClusterVersion(aksValues.clusterVersion);
+      setCidrRange(aksValues.cidrRange);
+      if (aksValues.skuTier !== AksSkuTier.UNSPECIFIED) {
+        setSkuTier(aksValues.skuTier)
+      }
     }
   }, [props.selectedClusterVersion]);
 
@@ -317,11 +336,11 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     if (!props.clusterId) {
       return (
         <>
-          <Text size={16}>Select an Azure location</Text>
+          <Text size={16}>Select an Azure location and tier</Text>
           <Spacer y={1} />
           <Text color="helper">
-            Porter will automatically provision your infrastructure in the
-            specified location.
+            Porter will automatically provision your infrastructure with the
+            specified configuration.
           </Text>
           <Spacer height="10px" />
           <SelectRow
@@ -334,6 +353,22 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
             setActiveValue={setAzureLocation}
             label="📍 Azure location"
           />
+          <Spacer y={.75} />
+          <div style={{display: "flex", alignItems: "center"}}>
+            <Spacer inline x={.05}/>
+            <Icon src={dotVertical} height={"15px"}/>
+            <Spacer inline x={.1}/>
+            <Label>Azure Tier</Label>
+          </div>
+          <SelectRow
+              options={skuTierOptions}
+              width="350px"
+              disabled={isReadOnly}
+              value={skuTier}
+              scrollBuffer={true}
+              dropdownMaxHeight="240px"
+              setActiveValue={setSkuTier}
+          />
         </>
       );
     }
@@ -342,6 +377,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     return (
       <>
         <Heading isAtTop>AKS configuration</Heading>
+        <Spacer y={0.75} />
         <SelectRow
           options={locationOptions}
           width="350px"
@@ -352,6 +388,22 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
           setActiveValue={setAzureLocation}
           label="📍 Azure location"
         />
+        <Spacer y={.75} />
+        <div style={{display: "flex", alignItems: "center"}}>
+          <Spacer inline x={.05}/>
+          <Icon src={dotVertical} height={"15px"}/>
+          <Spacer inline x={.1}/>
+          <Label>Azure Tier</Label>
+        </div>
+        <SelectRow
+            options={skuTierOptions}
+            width="350px"
+            disabled={isReadOnly}
+            value={skuTier}
+            scrollBuffer={true}
+            dropdownMaxHeight="240px"
+            setActiveValue={setSkuTier}
+        />
         {user?.isPorterUser && (
           <Heading>
             <ExpandHeader
@@ -590,4 +642,4 @@ const errorMessageToModal = (errorMessage: string) => {
     default:
       return null;
   }
-};
+};

+ 89 - 18
dashboard/src/lib/hooks/useDeploymentTarget.ts

@@ -1,22 +1,42 @@
+import { useContext } from "react";
 import { useQuery } from "@tanstack/react-query";
-import { useContext, useEffect, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
 import { z } from "zod";
 
-const deploymentTargetValidator = z.object({
-  deployment_target_id: z.string(),
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+export const deploymentTargetValidator = z.object({
+  id: z.string(),
+  project_id: z.number(),
+  cluster_id: z.number(),
+  namespace: z.string(),
+  name: z.string(),
+  is_preview: z.boolean(),
+  is_default: z.boolean(),
+  created_at: z.string(),
+  updated_at: z.string(),
 });
-type DeploymentTarget = z.infer<typeof deploymentTargetValidator>;
+export type DeploymentTarget = z.infer<typeof deploymentTargetValidator>;
+
+const emptyDeploymentTarget: DeploymentTarget = {
+  cluster_id: 0,
+  created_at: "",
+  id: "",
+  is_default: false,
+  is_preview: false,
+  name: "",
+  namespace: "",
+  project_id: 0,
+  updated_at: "",
+};
 
-export function useDefaultDeploymentTarget() {
+export function useDefaultDeploymentTarget(): {
+  defaultDeploymentTarget: DeploymentTarget;
+  isDefaultDeploymentTargetLoading: boolean;
+} {
   const { currentProject, currentCluster } = useContext(Context);
-  const [
-    deploymentTarget,
-    setDeploymentTarget,
-  ] = useState<DeploymentTarget | null>(null);
 
-  const { data } = useQuery(
+  const { data = emptyDeploymentTarget, isLoading } = useQuery(
     ["getDefaultDeploymentTarget", currentProject?.id, currentCluster?.id],
     async () => {
       // see Context.tsx L98 for why the last check is necessary
@@ -36,7 +56,13 @@ export function useDefaultDeploymentTarget() {
         }
       );
 
-      return deploymentTargetValidator.parseAsync(res.data);
+      const object = await z
+        .object({
+          deployment_target: deploymentTargetValidator,
+        })
+        .parseAsync(res.data);
+
+      return object.deployment_target;
     },
     {
       enabled:
@@ -46,11 +72,56 @@ export function useDefaultDeploymentTarget() {
     }
   );
 
-  useEffect(() => {
-    if (data) {
-      setDeploymentTarget(data);
+  return {
+    defaultDeploymentTarget: data,
+    isDefaultDeploymentTargetLoading: isLoading,
+  };
+}
+
+export function useDeploymentTargetList(input: { preview: boolean }): {
+  deploymentTargetList: DeploymentTarget[];
+  isDeploymentTargetListLoading: boolean;
+} {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { data = [], isLoading } = useQuery(
+    [
+      "listDeploymentTargets",
+      currentProject?.id,
+      currentCluster?.id,
+      input.preview,
+    ],
+    async () => {
+      if (!currentProject || !currentCluster) {
+        return;
+      }
+
+      const res = await api.listDeploymentTargets(
+        "<token>",
+        {
+          preview: input.preview,
+        },
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      const deploymentTargets = await z
+        .object({
+          deployment_targets: z.array(deploymentTargetValidator),
+        })
+        .parseAsync(res.data);
+
+      return deploymentTargets.deployment_targets;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
     }
-  }, [data]);
+  );
 
-  return deploymentTarget;
+  return {
+    deploymentTargetList: data,
+    isDeploymentTargetListLoading: isLoading,
+  };
 }

+ 6 - 1
dashboard/src/lib/revisions/types.ts

@@ -22,7 +22,12 @@ export const appRevisionValidator = z.object({
   ]),
   b64_app_proto: z.string(),
   revision_number: z.number(),
-  deployment_target_id: z.string(),
+  deployment_target: z.object(
+{
+        id: z.string(),
+        name: z.string()
+      }
+  ),
   id: z.string(),
   created_at: z.string(),
   updated_at: z.string(),

+ 25 - 22
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -334,15 +334,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       ]);
       setPreviewRevision(null);
 
-      if (deploymentTarget.isPreview) {
-        history.push(
-          `/preview-environments/apps/${porterAppRecord.name}/${DEFAULT_TAB}?target=${deploymentTarget.id}`
-        );
-        return;
-      }
-
-      // redirect to the default tab after save
-      history.push(`/apps/${porterAppRecord.name}/${DEFAULT_TAB}`);
+      history.push(
+        formattedPath(DEFAULT_TAB, deploymentTarget.id, porterAppRecord.name)
+      );
     } catch (err) {
       showIntercomWithMessage({
         message: "I am running into an issue updating my application.",
@@ -512,7 +506,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       { label: "Environment", value: "environment" },
     ];
 
-    if (deploymentTarget.isPreview) {
+    if (deploymentTarget.is_preview) {
       return base;
     }
 
@@ -538,11 +532,27 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     base.push({ label: "Settings", value: "settings" });
     return base;
   }, [
-    deploymentTarget.isPreview,
+    deploymentTarget.is_preview,
     latestProto.build,
     latestNotifications.length,
   ]);
 
+  const formattedPath = (
+    tab: string,
+    deploymentTargetId: string,
+    appName: string
+  ): string => {
+    let path = `/apps/${appName}/${tab}`;
+    if (currentProject?.managed_deployment_targets_enabled) {
+      path = `/apps/${appName}/${tab}?target=${deploymentTargetId}`;
+    }
+    if (deploymentTarget.is_preview) {
+      path = `/preview-environments/apps/${appName}/${tab}?target=${deploymentTargetId}`;
+    }
+
+    return path;
+  };
+
   useEffect(() => {
     const newProto = previewRevision
       ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
@@ -600,10 +610,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                   loadingText={"Updating..."}
                   height={"10px"}
                   status={isSubmitting ? "loading" : ""}
-                  disabled={
-                    isSubmitting ||
-                    latestRevision.status === "CREATED"
-                  }
+                  disabled={isSubmitting || latestRevision.status === "CREATED"}
                   disabledTooltipMessage="Please wait for the deploy to complete before updating the app"
                   disabledTooltipPosition="bottom"
                 >
@@ -629,13 +636,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           options={tabs}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
-            if (deploymentTarget.isPreview) {
-              history.push(
-                `/preview-environments/apps/${porterAppRecord.name}/${tab}?target=${deploymentTarget.id}`
-              );
-              return;
-            }
-            history.push(`/apps/${porterAppRecord.name}/${tab}`);
+            history.push(
+              formattedPath(tab, deploymentTarget.id, porterAppRecord.name)
+            );
           }}
         />
         <Spacer y={1} />

+ 5 - 5
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -174,18 +174,18 @@ const AppHeader: React.FC = () => {
               </A>
             </Container>
             <Spacer inline x={1} />
-            <TagWrapper preview={deploymentTarget.isPreview}>
-              {deploymentTarget.isPreview ? "Preview" : "Branch"}
-              <BranchTag preview={deploymentTarget.isPreview}>
+            <TagWrapper preview={deploymentTarget.is_preview}>
+              {deploymentTarget.is_preview ? "Preview" : "Branch"}
+              <BranchTag preview={deploymentTarget.is_preview}>
                 <PullRequestIcon
                   styles={{
                     height: "14px",
                     opacity: "0.65",
                     marginRight: "5px",
-                    fill: deploymentTarget.isPreview ? "" : "#fff",
+                    fill: deploymentTarget.is_preview ? "" : "#fff",
                   }}
                 />
-                {deploymentTarget.isPreview
+                {deploymentTarget.is_preview
                   ? deploymentTarget.namespace
                   : gitData.branch}
               </BranchTag>

+ 3 - 47
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -32,7 +32,6 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import {
   useDeploymentTarget,
-  type DeploymentTarget,
 } from "shared/DeploymentTargetContext";
 import { valueExists } from "shared/util";
 import notFound from "assets/not-found.png";
@@ -43,6 +42,7 @@ import {
 } from "../validate-apply/app-settings/types";
 import { porterAppValidator, type PorterAppRecord } from "./AppView";
 import { porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
+import {type DeploymentTarget} from "lib/hooks/useDeploymentTarget";
 
 type LatestRevisionContextType = {
   porterApp: PorterAppRecord;
@@ -136,6 +136,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
       appName,
     ],
     async () => {
+
       if (!appParamsExist) {
         return { app_revision: undefined, notifications: [] };
       }
@@ -172,46 +173,6 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     }
   );
 
-  const { data, status: deploymentTargetStatus } = useQuery(
-    [
-      "getDeploymentTarget",
-      {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-        deployment_target_id: currentDeploymentTarget?.id,
-      },
-    ],
-    async () => {
-      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
-        return;
-      }
-      const res = await api.getDeploymentTarget(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          deployment_target_id: currentDeploymentTarget.id,
-        }
-      );
-
-      const { deployment_target: deploymentTarget } = await z
-        .object({
-          deployment_target: z.object({
-            cluster_id: z.number(),
-            namespace: z.string(),
-            is_preview: z.boolean(),
-          }),
-        })
-        .parseAsync(res.data);
-
-      return deploymentTarget;
-    },
-    {
-      enabled: !!currentCluster && !!currentProject,
-    }
-  );
-
   const revisionId = previewRevision?.id ?? latestRevision?.id;
   const { data: { attachedEnvGroups = [], appEnv } = {} } = useQuery(
     ["getAttachedEnvGroups", appName, revisionId],
@@ -322,7 +283,6 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
   if (
     status === "loading" ||
     porterAppStatus === "loading" ||
-    deploymentTargetStatus === "loading" ||
     !appParamsExist ||
     porterYamlLoading
   ) {
@@ -332,7 +292,6 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
   if (
     status === "error" ||
     porterAppStatus === "error" ||
-    deploymentTargetStatus === "error" ||
     !latestRevision ||
     !latestProto ||
     !porterApp
@@ -360,10 +319,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,
-        deploymentTarget: {
-          ...currentDeploymentTarget,
-          namespace: data?.namespace ?? "",
-        },
+        deploymentTarget: currentDeploymentTarget,
         servicesFromYaml: detectedServices,
         attachedEnvGroups,
         appEnv,

+ 29 - 21
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from "react";
+import React, {useContext, useMemo} from "react";
 import { type AppRevisionWithSource } from "./types";
 import { search } from "shared/search";
 import _ from "lodash";
@@ -8,7 +8,7 @@ import { Link } from "react-router-dom";
 import web from "assets/web.png";
 import box from "assets/box.png";
 import time from "assets/time.png";
-import healthy from "assets/status-healthy.png";
+import target from "assets/target.svg";
 import notFound from "assets/not-found.png";
 import github from "assets/github.png";
 
@@ -21,6 +21,7 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import { readableDate } from "shared/string_utils";
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+import {Context} from "../../../../shared/Context";
 
 type AppGridProps = {
   apps: AppRevisionWithSource[];
@@ -39,6 +40,7 @@ const icons = [
 
 const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
   const { currentDeploymentTarget } = useDeploymentTarget();
+  const { currentProject } = useContext(Context);
   const appsWithProto = useMemo(() => {
     return apps.map((app) => {
       return {
@@ -80,7 +82,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
       .exhaustive();
   }, [appsWithProto, searchValue, sort]);
 
-  const renderIcon = (bp: string[], size?: string) => {
+  const renderIcon = (bp: string[], size?: string): JSX.Element => {
     let src = box;
     if (bp.length) {
       const [_, name] = bp[0].split("/");
@@ -112,7 +114,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     );
   };
 
-  const renderSource = (source: AppRevisionWithSource["source"]) => {
+  const renderSource = (source: AppRevisionWithSource["source"]): JSX.Element => {
     return (
       <>
         {source.repo_name ? (
@@ -153,14 +155,19 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     .with("grid", () => (
       <GridList>
         {(filteredApps ?? []).map(
-          ({ app_revision: { proto, updated_at }, source }, i) => {
+          ({ app_revision: { proto, updated_at: updatedAt, deployment_target: deploymentTarget }, source }, i) => {
+
+            let appLink = `/apps/${proto.name}`;
+            if (currentProject?.managed_deployment_targets_enabled) {
+                appLink = `/apps/${proto.name}/activity?target=${deploymentTarget.id}`;
+            }
+            if (currentDeploymentTarget?.is_preview) {
+                appLink = `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`;
+            }
+
             return (
               <Link
-                to={
-                  currentDeploymentTarget?.isPreview
-                    ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
-                    : `/apps/${proto.name}`
-                }
+                to={appLink}
                 key={i}
               >
                 <Block>
@@ -173,10 +180,18 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
                   {renderSource(source)}
+                  {currentProject?.managed_deployment_targets_enabled && !currentDeploymentTarget?.is_preview && (
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={target} />
+                      <Text size={13} color="#ffffff44">
+                        {deploymentTarget.name}
+                      </Text>
+                    </Container>
+                  )}
                   <Container row>
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
-                      {readableDate(updated_at)}
+                      {readableDate(updatedAt)}
                     </Text>
                   </Container>
                 </Block>
@@ -189,11 +204,11 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     .with("list", () => (
       <List>
         {(filteredApps ?? []).map(
-          ({ app_revision: { proto, updated_at }, source }, i) => {
+          ({ app_revision: { proto, updated_at: updatedAt }, source }, i) => {
             return (
               <Link
                 to={
-                  currentDeploymentTarget?.preview
+                  currentDeploymentTarget?.is_preview
                     ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
                     : `/apps/${proto.name}`
                 }
@@ -215,7 +230,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
-                      {readableDate(updated_at)}
+                      {readableDate(updatedAt)}
                     </Text>
                   </Container>
                 </Row>
@@ -269,13 +284,6 @@ const Block = styled.div`
   }
 `;
 
-const StatusIcon = styled.img`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  height: 18px;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;

+ 57 - 96
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -1,41 +1,37 @@
-import React, { useState, useContext, useCallback } from "react";
+import React, { useCallback, useContext, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useHistory } from "react-router";
 import styled from "styled-components";
-import _ from "lodash";
-
-import web from "assets/web.png";
-import grid from "assets/grid.png";
-import list from "assets/list.png";
-import letter from "assets/vector.svg";
-import calendar from "assets/calendar-number.svg";
-import pull_request from "assets/pull_request_icon.svg";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
+import { z } from "zod";
 
-import Container from "components/porter/Container";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Loading from "components/Loading";
 import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import PorterLink from "components/porter/Link";
+import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import SearchBar from "components/porter/SearchBar";
 import Toggle from "components/porter/Toggle";
-import PorterLink from "components/porter/Link";
-import Loading from "components/Loading";
-import Fieldset from "components/porter/Fieldset";
-import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
-import { useQuery } from "@tanstack/react-query";
-import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
-import { appRevisionWithSourceValidator } from "./types";
-import AppGrid from "./AppGrid";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
-import { z } from "zod";
-import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import DeleteEnvModal from "main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal";
-import { useHistory } from "react-router";
-import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 
-type Props = {};
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+import calendar from "assets/calendar-number.svg";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import pull_request from "assets/pull_request_icon.svg";
+import letter from "assets/vector.svg";
+import web from "assets/web.png";
 
-const Apps: React.FC<Props> = ({ }) => {
+import AppGrid from "./AppGrid";
+import { appRevisionWithSourceValidator } from "./types";
+
+const Apps: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
   const { currentDeploymentTarget } = useDeploymentTarget();
@@ -70,7 +66,12 @@ const Apps: React.FC<Props> = ({ }) => {
       const res = await api.getLatestAppRevisions(
         "<token>",
         {
-          deployment_target_id: currentDeploymentTarget?.id,
+          deployment_target_id:
+            currentProject.managed_deployment_targets_enabled &&
+            !currentDeploymentTarget.is_preview
+              ? undefined
+              : currentDeploymentTarget.id,
+          ignore_preview_apps: !currentDeploymentTarget.is_preview,
         },
         { cluster_id: currentCluster.id, project_id: currentProject.id }
       );
@@ -90,49 +91,6 @@ const Apps: React.FC<Props> = ({ }) => {
     }
   );
 
-  const { data, status: deploymentTargetStatus } = useQuery(
-    [
-      "getDeploymentTarget",
-      {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-        deployment_target_id: currentDeploymentTarget?.id,
-      },
-    ],
-    async () => {
-      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
-        return;
-      }
-      const res = await api.getDeploymentTarget(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          deployment_target_id: currentDeploymentTarget.id,
-        }
-      );
-
-      const { deployment_target: deploymentTarget } = await z
-        .object({
-          deployment_target: z.object({
-            cluster_id: z.number(),
-            namespace: z.string(),
-            is_preview: z.boolean(),
-          }),
-        })
-        .parseAsync(res.data);
-
-      return deploymentTarget;
-    },
-    {
-      enabled:
-        !!currentCluster &&
-        !!currentProject &&
-        currentDeploymentTarget?.isPreview,
-    }
-  );
-
   const deletePreviewEnv = useCallback(async () => {
     try {
       if (!currentCluster || !currentProject || !currentDeploymentTarget) {
@@ -165,14 +123,14 @@ const Apps: React.FC<Props> = ({ }) => {
     setEnvDeleting,
   ]);
 
-  const renderContents = () => {
+  const renderContents = (): JSX.Element => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
     }
 
     if (
       status === "loading" ||
-      (currentDeploymentTarget?.isPreview && deploymentTargetStatus === "loading")
+      (currentDeploymentTarget?.is_preview && currentDeploymentTarget.id === "")
     ) {
       return <Loading offset="-150px" />;
     }
@@ -180,17 +138,22 @@ const Apps: React.FC<Props> = ({ }) => {
     if (apps.length === 0) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>
-            No apps have been deployed yet
-          </Text>
+          <Text size={16}>No apps have been deployed yet</Text>
           <Spacer y={0.5} />
-          <Text color={"helper"}>
-            Get started by deploying your app.
-          </Text>
+          <Text color={"helper"}>Get started by deploying your app.</Text>
           <Spacer y={1} />
           <PorterLink to="/apps/new/app">
-            <Button alt onClick={async () => { await updateAppStep({ step: "stack-launch-start" }); }} height="35px">
-              Deploy app <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+            <Button
+              alt
+              onClick={async () => {
+                await updateAppStep({ step: "stack-launch-start" });
+              }}
+              height="35px"
+            >
+              Deploy app <Spacer inline x={1} />{" "}
+              <i className="material-icons" style={{ fontSize: "18px" }}>
+                east
+              </i>
             </Button>
           </PorterLink>
         </DashboardPlaceholder>
@@ -199,7 +162,7 @@ const Apps: React.FC<Props> = ({ }) => {
 
     return (
       <>
-        {currentDeploymentTarget?.isPreview && (
+        {currentDeploymentTarget?.is_preview && (
           <DashboardHeader
             image={pull_request}
             title={
@@ -210,7 +173,9 @@ const Apps: React.FC<Props> = ({ }) => {
                   alignItems: "center",
                 }}
               >
-                <div>{data?.namespace ?? "Preview Apps"}</div>
+                <div>
+                  {currentDeploymentTarget?.namespace ?? "Preview Apps"}
+                </div>
                 <Badge>Preview</Badge>
               </div>
             }
@@ -263,7 +228,7 @@ const Apps: React.FC<Props> = ({ }) => {
             activeColor={"transparent"}
           />
           <Spacer inline x={2} />
-          {currentDeploymentTarget?.isPreview ? (
+          {currentDeploymentTarget?.is_preview ? (
             <Button
               onClick={async () => {
                 setShowDeleteEnvModal(true);
@@ -277,8 +242,9 @@ const Apps: React.FC<Props> = ({ }) => {
           ) : (
             <PorterLink to="/apps/new/app">
               <Button
-                onClick={async () => { await updateAppStep({ step: "stack-launch-start" }); }
-                }
+                onClick={async () => {
+                  await updateAppStep({ step: "stack-launch-start" });
+                }}
                 height="30px"
                 width="160px"
               >
@@ -300,7 +266,7 @@ const Apps: React.FC<Props> = ({ }) => {
 
   return (
     <StyledAppDashboard>
-      {!currentDeploymentTarget?.isPreview && (
+      {!currentDeploymentTarget?.is_preview && (
         <DashboardHeader
           image={web}
           title="Applications"
@@ -312,7 +278,9 @@ const Apps: React.FC<Props> = ({ }) => {
       <Spacer y={5} />
       {showDeleteEnvModal && (
         <DeleteEnvModal
-          closeModal={() => { setShowDeleteEnvModal(false); }}
+          closeModal={() => {
+            setShowDeleteEnvModal(false);
+          }}
           deleteEnv={deletePreviewEnv}
           loading={envDeleting}
         />
@@ -343,13 +311,6 @@ const StyledAppDashboard = styled.div`
   height: 100%;
 `;
 
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;
-
 const Badge = styled.div`
   border: 1px solid #ca8a04;
   background-color: #fefce8;

+ 50 - 9
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -15,13 +15,18 @@ import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Link from "components/porter/Link";
+import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppValidation } from "lib/hooks/useAppValidation";
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import {
+  useDefaultDeploymentTarget,
+  useDeploymentTargetList,
+  type DeploymentTarget,
+} from "lib/hooks/useDeploymentTarget";
 import { useIntercom } from "lib/hooks/useIntercom";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import {
@@ -197,14 +202,24 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     source: source?.type === "github" ? source : null,
     appName: "", // only want to know if porter.yaml has name set, otherwise use name from input
   });
-  const deploymentTarget = useDefaultDeploymentTarget();
+  const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
+    useDefaultDeploymentTarget();
+  const { deploymentTargetList } = useDeploymentTargetList({ preview: false });
+  const [deploymentTargetID, setDeploymentTargetID] = React.useState("");
   const { updateAppStep } = useAppAnalytics();
   const { validateApp } = useAppValidation({
-    deploymentTargetID: deploymentTarget?.deployment_target_id,
+    deploymentTargetID,
     creating: true,
   });
   const { currentClusterResources } = useClusterResources();
 
+  // set the deployment target id to the default if no deployment target has been selected yet
+  useEffect(() => {
+    if (!isDefaultDeploymentTargetLoading && deploymentTargetID === "") {
+      setDeploymentTargetID(defaultDeploymentTarget?.id ?? "");
+    }
+  }, [defaultDeploymentTarget]);
+
   const resetAllExceptName = (): void => {
     setIsNameHighlight(true);
 
@@ -317,7 +332,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           return false;
         }
 
-        if (!app || !deploymentTarget) {
+        if (!app || !deploymentTargetID) {
           return false;
         }
 
@@ -325,7 +340,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           await api.updateApp(
             "<token>",
             {
-              deployment_target_id: deploymentTarget.deployment_target_id,
+              deployment_target_id: deploymentTargetID,
               b64_app_proto: btoa(app.toJsonString()),
               secrets,
               variables,
@@ -349,7 +364,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
             app,
             projectID: currentProject.id,
             clusterID: currentCluster.id,
-            deploymentTargetID: deploymentTarget.deployment_target_id,
+            deploymentTargetID,
             variables,
             secrets,
           });
@@ -362,7 +377,11 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         });
 
         if (source.type === "docker-registry") {
-          history.push(`/apps/${app.name}`);
+          let targetSuffix = "";
+          if (currentProject?.managed_deployment_targets_enabled) {
+            targetSuffix = `?target=${deploymentTargetID}`;
+          }
+          history.push(`/apps/${app.name}${targetSuffix}`);
         }
 
         return true;
@@ -397,7 +416,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     [
       currentProject?.id,
       currentCluster?.id,
-      deploymentTarget,
+      deploymentTargetID,
       name.value,
       createWithValidateApply,
     ]
@@ -554,7 +573,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     } else {
       clearErrors("app.name.value");
     }
-  }, [porterApps, name.value]);
+  }, [porterApps.join(""), name.value]);
 
   if (!currentProject || !currentCluster) {
     return null;
@@ -595,6 +614,27 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       }
                       {...register("app.name.value")}
                     />
+                    {currentProject?.managed_deployment_targets_enabled && (
+                      <>
+                        <Spacer y={1} />
+                        <Select
+                          value={deploymentTargetID}
+                          width="300px"
+                          options={deploymentTargetList.map(
+                            (target: DeploymentTarget) => {
+                              return {
+                                value: target.id,
+                                label: target.name,
+                              };
+                            }
+                          )}
+                          setValue={(value) => {
+                            setDeploymentTargetID(value);
+                          }}
+                          label={"Deployment Target"}
+                        />
+                      </>
+                    )}
                   </>,
                   <>
                     <Text size={16}>Deployment method</Text>
@@ -798,6 +838,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }
           deploymentError={deployError}
           porterYamlPath={source.porter_yaml_path}
+          redirectPath={currentProject.managed_deployment_targets_enabled ? `/apps/${name.value}?target=${deploymentTargetID}` : `/apps/${name.value}`}
         />
       )}
     </CenterWrapper>

+ 7 - 5
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -1,4 +1,4 @@
-import { RouteComponentProps, withRouter } from "react-router";
+import { type RouteComponentProps, withRouter } from "react-router";
 import styled from "styled-components";
 import React, { useMemo } from "react";
 
@@ -27,6 +27,7 @@ type Props = RouteComponentProps & {
   deploymentError?: string;
   porterYamlPath?: string;
   type?: "create" | "preview";
+  redirectPath: string;
 };
 
 type Choice = "open_pr" | "copy";
@@ -44,6 +45,7 @@ const GithubActionModal: React.FC<Props> = ({
   deploymentError,
   porterYamlPath,
   type = "create",
+  redirectPath ,
   ...props
 }) => {
   const [choice, setChoice] = React.useState<Choice>("open_pr");
@@ -94,7 +96,7 @@ const GithubActionModal: React.FC<Props> = ({
       try {
         setLoading(true);
         // this creates the dummy chart
-        var success = true;
+        let success = true;
         if (deployPorterApp) {
           success = await deployPorterApp();
         }
@@ -126,7 +128,7 @@ const GithubActionModal: React.FC<Props> = ({
               window.location.reload();
             }
           }
-          props.history.push(`/apps/${stackName}`);
+          props.history.push(redirectPath);
         }
       } catch (error) {
       } finally {
@@ -184,7 +186,7 @@ const GithubActionModal: React.FC<Props> = ({
                 value: "copy",
               },
             ]}
-            setValue={(x: string) => setChoice(x as Choice)}
+            setValue={(x: string) => { setChoice(x as Choice); }}
             width="100%"
           />
           <Spacer y={1} />
@@ -207,7 +209,7 @@ const GithubActionModal: React.FC<Props> = ({
         <>
           <Checkbox
             checked={isChecked}
-            toggleChecked={() => setIsChecked(!isChecked)}
+            toggleChecked={() => { setIsChecked(!isChecked); }}
           >
             <Text>I authorize Porter to open a PR on my behalf</Text>
           </Checkbox>

+ 8 - 8
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -1,5 +1,4 @@
 import React, { useMemo } from "react";
-import { RawDeploymentTarget } from "./PreviewEnvs";
 import { match } from "ts-pattern";
 import _ from "lodash";
 import styled from "styled-components";
@@ -17,9 +16,10 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
+import type {DeploymentTarget} from "lib/hooks/useDeploymentTarget";
 
 type PreviewEnvGridProps = {
-  deploymentTargets: RawDeploymentTarget[];
+  deploymentTargets: DeploymentTarget[];
   searchValue: string;
   view: "grid" | "list";
   sort: "letter" | "calendar";
@@ -33,7 +33,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 }) => {
   const filteredEnvs = useMemo(() => {
     const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
-      keys: ["selector"],
+      keys: ["namespace"],
       isCaseSensitive: false,
     });
 
@@ -41,7 +41,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
       .with("calendar", () =>
         _.sortBy(filteredBySearch, ["created_at"]).reverse()
       )
-      .with("letter", () => _.sortBy(filteredBySearch, ["selector"]))
+      .with("letter", () => _.sortBy(filteredBySearch, ["namespace"]))
       .exhaustive();
   }, [deploymentTargets, searchValue, sort]);
 
@@ -67,13 +67,13 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           return (
             <Link
               to={`/preview-environments/apps?target=${env.id}`}
-              key={env.selector}
+              key={env.namespace}
             >
               <Block>
                 <Container row>
                   <Icon height="18px" src={pull_request} />
                   <Spacer inline width="12px" />
-                  <Text size={14}>{env.selector}</Text>
+                  <Text size={14}>{env.namespace}</Text>
                   <Spacer inline x={2} />
                 </Container>
                 <StatusIcon src={healthy} />
@@ -95,14 +95,14 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           return (
             <Link
               to={`/preview-environments/apps?target=${env.id}`}
-              key={env.selector}
+              key={env.namespace}
             >
               <Row>
                 <Container row>
                   <Spacer inline width="1px" />
                   <Icon height="18px" src={pull_request} />
                   <Spacer inline width="12px" />
-                  <Text size={14}>{env.selector}</Text>
+                  <Text size={14}>{env.namespace}</Text>
                   <Spacer inline x={1} />
                   <Icon height="16px" src={healthy} />
                 </Container>

+ 15 - 109
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -1,84 +1,38 @@
-import { useQuery } from "@tanstack/react-query";
+import React, { useState } from "react";
+import styled from "styled-components";
+
 import Loading from "components/Loading";
 import Container from "components/porter/Container";
-import Link from "components/porter/Link";
+import Fieldset from "components/porter/Fieldset";
+import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import React, { useContext, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import styled from "styled-components";
-import { z } from "zod";
+import Toggle from "components/porter/Toggle";
+import { useDeploymentTargetList } from "lib/hooks/useDeploymentTarget";
 
-import PullRequestIcon from "assets/pull_request_icon.svg";
+import calendar from "assets/calendar-number.svg";
 import grid from "assets/grid.png";
 import list from "assets/list.png";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import letter from "assets/vector.svg";
-import calendar from "assets/calendar-number.svg";
 
-import PorterLink from "components/porter/Link";
-import SearchBar from "components/porter/SearchBar";
-import Toggle from "components/porter/Toggle";
 import DashboardHeader from "../../DashboardHeader";
-import Fieldset from "components/porter/Fieldset";
-import Button from "components/porter/Button";
 import PreviewEnvGrid from "./PreviewEnvGrid";
 
-const rawDeploymentTargetValidator = z.object({
-  id: z.string(),
-  project_id: z.number(),
-  cluster_id: z.number(),
-  selector: z.string(),
-  selector_type: z.string(),
-  created_at: z.string(),
-  updated_at: z.string(),
-});
-export type RawDeploymentTarget = z.infer<typeof rawDeploymentTargetValidator>;
-
 const PreviewEnvs: React.FC = () => {
-  const { currentProject, currentCluster } = useContext(Context);
-
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState<"grid" | "list">("grid");
   const [sort, setSort] = useState<"calendar" | "letter">("calendar");
 
-  const { data: deploymentTargets = [], status } = useQuery(
-    ["listDeploymentTargets", currentProject?.id, currentCluster?.id],
-    async () => {
-      if (!currentProject || !currentCluster) {
-        return;
-      }
-
-      const res = await api.listDeploymentTargets(
-        "<token>",
-        {
-          preview: true,
-        },
-        {
-          project_id: currentProject?.id,
-          cluster_id: currentCluster?.id,
-        }
-      );
+  const { deploymentTargetList, isDeploymentTargetListLoading } =
+    useDeploymentTargetList({ preview: true });
 
-      const deploymentTargets = await z
-        .object({
-          deployment_targets: z.array(rawDeploymentTargetValidator),
-        })
-        .parseAsync(res.data);
-
-      return deploymentTargets.deployment_targets;
-    },
-    {
-      enabled: !!currentProject && !!currentCluster,
-    }
-  );
-
-  const renderContents = () => {
-    if (status === "loading") {
+  const renderContents = (): JSX.Element => {
+    if (isDeploymentTargetListLoading) {
       return <Loading offset="-150px" />;
     }
 
-    if (!deploymentTargets || deploymentTargets.length === 0) {
+    if (deploymentTargetList.length === 0) {
       <Fieldset>
         <CentralContainer>
           <Text size={16}>No preview environments have been deployed yet.</Text>
@@ -137,7 +91,7 @@ const PreviewEnvs: React.FC = () => {
         </Container>
         <Spacer y={1} />
         <PreviewEnvGrid
-          deploymentTargets={deploymentTargets}
+          deploymentTargets={deploymentTargetList}
           sort={sort}
           view={view}
           searchValue={searchValue}
@@ -179,51 +133,3 @@ const ToggleIcon = styled.img`
   margin: 0 5px;
   min-width: 12px;
 `;
-
-const GridList = styled.div`
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-`;
-
-const Block = styled.div`
-  height: 150px;
-  flex-direction: column;
-  display: flex;
-  justify-content: space-between;
-  cursor: pointer;
-  padding: 20px;
-  color: ${(props) => props.theme.text.primary};
-  position: relative;
-  border-radius: 5px;
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const StatusIcon = styled.img`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  height: 18px;
-`;
-
-const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
-  margin-left: 2px;
-  height: ${(props) => props.height || "14px"};
-  opacity: ${(props) => props.opacity || 1};
-  filter: grayscale(100%);
-  margin-right: 10px;
-`;

+ 70 - 20
dashboard/src/shared/DeploymentTargetContext.tsx

@@ -1,18 +1,24 @@
 import React, { createContext, useContext, useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
 import { useLocation } from "react-router";
+import { z } from "zod";
 
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import {
+  deploymentTargetValidator,
+  useDefaultDeploymentTarget,
+  type DeploymentTarget,
+} from "lib/hooks/useDeploymentTarget";
 
-export type DeploymentTarget = {
-  id: string;
-  isPreview: boolean;
-};
+import api from "./api";
+import { Context } from "./Context";
 
 export const DeploymentTargetContext = createContext<{
   currentDeploymentTarget: DeploymentTarget | null;
 } | null>(null);
 
-export const useDeploymentTarget = () => {
+export const useDeploymentTarget = (): {
+  currentDeploymentTarget: DeploymentTarget | null;
+} => {
   const context = useContext(DeploymentTargetContext);
   if (context === null) {
     throw new Error(
@@ -22,34 +28,78 @@ export const useDeploymentTarget = () => {
   return context;
 };
 
-const DeploymentTargetProvider = ({ children }: { children: JSX.Element }) => {
+const DeploymentTargetProvider = ({
+  children,
+}: {
+  children: JSX.Element;
+}): JSX.Element => {
   const { search } = useLocation();
+  const { currentCluster, currentProject } = useContext(Context);
   const queryParams = new URLSearchParams(search);
 
-  const idParam = queryParams.get("target");
-  const defaultDeploymentTarget = useDefaultDeploymentTarget();
+  const deploymentTargetID = queryParams.get("target");
+  const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
+    useDefaultDeploymentTarget();
+
+  const { data: deploymentTargetFromIdParam, status } = useQuery(
+    [
+      "getDeploymentTarget",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: deploymentTargetID,
+      },
+    ],
+    async () => {
+      if (!currentCluster || !currentProject || !deploymentTargetID) {
+        return;
+      }
+      const res = await api.getDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_target_id: deploymentTargetID,
+        }
+      );
+
+      const deploymentTarget = await z
+        .object({ deployment_target: deploymentTargetValidator })
+        .parseAsync(res.data);
+
+      return deploymentTarget.deployment_target;
+    },
+    {
+      enabled: !!currentCluster && !!currentProject && !!deploymentTargetID,
+    }
+  );
 
   const deploymentTarget: DeploymentTarget | null = useMemo(() => {
-    if (!idParam && !defaultDeploymentTarget) {
+    if (!deploymentTargetID && isDefaultDeploymentTargetLoading) {
       return null;
     }
 
-    if (idParam) {
-      return {
-        id: idParam,
-        isPreview: true,
-      };
+    if (deploymentTargetID) {
+      if (status === "loading" || !deploymentTargetFromIdParam) {
+        return null;
+      }
+
+      return deploymentTargetFromIdParam;
     }
 
     if (defaultDeploymentTarget) {
-      return {
-        id: defaultDeploymentTarget.deployment_target_id,
-        isPreview: false,
-      };
+      return defaultDeploymentTarget;
     }
 
     return null;
-  }, [idParam, defaultDeploymentTarget]);
+  }, [
+    deploymentTargetID,
+    isDefaultDeploymentTargetLoading,
+    defaultDeploymentTarget,
+    deploymentTargetFromIdParam,
+    status,
+  ]);
 
   return (
     <DeploymentTargetContext.Provider

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

@@ -1169,7 +1169,8 @@ const listAppRevisions = baseApi<
 
 const getLatestAppRevisions = baseApi<
   {
-    deployment_target_id: string;
+    deployment_target_id: string | undefined;
+    ignore_preview_apps: boolean;
   },
   {
     project_id: number;

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

@@ -288,6 +288,7 @@ export type ProjectType = {
   soc2_controls_enabled: boolean;
   stacks_enabled: boolean;
   validate_apply_v2: boolean;
+  managed_deployment_targets_enabled: boolean;
   roles: Array<{
     id: number;
     kind: string;

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.58
+	github.com/porter-dev/api-contracts v0.2.59
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 6
go.sum

@@ -1520,12 +1520,8 @@ 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.55 h1:H8RvD004mX4uWrlRVcL8kzo7ZtFQyZDN+X9j+2bW7fc=
-github.com/porter-dev/api-contracts v0.2.55/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.56 h1:zym8eomipCj7BVQVlnCY4RjNv1tkduY68gsmwVJIfaQ=
-github.com/porter-dev/api-contracts v0.2.56/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.58 h1:8h5ORZtaq2f1vyEdYD88ZOcHQmYJb77zs+lQXY82yrU=
-github.com/porter-dev/api-contracts v0.2.58/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+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/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 4 - 4
internal/kubernetes/prometheus/metrics.go

@@ -496,7 +496,7 @@ func createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionR
 	)
 
 	targetCPUUtilThresholdOne := fmt.Sprintf(
-		`%s{%s} / 50`,
+		`%s{%s} / 100`,
 		metricName,
 		kubeMetricsHPASelectorOne,
 	)
@@ -511,7 +511,7 @@ func createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionR
 	)
 
 	targetCPUUtilThresholdTwo := fmt.Sprintf(
-		`%s{%s} / 50`,
+		`%s{%s} / 100`,
 		metricName,
 		kubeMetricsHPASelectorTwo,
 	)
@@ -565,7 +565,7 @@ func createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelecti
 	)
 
 	targetMemUtilThresholdOne := fmt.Sprintf(
-		`%s{%s} / 50`,
+		`%s{%s} / 100`,
 		metricName,
 		kubeMetricsHPASelectorOne,
 	)
@@ -580,7 +580,7 @@ func createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelecti
 	)
 
 	targetMemUtilThresholdTwo := fmt.Sprintf(
-		`%s{%s} / 50`,
+		`%s{%s} / 100`,
 		metricName,
 		kubeMetricsHPASelectorTwo,
 	)

+ 9 - 7
internal/models/deployment_target.go

@@ -49,12 +49,14 @@ type DeploymentTarget struct {
 // ToDeploymentTargetType generates an external types.PorterApp to be shared over REST
 func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget {
 	return &types.DeploymentTarget{
-		ID:           d.ID,
-		ProjectID:    uint(d.ProjectID),
-		ClusterID:    uint(d.ClusterID),
-		Selector:     d.Selector,
-		SelectorType: string(d.SelectorType),
-		CreatedAt:    d.CreatedAt,
-		UpdatedAt:    d.UpdatedAt,
+		ID:        d.ID,
+		ProjectID: uint(d.ProjectID),
+		ClusterID: uint(d.ClusterID),
+		Namespace: d.Selector,
+		IsPreview: d.Preview,
+		IsDefault: d.IsDefault,
+		Name:      d.VanityName,
+		CreatedAt: d.CreatedAt,
+		UpdatedAt: d.UpdatedAt,
 	}
 }

+ 45 - 40
internal/models/project.go

@@ -75,30 +75,34 @@ const (
 
 	// AWSACKAuthEnabled controls whether a project's AWS access is governed through AWS ACK
 	AWSACKAuthEnabled FeatureFlagLabel = "aws_ack_auth_enabled"
+
+	// ManagedDeploymentTargetsEnabled controls whether a project can use managed deployment targets
+	ManagedDeploymentTargetsEnabled FeatureFlagLabel = "managed_deployment_targets_enabled"
 )
 
 // ProjectFeatureFlags keeps track of all project-related feature flags
 var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
-	APITokensEnabled:       false,
-	AWSACKAuthEnabled:      false,
-	AzureEnabled:           false,
-	BetaFeaturesEnabled:    false,
-	CapiProvisionerEnabled: true,
-	DBEnabled:              false,
-	EFSEnabled:             false,
-	EnableReprovision:      false,
-	FullAddOns:             false,
-	GPUEnabled:             false,
-	HelmValuesEnabled:      false,
-	ManagedInfraEnabled:    false,
-	MultiCluster:           false,
-	PreviewEnvsEnabled:     false,
-	QuotaIncrease:          false,
-	RDSDatabasesEnabled:    false,
-	SimplifiedViewEnabled:  true,
-	SOC2ControlsEnabled:    false,
-	StacksEnabled:          false,
-	ValidateApplyV2:        true,
+	APITokensEnabled:                false,
+	AWSACKAuthEnabled:               false,
+	AzureEnabled:                    false,
+	BetaFeaturesEnabled:             false,
+	CapiProvisionerEnabled:          true,
+	DBEnabled:                       false,
+	EFSEnabled:                      false,
+	EnableReprovision:               false,
+	FullAddOns:                      false,
+	GPUEnabled:                      false,
+	HelmValuesEnabled:               false,
+	ManagedInfraEnabled:             false,
+	MultiCluster:                    false,
+	PreviewEnvsEnabled:              false,
+	QuotaIncrease:                   false,
+	RDSDatabasesEnabled:             false,
+	SimplifiedViewEnabled:           true,
+	SOC2ControlsEnabled:             false,
+	StacksEnabled:                   false,
+	ValidateApplyV2:                 true,
+	ManagedDeploymentTargetsEnabled: false,
 }
 
 type ProjectPlan string
@@ -264,26 +268,27 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		Name:  projectName,
 		Roles: roles,
 
-		APITokensEnabled:       p.GetFeatureFlag(APITokensEnabled, launchDarklyClient),
-		AWSACKAuthEnabled:      p.GetFeatureFlag(AWSACKAuthEnabled, launchDarklyClient),
-		AzureEnabled:           p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
-		BetaFeaturesEnabled:    p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
-		CapiProvisionerEnabled: p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient),
-		DBEnabled:              p.GetFeatureFlag(DBEnabled, launchDarklyClient),
-		EFSEnabled:             p.GetFeatureFlag(EFSEnabled, launchDarklyClient),
-		EnableReprovision:      p.GetFeatureFlag(EnableReprovision, launchDarklyClient),
-		FullAddOns:             p.GetFeatureFlag(FullAddOns, launchDarklyClient),
-		GPUEnabled:             p.GetFeatureFlag(GPUEnabled, launchDarklyClient),
-		HelmValuesEnabled:      p.GetFeatureFlag(HelmValuesEnabled, launchDarklyClient),
-		ManagedInfraEnabled:    p.GetFeatureFlag(ManagedInfraEnabled, launchDarklyClient),
-		MultiCluster:           p.GetFeatureFlag(MultiCluster, launchDarklyClient),
-		PreviewEnvsEnabled:     p.GetFeatureFlag(PreviewEnvsEnabled, launchDarklyClient),
-		QuotaIncrease:          p.GetFeatureFlag(QuotaIncrease, launchDarklyClient),
-		RDSDatabasesEnabled:    p.GetFeatureFlag(RDSDatabasesEnabled, launchDarklyClient),
-		SimplifiedViewEnabled:  p.GetFeatureFlag(SimplifiedViewEnabled, launchDarklyClient),
-		SOC2ControlsEnabled:    p.GetFeatureFlag(SOC2ControlsEnabled, launchDarklyClient),
-		StacksEnabled:          p.GetFeatureFlag(StacksEnabled, launchDarklyClient),
-		ValidateApplyV2:        p.GetFeatureFlag(ValidateApplyV2, launchDarklyClient),
+		APITokensEnabled:                p.GetFeatureFlag(APITokensEnabled, launchDarklyClient),
+		AWSACKAuthEnabled:               p.GetFeatureFlag(AWSACKAuthEnabled, launchDarklyClient),
+		AzureEnabled:                    p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
+		BetaFeaturesEnabled:             p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
+		CapiProvisionerEnabled:          p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient),
+		DBEnabled:                       p.GetFeatureFlag(DBEnabled, launchDarklyClient),
+		EFSEnabled:                      p.GetFeatureFlag(EFSEnabled, launchDarklyClient),
+		EnableReprovision:               p.GetFeatureFlag(EnableReprovision, launchDarklyClient),
+		FullAddOns:                      p.GetFeatureFlag(FullAddOns, launchDarklyClient),
+		GPUEnabled:                      p.GetFeatureFlag(GPUEnabled, launchDarklyClient),
+		HelmValuesEnabled:               p.GetFeatureFlag(HelmValuesEnabled, launchDarklyClient),
+		ManagedInfraEnabled:             p.GetFeatureFlag(ManagedInfraEnabled, launchDarklyClient),
+		MultiCluster:                    p.GetFeatureFlag(MultiCluster, launchDarklyClient),
+		PreviewEnvsEnabled:              p.GetFeatureFlag(PreviewEnvsEnabled, launchDarklyClient),
+		QuotaIncrease:                   p.GetFeatureFlag(QuotaIncrease, launchDarklyClient),
+		RDSDatabasesEnabled:             p.GetFeatureFlag(RDSDatabasesEnabled, launchDarklyClient),
+		SimplifiedViewEnabled:           p.GetFeatureFlag(SimplifiedViewEnabled, launchDarklyClient),
+		SOC2ControlsEnabled:             p.GetFeatureFlag(SOC2ControlsEnabled, launchDarklyClient),
+		StacksEnabled:                   p.GetFeatureFlag(StacksEnabled, launchDarklyClient),
+		ValidateApplyV2:                 p.GetFeatureFlag(ValidateApplyV2, launchDarklyClient),
+		ManagedDeploymentTargetsEnabled: p.GetFeatureFlag(ManagedDeploymentTargetsEnabled, launchDarklyClient),
 	}
 }
 

+ 16 - 10
internal/porter_app/revisions.go

@@ -34,13 +34,19 @@ type Revision struct {
 	// UpdatedAt is the time the revision was updated
 	UpdatedAt time.Time `json:"updated_at"`
 	// DeploymentTargetID is the id of the deployment target the revision is associated with
-	DeploymentTargetID string `json:"deployment_target_id"`
+	DeploymentTarget DeploymentTarget `json:"deployment_target"`
 	// Env is the environment variables for the revision
 	Env environment_groups.EnvironmentGroup `json:"env,omitempty"`
 	// AppInstanceID is the id of the app instance the revision is associated with
 	AppInstanceID uuid.UUID `json:"app_instance_id"`
 }
 
+// DeploymentTarget is a simplified version of the deployment target struct
+type DeploymentTarget struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
 // GetAppRevisionInput is the input struct for GetAppRevisions
 type GetAppRevisionInput struct {
 	ProjectID     uint
@@ -122,14 +128,14 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 	}
 
 	revision = Revision{
-		B64AppProto:        b64,
-		Status:             status,
-		ID:                 appRevision.Id,
-		RevisionNumber:     appRevision.RevisionNumber,
-		CreatedAt:          appRevision.CreatedAt.AsTime(),
-		UpdatedAt:          appRevision.UpdatedAt.AsTime(),
-		DeploymentTargetID: appRevision.DeploymentTargetId,
-		AppInstanceID:      appInstanceId,
+		B64AppProto:      b64,
+		Status:           status,
+		ID:               appRevision.Id,
+		RevisionNumber:   appRevision.RevisionNumber,
+		CreatedAt:        appRevision.CreatedAt.AsTime(),
+		UpdatedAt:        appRevision.UpdatedAt.AsTime(),
+		DeploymentTarget: DeploymentTarget{ID: appRevision.DeploymentTargetId},
+		AppInstanceID:    appInstanceId,
 	}
 
 	return revision, nil
@@ -174,7 +180,7 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 		return revision, telemetry.Error(ctx, span, err, "error unmarshalling app proto")
 	}
 
-	envName, err := AppEnvGroupName(ctx, appDef.Name, inp.Revision.DeploymentTargetID, uint(inp.ClusterID), inp.PorterAppRepository)
+	envName, err := AppEnvGroupName(ctx, appDef.Name, inp.Revision.DeploymentTarget.ID, uint(inp.ClusterID), inp.PorterAppRepository)
 	if err != nil {
 		return revision, telemetry.Error(ctx, span, err, "error getting app env group name")
 	}