d-g-town 2 лет назад
Родитель
Сommit
d5ae3ee90e
27 измененных файлов с 591 добавлено и 480 удалено
  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. 1 1
      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. 89 18
      dashboard/src/lib/hooks/useDeploymentTarget.ts
  12. 6 1
      dashboard/src/lib/revisions/types.ts
  13. 25 22
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  14. 5 5
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  15. 3 47
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  16. 29 21
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  17. 57 96
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  18. 50 9
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  19. 7 5
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  20. 8 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  21. 15 109
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  22. 70 20
      dashboard/src/shared/DeploymentTargetContext.tsx
  23. 2 1
      dashboard/src/shared/api.tsx
  24. 1 0
      dashboard/src/shared/types.tsx
  25. 9 7
      internal/models/deployment_target.go
  26. 45 40
      internal/models/project.go
  27. 16 10
      internal/porter_app/revisions.go

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

@@ -2,6 +2,9 @@ package deployment_target
 
 
 import (
 import (
 	"net/http"
 	"net/http"
+	"time"
+
+	"github.com/google/uuid"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -37,7 +40,7 @@ type GetDeploymentTargetRequest struct {
 
 
 // GetDeploymentTargetResponse is the response object for the /deployment-targets/{deployment_target_id} GET endpoint
 // GetDeploymentTargetResponse is the response object for the /deployment-targets/{deployment_target_id} GET endpoint
 type GetDeploymentTargetResponse struct {
 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) {
 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
 		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{
 	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)
 	c.WriteResult(w, r, res)

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

@@ -2,6 +2,9 @@ package porter_app
 
 
 import (
 import (
 	"net/http"
 	"net/http"
+	"time"
+
+	"github.com/google/uuid"
 
 
 	"connectrpc.com/connect"
 	"connectrpc.com/connect"
 
 
@@ -37,7 +40,9 @@ type DefaultDeploymentTargetRequest struct{}
 
 
 // DefaultDeploymentTargetResponse is the response object for the /default-deployment-target endpoint
 // DefaultDeploymentTargetResponse is the response object for the /default-deployment-target endpoint
 type DefaultDeploymentTargetResponse struct {
 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 (
 const (
@@ -79,8 +84,33 @@ func (c *DefaultDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *htt
 	}
 	}
 
 
 	defaultDeploymentTarget := defaultDeploymentTargetResp.Msg.DeploymentTarget
 	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{
 	response := &DefaultDeploymentTargetResponse{
 		DeploymentTargetID: defaultDeploymentTarget.Id,
 		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)
 	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{
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	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{
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	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{
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	if err != nil {

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

@@ -4,7 +4,6 @@ import (
 	"net/http"
 	"net/http"
 
 
 	"connectrpc.com/connect"
 	"connectrpc.com/connect"
-	"github.com/google/uuid"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	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/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -35,6 +34,8 @@ func NewLatestAppRevisionsHandler(
 // LatestAppRevisionsRequest represents the request for the /apps/revisions endpoint
 // LatestAppRevisionsRequest represents the request for the /apps/revisions endpoint
 type LatestAppRevisionsRequest struct {
 type LatestAppRevisionsRequest struct {
 	DeploymentTargetID string `schema:"deployment_target_id"`
 	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
 // 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
 		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{
 	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)
 	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),
 		AppRevisions: make([]LatestRevisionWithSource, 0),
 	}
 	}
 
 
+	deploymentTargets := map[string]*porterv1.DeploymentTarget{}
+
 	for _, revision := range appRevisions {
 	for _, revision := range appRevisions {
 		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
 		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
 		if err != nil {
 		if err != nil {
@@ -121,6 +126,34 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			return
 			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{
 		res.AppRevisions = append(res.AppRevisions, LatestRevisionWithSource{
 			AppRevision: encodedRevision,
 			AppRevision: encodedRevision,
 			Source:      *porterApp.ToPorterAppType(),
 			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{
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(project.ID),
 		ProjectID:          int64(project.ID),
 		ClusterID:          int64(cluster.ID),
 		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 		CCPClient:          c.Config().ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	if err != nil {
@@ -237,7 +237,7 @@ func writePRComment(ctx context.Context, inp writePRCommentInput) error {
 	}
 	}
 
 
 	body := "## Porter Preview Environments\n"
 	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 {
 	switch inp.revision.Status {
 	case models.AppRevisionStatus_BuildFailed:
 	case models.AppRevisionStatus_BuildFailed:

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

@@ -223,7 +223,7 @@ func defaultEnvGroup(ctx context.Context, input formatDefaultEnvGroupInput) (map
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
 		ProjectID:          int64(input.ProjectID),
 		ProjectID:          int64(input.ProjectID),
 		ClusterID:          int64(input.Cluster.ID),
 		ClusterID:          int64(input.Cluster.ID),
-		DeploymentTargetID: revision.DeploymentTargetID,
+		DeploymentTargetID: revision.DeploymentTarget.ID,
 		CCPClient:          input.ClusterControlPlaneClient,
 		CCPClient:          input.ClusterControlPlaneClient,
 	})
 	})
 	if err != nil {
 	if err != nil {

+ 6 - 4
api/types/deployment_target.go

@@ -12,8 +12,10 @@ type DeploymentTarget struct {
 	ProjectID uint      `json:"project_id"`
 	ProjectID uint      `json:"project_id"`
 	ClusterID uint      `json:"cluster_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`
 // Project type for entries in api responses for everything other than `GET /projects`
 type Project struct {
 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
 // 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
 // Deprecated: Add the feature flag to the `Project` struct instead and
 // retrieve feature flags from the `GET /projects/{project_id}` response instead
 // retrieve feature flags from the `GET /projects/{project_id}` response instead
 type FeatureFlags struct {
 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
 // CreateProjectRequest is a struct that contains the information

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

@@ -1,22 +1,42 @@
+import { useContext } from "react";
 import { useQuery } from "@tanstack/react-query";
 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";
 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 { currentProject, currentCluster } = useContext(Context);
-  const [
-    deploymentTarget,
-    setDeploymentTarget,
-  ] = useState<DeploymentTarget | null>(null);
 
 
-  const { data } = useQuery(
+  const { data = emptyDeploymentTarget, isLoading } = useQuery(
     ["getDefaultDeploymentTarget", currentProject?.id, currentCluster?.id],
     ["getDefaultDeploymentTarget", currentProject?.id, currentCluster?.id],
     async () => {
     async () => {
       // see Context.tsx L98 for why the last check is necessary
       // 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:
       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(),
   b64_app_proto: z.string(),
   revision_number: z.number(),
   revision_number: z.number(),
-  deployment_target_id: z.string(),
+  deployment_target: z.object(
+{
+        id: z.string(),
+        name: z.string()
+      }
+  ),
   id: z.string(),
   id: z.string(),
   created_at: z.string(),
   created_at: z.string(),
   updated_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);
       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) {
     } catch (err) {
       showIntercomWithMessage({
       showIntercomWithMessage({
         message: "I am running into an issue updating my application.",
         message: "I am running into an issue updating my application.",
@@ -512,7 +506,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       { label: "Environment", value: "environment" },
       { label: "Environment", value: "environment" },
     ];
     ];
 
 
-    if (deploymentTarget.isPreview) {
+    if (deploymentTarget.is_preview) {
       return base;
       return base;
     }
     }
 
 
@@ -538,11 +532,27 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     base.push({ label: "Settings", value: "settings" });
     base.push({ label: "Settings", value: "settings" });
     return base;
     return base;
   }, [
   }, [
-    deploymentTarget.isPreview,
+    deploymentTarget.is_preview,
     latestProto.build,
     latestProto.build,
     latestNotifications.length,
     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(() => {
   useEffect(() => {
     const newProto = previewRevision
     const newProto = previewRevision
       ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
       ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
@@ -600,10 +610,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                   loadingText={"Updating..."}
                   loadingText={"Updating..."}
                   height={"10px"}
                   height={"10px"}
                   status={isSubmitting ? "loading" : ""}
                   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"
                   disabledTooltipMessage="Please wait for the deploy to complete before updating the app"
                   disabledTooltipPosition="bottom"
                   disabledTooltipPosition="bottom"
                 >
                 >
@@ -629,13 +636,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           options={tabs}
           options={tabs}
           currentTab={currentTab}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
           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} />
         <Spacer y={1} />

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

@@ -174,18 +174,18 @@ const AppHeader: React.FC = () => {
               </A>
               </A>
             </Container>
             </Container>
             <Spacer inline x={1} />
             <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
                 <PullRequestIcon
                   styles={{
                   styles={{
                     height: "14px",
                     height: "14px",
                     opacity: "0.65",
                     opacity: "0.65",
                     marginRight: "5px",
                     marginRight: "5px",
-                    fill: deploymentTarget.isPreview ? "" : "#fff",
+                    fill: deploymentTarget.is_preview ? "" : "#fff",
                   }}
                   }}
                 />
                 />
-                {deploymentTarget.isPreview
+                {deploymentTarget.is_preview
                   ? deploymentTarget.namespace
                   ? deploymentTarget.namespace
                   : gitData.branch}
                   : gitData.branch}
               </BranchTag>
               </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 { Context } from "shared/Context";
 import {
 import {
   useDeploymentTarget,
   useDeploymentTarget,
-  type DeploymentTarget,
 } from "shared/DeploymentTargetContext";
 } from "shared/DeploymentTargetContext";
 import { valueExists } from "shared/util";
 import { valueExists } from "shared/util";
 import notFound from "assets/not-found.png";
 import notFound from "assets/not-found.png";
@@ -43,6 +42,7 @@ import {
 } from "../validate-apply/app-settings/types";
 } from "../validate-apply/app-settings/types";
 import { porterAppValidator, type PorterAppRecord } from "./AppView";
 import { porterAppValidator, type PorterAppRecord } from "./AppView";
 import { porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
 import { porterAppNotificationEventMetadataValidator } from "./tabs/activity-feed/events/types";
+import {type DeploymentTarget} from "lib/hooks/useDeploymentTarget";
 
 
 type LatestRevisionContextType = {
 type LatestRevisionContextType = {
   porterApp: PorterAppRecord;
   porterApp: PorterAppRecord;
@@ -136,6 +136,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
       appName,
       appName,
     ],
     ],
     async () => {
     async () => {
+
       if (!appParamsExist) {
       if (!appParamsExist) {
         return { app_revision: undefined, notifications: [] };
         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 revisionId = previewRevision?.id ?? latestRevision?.id;
   const { data: { attachedEnvGroups = [], appEnv } = {} } = useQuery(
   const { data: { attachedEnvGroups = [], appEnv } = {} } = useQuery(
     ["getAttachedEnvGroups", appName, revisionId],
     ["getAttachedEnvGroups", appName, revisionId],
@@ -322,7 +283,6 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
   if (
   if (
     status === "loading" ||
     status === "loading" ||
     porterAppStatus === "loading" ||
     porterAppStatus === "loading" ||
-    deploymentTargetStatus === "loading" ||
     !appParamsExist ||
     !appParamsExist ||
     porterYamlLoading
     porterYamlLoading
   ) {
   ) {
@@ -332,7 +292,6 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
   if (
   if (
     status === "error" ||
     status === "error" ||
     porterAppStatus === "error" ||
     porterAppStatus === "error" ||
-    deploymentTargetStatus === "error" ||
     !latestRevision ||
     !latestRevision ||
     !latestProto ||
     !latestProto ||
     !porterApp
     !porterApp
@@ -360,10 +319,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
         porterApp,
         porterApp,
         clusterId: currentCluster.id,
         clusterId: currentCluster.id,
         projectId: currentProject.id,
         projectId: currentProject.id,
-        deploymentTarget: {
-          ...currentDeploymentTarget,
-          namespace: data?.namespace ?? "",
-        },
+        deploymentTarget: currentDeploymentTarget,
         servicesFromYaml: detectedServices,
         servicesFromYaml: detectedServices,
         attachedEnvGroups,
         attachedEnvGroups,
         appEnv,
         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 { type AppRevisionWithSource } from "./types";
 import { search } from "shared/search";
 import { search } from "shared/search";
 import _ from "lodash";
 import _ from "lodash";
@@ -8,7 +8,7 @@ import { Link } from "react-router-dom";
 import web from "assets/web.png";
 import web from "assets/web.png";
 import box from "assets/box.png";
 import box from "assets/box.png";
 import time from "assets/time.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 notFound from "assets/not-found.png";
 import github from "assets/github.png";
 import github from "assets/github.png";
 
 
@@ -21,6 +21,7 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+import {Context} from "../../../../shared/Context";
 
 
 type AppGridProps = {
 type AppGridProps = {
   apps: AppRevisionWithSource[];
   apps: AppRevisionWithSource[];
@@ -39,6 +40,7 @@ const icons = [
 
 
 const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
 const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
   const { currentDeploymentTarget } = useDeploymentTarget();
   const { currentDeploymentTarget } = useDeploymentTarget();
+  const { currentProject } = useContext(Context);
   const appsWithProto = useMemo(() => {
   const appsWithProto = useMemo(() => {
     return apps.map((app) => {
     return apps.map((app) => {
       return {
       return {
@@ -80,7 +82,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
       .exhaustive();
       .exhaustive();
   }, [appsWithProto, searchValue, sort]);
   }, [appsWithProto, searchValue, sort]);
 
 
-  const renderIcon = (bp: string[], size?: string) => {
+  const renderIcon = (bp: string[], size?: string): JSX.Element => {
     let src = box;
     let src = box;
     if (bp.length) {
     if (bp.length) {
       const [_, name] = bp[0].split("/");
       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 (
     return (
       <>
       <>
         {source.repo_name ? (
         {source.repo_name ? (
@@ -153,14 +155,19 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     .with("grid", () => (
     .with("grid", () => (
       <GridList>
       <GridList>
         {(filteredApps ?? []).map(
         {(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 (
             return (
               <Link
               <Link
-                to={
-                  currentDeploymentTarget?.isPreview
-                    ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
-                    : `/apps/${proto.name}`
-                }
+                to={appLink}
                 key={i}
                 key={i}
               >
               >
                 <Block>
                 <Block>
@@ -173,10 +180,18 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                   {/** TODO: make the status icon dynamic */}
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
                   {/* <StatusIcon src={healthy} /> */}
                   {renderSource(source)}
                   {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>
                   <Container row>
                     <SmallIcon opacity="0.4" src={time} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
                     <Text size={13} color="#ffffff44">
-                      {readableDate(updated_at)}
+                      {readableDate(updatedAt)}
                     </Text>
                     </Text>
                   </Container>
                   </Container>
                 </Block>
                 </Block>
@@ -189,11 +204,11 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     .with("list", () => (
     .with("list", () => (
       <List>
       <List>
         {(filteredApps ?? []).map(
         {(filteredApps ?? []).map(
-          ({ app_revision: { proto, updated_at }, source }, i) => {
+          ({ app_revision: { proto, updated_at: updatedAt }, source }, i) => {
             return (
             return (
               <Link
               <Link
                 to={
                 to={
-                  currentDeploymentTarget?.preview
+                  currentDeploymentTarget?.is_preview
                     ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
                     ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
                     : `/apps/${proto.name}`
                     : `/apps/${proto.name}`
                 }
                 }
@@ -215,7 +230,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                     <Spacer inline x={1} />
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
                     <Text size={13} color="#ffffff44">
-                      {readableDate(updated_at)}
+                      {readableDate(updatedAt)}
                     </Text>
                     </Text>
                   </Container>
                   </Container>
                 </Row>
                 </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`
 const List = styled.div`
   overflow: hidden;
   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 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 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 Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
-import SearchBar from "components/porter/SearchBar";
 import Toggle from "components/porter/Toggle";
 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 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 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 { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
   const { updateAppStep } = useAppAnalytics();
   const { currentDeploymentTarget } = useDeploymentTarget();
   const { currentDeploymentTarget } = useDeploymentTarget();
@@ -70,7 +66,12 @@ const Apps: React.FC<Props> = ({ }) => {
       const res = await api.getLatestAppRevisions(
       const res = await api.getLatestAppRevisions(
         "<token>",
         "<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 }
         { 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 () => {
   const deletePreviewEnv = useCallback(async () => {
     try {
     try {
       if (!currentCluster || !currentProject || !currentDeploymentTarget) {
       if (!currentCluster || !currentProject || !currentDeploymentTarget) {
@@ -165,14 +123,14 @@ const Apps: React.FC<Props> = ({ }) => {
     setEnvDeleting,
     setEnvDeleting,
   ]);
   ]);
 
 
-  const renderContents = () => {
+  const renderContents = (): JSX.Element => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
       return <ClusterProvisioningPlaceholder />;
     }
     }
 
 
     if (
     if (
       status === "loading" ||
       status === "loading" ||
-      (currentDeploymentTarget?.isPreview && deploymentTargetStatus === "loading")
+      (currentDeploymentTarget?.is_preview && currentDeploymentTarget.id === "")
     ) {
     ) {
       return <Loading offset="-150px" />;
       return <Loading offset="-150px" />;
     }
     }
@@ -180,17 +138,22 @@ const Apps: React.FC<Props> = ({ }) => {
     if (apps.length === 0) {
     if (apps.length === 0) {
       return (
       return (
         <DashboardPlaceholder>
         <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} />
           <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} />
           <Spacer y={1} />
           <PorterLink to="/apps/new/app">
           <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>
             </Button>
           </PorterLink>
           </PorterLink>
         </DashboardPlaceholder>
         </DashboardPlaceholder>
@@ -199,7 +162,7 @@ const Apps: React.FC<Props> = ({ }) => {
 
 
     return (
     return (
       <>
       <>
-        {currentDeploymentTarget?.isPreview && (
+        {currentDeploymentTarget?.is_preview && (
           <DashboardHeader
           <DashboardHeader
             image={pull_request}
             image={pull_request}
             title={
             title={
@@ -210,7 +173,9 @@ const Apps: React.FC<Props> = ({ }) => {
                   alignItems: "center",
                   alignItems: "center",
                 }}
                 }}
               >
               >
-                <div>{data?.namespace ?? "Preview Apps"}</div>
+                <div>
+                  {currentDeploymentTarget?.namespace ?? "Preview Apps"}
+                </div>
                 <Badge>Preview</Badge>
                 <Badge>Preview</Badge>
               </div>
               </div>
             }
             }
@@ -263,7 +228,7 @@ const Apps: React.FC<Props> = ({ }) => {
             activeColor={"transparent"}
             activeColor={"transparent"}
           />
           />
           <Spacer inline x={2} />
           <Spacer inline x={2} />
-          {currentDeploymentTarget?.isPreview ? (
+          {currentDeploymentTarget?.is_preview ? (
             <Button
             <Button
               onClick={async () => {
               onClick={async () => {
                 setShowDeleteEnvModal(true);
                 setShowDeleteEnvModal(true);
@@ -277,8 +242,9 @@ const Apps: React.FC<Props> = ({ }) => {
           ) : (
           ) : (
             <PorterLink to="/apps/new/app">
             <PorterLink to="/apps/new/app">
               <Button
               <Button
-                onClick={async () => { await updateAppStep({ step: "stack-launch-start" }); }
-                }
+                onClick={async () => {
+                  await updateAppStep({ step: "stack-launch-start" });
+                }}
                 height="30px"
                 height="30px"
                 width="160px"
                 width="160px"
               >
               >
@@ -300,7 +266,7 @@ const Apps: React.FC<Props> = ({ }) => {
 
 
   return (
   return (
     <StyledAppDashboard>
     <StyledAppDashboard>
-      {!currentDeploymentTarget?.isPreview && (
+      {!currentDeploymentTarget?.is_preview && (
         <DashboardHeader
         <DashboardHeader
           image={web}
           image={web}
           title="Applications"
           title="Applications"
@@ -312,7 +278,9 @@ const Apps: React.FC<Props> = ({ }) => {
       <Spacer y={5} />
       <Spacer y={5} />
       {showDeleteEnvModal && (
       {showDeleteEnvModal && (
         <DeleteEnvModal
         <DeleteEnvModal
-          closeModal={() => { setShowDeleteEnvModal(false); }}
+          closeModal={() => {
+            setShowDeleteEnvModal(false);
+          }}
           deleteEnv={deletePreviewEnv}
           deleteEnv={deletePreviewEnv}
           loading={envDeleting}
           loading={envDeleting}
         />
         />
@@ -343,13 +311,6 @@ const StyledAppDashboard = styled.div`
   height: 100%;
   height: 100%;
 `;
 `;
 
 
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;
-
 const Badge = styled.div`
 const Badge = styled.div`
   border: 1px solid #ca8a04;
   border: 1px solid #ca8a04;
   background-color: #fefce8;
   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 { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Error from "components/porter/Error";
 import Link from "components/porter/Link";
 import Link from "components/porter/Link";
+import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import VerticalSteps from "components/porter/VerticalSteps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppValidation } from "lib/hooks/useAppValidation";
 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 { useIntercom } from "lib/hooks/useIntercom";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import {
 import {
@@ -197,14 +202,24 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     source: source?.type === "github" ? source : null,
     source: source?.type === "github" ? source : null,
     appName: "", // only want to know if porter.yaml has name set, otherwise use name from input
     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 { updateAppStep } = useAppAnalytics();
   const { validateApp } = useAppValidation({
   const { validateApp } = useAppValidation({
-    deploymentTargetID: deploymentTarget?.deployment_target_id,
+    deploymentTargetID,
     creating: true,
     creating: true,
   });
   });
   const { currentClusterResources } = useClusterResources();
   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 => {
   const resetAllExceptName = (): void => {
     setIsNameHighlight(true);
     setIsNameHighlight(true);
 
 
@@ -317,7 +332,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           return false;
           return false;
         }
         }
 
 
-        if (!app || !deploymentTarget) {
+        if (!app || !deploymentTargetID) {
           return false;
           return false;
         }
         }
 
 
@@ -325,7 +340,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           await api.updateApp(
           await api.updateApp(
             "<token>",
             "<token>",
             {
             {
-              deployment_target_id: deploymentTarget.deployment_target_id,
+              deployment_target_id: deploymentTargetID,
               b64_app_proto: btoa(app.toJsonString()),
               b64_app_proto: btoa(app.toJsonString()),
               secrets,
               secrets,
               variables,
               variables,
@@ -349,7 +364,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
             app,
             app,
             projectID: currentProject.id,
             projectID: currentProject.id,
             clusterID: currentCluster.id,
             clusterID: currentCluster.id,
-            deploymentTargetID: deploymentTarget.deployment_target_id,
+            deploymentTargetID,
             variables,
             variables,
             secrets,
             secrets,
           });
           });
@@ -362,7 +377,11 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         });
         });
 
 
         if (source.type === "docker-registry") {
         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;
         return true;
@@ -397,7 +416,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     [
     [
       currentProject?.id,
       currentProject?.id,
       currentCluster?.id,
       currentCluster?.id,
-      deploymentTarget,
+      deploymentTargetID,
       name.value,
       name.value,
       createWithValidateApply,
       createWithValidateApply,
     ]
     ]
@@ -554,7 +573,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     } else {
     } else {
       clearErrors("app.name.value");
       clearErrors("app.name.value");
     }
     }
-  }, [porterApps, name.value]);
+  }, [porterApps.join(""), name.value]);
 
 
   if (!currentProject || !currentCluster) {
   if (!currentProject || !currentCluster) {
     return null;
     return null;
@@ -595,6 +614,27 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       }
                       }
                       {...register("app.name.value")}
                       {...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>
                     <Text size={16}>Deployment method</Text>
@@ -798,6 +838,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }
           }
           deploymentError={deployError}
           deploymentError={deployError}
           porterYamlPath={source.porter_yaml_path}
           porterYamlPath={source.porter_yaml_path}
+          redirectPath={currentProject.managed_deployment_targets_enabled ? `/apps/${name.value}?target=${deploymentTargetID}` : `/apps/${name.value}`}
         />
         />
       )}
       )}
     </CenterWrapper>
     </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 styled from "styled-components";
 import React, { useMemo } from "react";
 import React, { useMemo } from "react";
 
 
@@ -27,6 +27,7 @@ type Props = RouteComponentProps & {
   deploymentError?: string;
   deploymentError?: string;
   porterYamlPath?: string;
   porterYamlPath?: string;
   type?: "create" | "preview";
   type?: "create" | "preview";
+  redirectPath: string;
 };
 };
 
 
 type Choice = "open_pr" | "copy";
 type Choice = "open_pr" | "copy";
@@ -44,6 +45,7 @@ const GithubActionModal: React.FC<Props> = ({
   deploymentError,
   deploymentError,
   porterYamlPath,
   porterYamlPath,
   type = "create",
   type = "create",
+  redirectPath ,
   ...props
   ...props
 }) => {
 }) => {
   const [choice, setChoice] = React.useState<Choice>("open_pr");
   const [choice, setChoice] = React.useState<Choice>("open_pr");
@@ -94,7 +96,7 @@ const GithubActionModal: React.FC<Props> = ({
       try {
       try {
         setLoading(true);
         setLoading(true);
         // this creates the dummy chart
         // this creates the dummy chart
-        var success = true;
+        let success = true;
         if (deployPorterApp) {
         if (deployPorterApp) {
           success = await deployPorterApp();
           success = await deployPorterApp();
         }
         }
@@ -126,7 +128,7 @@ const GithubActionModal: React.FC<Props> = ({
               window.location.reload();
               window.location.reload();
             }
             }
           }
           }
-          props.history.push(`/apps/${stackName}`);
+          props.history.push(redirectPath);
         }
         }
       } catch (error) {
       } catch (error) {
       } finally {
       } finally {
@@ -184,7 +186,7 @@ const GithubActionModal: React.FC<Props> = ({
                 value: "copy",
                 value: "copy",
               },
               },
             ]}
             ]}
-            setValue={(x: string) => setChoice(x as Choice)}
+            setValue={(x: string) => { setChoice(x as Choice); }}
             width="100%"
             width="100%"
           />
           />
           <Spacer y={1} />
           <Spacer y={1} />
@@ -207,7 +209,7 @@ const GithubActionModal: React.FC<Props> = ({
         <>
         <>
           <Checkbox
           <Checkbox
             checked={isChecked}
             checked={isChecked}
-            toggleChecked={() => setIsChecked(!isChecked)}
+            toggleChecked={() => { setIsChecked(!isChecked); }}
           >
           >
             <Text>I authorize Porter to open a PR on my behalf</Text>
             <Text>I authorize Porter to open a PR on my behalf</Text>
           </Checkbox>
           </Checkbox>

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

@@ -1,5 +1,4 @@
 import React, { useMemo } from "react";
 import React, { useMemo } from "react";
-import { RawDeploymentTarget } from "./PreviewEnvs";
 import { match } from "ts-pattern";
 import { match } from "ts-pattern";
 import _ from "lodash";
 import _ from "lodash";
 import styled from "styled-components";
 import styled from "styled-components";
@@ -17,9 +16,10 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
+import type {DeploymentTarget} from "lib/hooks/useDeploymentTarget";
 
 
 type PreviewEnvGridProps = {
 type PreviewEnvGridProps = {
-  deploymentTargets: RawDeploymentTarget[];
+  deploymentTargets: DeploymentTarget[];
   searchValue: string;
   searchValue: string;
   view: "grid" | "list";
   view: "grid" | "list";
   sort: "letter" | "calendar";
   sort: "letter" | "calendar";
@@ -33,7 +33,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 }) => {
 }) => {
   const filteredEnvs = useMemo(() => {
   const filteredEnvs = useMemo(() => {
     const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
     const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
-      keys: ["selector"],
+      keys: ["namespace"],
       isCaseSensitive: false,
       isCaseSensitive: false,
     });
     });
 
 
@@ -41,7 +41,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
       .with("calendar", () =>
       .with("calendar", () =>
         _.sortBy(filteredBySearch, ["created_at"]).reverse()
         _.sortBy(filteredBySearch, ["created_at"]).reverse()
       )
       )
-      .with("letter", () => _.sortBy(filteredBySearch, ["selector"]))
+      .with("letter", () => _.sortBy(filteredBySearch, ["namespace"]))
       .exhaustive();
       .exhaustive();
   }, [deploymentTargets, searchValue, sort]);
   }, [deploymentTargets, searchValue, sort]);
 
 
@@ -67,13 +67,13 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           return (
           return (
             <Link
             <Link
               to={`/preview-environments/apps?target=${env.id}`}
               to={`/preview-environments/apps?target=${env.id}`}
-              key={env.selector}
+              key={env.namespace}
             >
             >
               <Block>
               <Block>
                 <Container row>
                 <Container row>
                   <Icon height="18px" src={pull_request} />
                   <Icon height="18px" src={pull_request} />
                   <Spacer inline width="12px" />
                   <Spacer inline width="12px" />
-                  <Text size={14}>{env.selector}</Text>
+                  <Text size={14}>{env.namespace}</Text>
                   <Spacer inline x={2} />
                   <Spacer inline x={2} />
                 </Container>
                 </Container>
                 <StatusIcon src={healthy} />
                 <StatusIcon src={healthy} />
@@ -95,14 +95,14 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           return (
           return (
             <Link
             <Link
               to={`/preview-environments/apps?target=${env.id}`}
               to={`/preview-environments/apps?target=${env.id}`}
-              key={env.selector}
+              key={env.namespace}
             >
             >
               <Row>
               <Row>
                 <Container row>
                 <Container row>
                   <Spacer inline width="1px" />
                   <Spacer inline width="1px" />
                   <Icon height="18px" src={pull_request} />
                   <Icon height="18px" src={pull_request} />
                   <Spacer inline width="12px" />
                   <Spacer inline width="12px" />
-                  <Text size={14}>{env.selector}</Text>
+                  <Text size={14}>{env.namespace}</Text>
                   <Spacer inline x={1} />
                   <Spacer inline x={1} />
                   <Icon height="16px" src={healthy} />
                   <Icon height="16px" src={healthy} />
                 </Container>
                 </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 Loading from "components/Loading";
 import Container from "components/porter/Container";
 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 Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 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 grid from "assets/grid.png";
 import list from "assets/list.png";
 import list from "assets/list.png";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import letter from "assets/vector.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 DashboardHeader from "../../DashboardHeader";
-import Fieldset from "components/porter/Fieldset";
-import Button from "components/porter/Button";
 import PreviewEnvGrid from "./PreviewEnvGrid";
 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 PreviewEnvs: React.FC = () => {
-  const { currentProject, currentCluster } = useContext(Context);
-
   const [searchValue, setSearchValue] = useState("");
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState<"grid" | "list">("grid");
   const [view, setView] = useState<"grid" | "list">("grid");
   const [sort, setSort] = useState<"calendar" | "letter">("calendar");
   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" />;
       return <Loading offset="-150px" />;
     }
     }
 
 
-    if (!deploymentTargets || deploymentTargets.length === 0) {
+    if (deploymentTargetList.length === 0) {
       <Fieldset>
       <Fieldset>
         <CentralContainer>
         <CentralContainer>
           <Text size={16}>No preview environments have been deployed yet.</Text>
           <Text size={16}>No preview environments have been deployed yet.</Text>
@@ -137,7 +91,7 @@ const PreviewEnvs: React.FC = () => {
         </Container>
         </Container>
         <Spacer y={1} />
         <Spacer y={1} />
         <PreviewEnvGrid
         <PreviewEnvGrid
-          deploymentTargets={deploymentTargets}
+          deploymentTargets={deploymentTargetList}
           sort={sort}
           sort={sort}
           view={view}
           view={view}
           searchValue={searchValue}
           searchValue={searchValue}
@@ -179,51 +133,3 @@ const ToggleIcon = styled.img`
   margin: 0 5px;
   margin: 0 5px;
   min-width: 12px;
   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 React, { createContext, useContext, useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
 import { useLocation } from "react-router";
 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<{
 export const DeploymentTargetContext = createContext<{
   currentDeploymentTarget: DeploymentTarget | null;
   currentDeploymentTarget: DeploymentTarget | null;
 } | null>(null);
 } | null>(null);
 
 
-export const useDeploymentTarget = () => {
+export const useDeploymentTarget = (): {
+  currentDeploymentTarget: DeploymentTarget | null;
+} => {
   const context = useContext(DeploymentTargetContext);
   const context = useContext(DeploymentTargetContext);
   if (context === null) {
   if (context === null) {
     throw new Error(
     throw new Error(
@@ -22,34 +28,78 @@ export const useDeploymentTarget = () => {
   return context;
   return context;
 };
 };
 
 
-const DeploymentTargetProvider = ({ children }: { children: JSX.Element }) => {
+const DeploymentTargetProvider = ({
+  children,
+}: {
+  children: JSX.Element;
+}): JSX.Element => {
   const { search } = useLocation();
   const { search } = useLocation();
+  const { currentCluster, currentProject } = useContext(Context);
   const queryParams = new URLSearchParams(search);
   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(() => {
   const deploymentTarget: DeploymentTarget | null = useMemo(() => {
-    if (!idParam && !defaultDeploymentTarget) {
+    if (!deploymentTargetID && isDefaultDeploymentTargetLoading) {
       return null;
       return null;
     }
     }
 
 
-    if (idParam) {
-      return {
-        id: idParam,
-        isPreview: true,
-      };
+    if (deploymentTargetID) {
+      if (status === "loading" || !deploymentTargetFromIdParam) {
+        return null;
+      }
+
+      return deploymentTargetFromIdParam;
     }
     }
 
 
     if (defaultDeploymentTarget) {
     if (defaultDeploymentTarget) {
-      return {
-        id: defaultDeploymentTarget.deployment_target_id,
-        isPreview: false,
-      };
+      return defaultDeploymentTarget;
     }
     }
 
 
     return null;
     return null;
-  }, [idParam, defaultDeploymentTarget]);
+  }, [
+    deploymentTargetID,
+    isDefaultDeploymentTargetLoading,
+    defaultDeploymentTarget,
+    deploymentTargetFromIdParam,
+    status,
+  ]);
 
 
   return (
   return (
     <DeploymentTargetContext.Provider
     <DeploymentTargetContext.Provider

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

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

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

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

+ 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
 // ToDeploymentTargetType generates an external types.PorterApp to be shared over REST
 func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget {
 func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget {
 	return &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 controls whether a project's AWS access is governed through AWS ACK
 	AWSACKAuthEnabled FeatureFlagLabel = "aws_ack_auth_enabled"
 	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
 // ProjectFeatureFlags keeps track of all project-related feature flags
 var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 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
 type ProjectPlan string
@@ -264,26 +268,27 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		Name:  projectName,
 		Name:  projectName,
 		Roles: roles,
 		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 is the time the revision was updated
 	UpdatedAt time.Time `json:"updated_at"`
 	UpdatedAt time.Time `json:"updated_at"`
 	// DeploymentTargetID is the id of the deployment target the revision is associated with
 	// 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 is the environment variables for the revision
 	Env environment_groups.EnvironmentGroup `json:"env,omitempty"`
 	Env environment_groups.EnvironmentGroup `json:"env,omitempty"`
 	// AppInstanceID is the id of the app instance the revision is associated with
 	// AppInstanceID is the id of the app instance the revision is associated with
 	AppInstanceID uuid.UUID `json:"app_instance_id"`
 	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
 // GetAppRevisionInput is the input struct for GetAppRevisions
 type GetAppRevisionInput struct {
 type GetAppRevisionInput struct {
 	ProjectID     uint
 	ProjectID     uint
@@ -122,14 +128,14 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 	}
 	}
 
 
 	revision = Revision{
 	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
 	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")
 		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 {
 	if err != nil {
 		return revision, telemetry.Error(ctx, span, err, "error getting app env group name")
 		return revision, telemetry.Error(ctx, span, err, "error getting app env group name")
 	}
 	}