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

Merge branch 'master' into doppler-fe

d-g-town 2 лет назад
Родитель
Сommit
36d5248fec
50 измененных файлов с 1258 добавлено и 841 удалено
  1. 2 0
      api/server/handlers/datastore/status.go
  2. 28 2
      api/server/handlers/deployment_target/get.go
  3. 31 1
      api/server/handlers/porter_app/default_deployment_target.go
  4. 1 1
      api/server/handlers/porter_app/get_app_env.go
  5. 1 1
      api/server/handlers/porter_app/get_build.go
  6. 1 1
      api/server/handlers/porter_app/get_build_env.go
  7. 46 13
      api/server/handlers/porter_app/latest_app_revisions.go
  8. 2 2
      api/server/handlers/porter_app/report_status.go
  9. 56 3
      api/server/handlers/porter_app/yaml_from_revision.go
  10. 6 4
      api/types/deployment_target.go
  11. 37 35
      api/types/project.go
  12. 1 0
      cli/cmd/commands/helm.go
  13. 1 0
      cli/cmd/commands/kubectl.go
  14. 7 7
      dashboard/package-lock.json
  15. 1 1
      dashboard/package.json
  16. 5 0
      dashboard/src/assets/dot-vertical.svg
  17. 15 27
      dashboard/src/components/AzureCostConsent.tsx
  18. 78 26
      dashboard/src/components/AzureProvisionerSettings.tsx
  19. 2 3
      dashboard/src/components/GCPProvisionerSettings.tsx
  20. 1 1
      dashboard/src/lib/hooks/useClusterResourceLimits.ts
  21. 89 18
      dashboard/src/lib/hooks/useDeploymentTarget.ts
  22. 6 1
      dashboard/src/lib/revisions/types.ts
  23. 25 22
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  24. 5 5
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  25. 3 47
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  26. 0 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx
  27. 44 32
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx
  28. 2 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts
  29. 29 21
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  30. 57 96
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  31. 50 9
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  32. 7 5
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  33. 32 29
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx
  34. 88 16
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  35. 8 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  36. 15 109
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  37. 177 77
      dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx
  38. 59 50
      dashboard/src/main/home/sidebar/Sidebar.tsx
  39. 70 20
      dashboard/src/shared/DeploymentTargetContext.tsx
  40. 65 62
      dashboard/src/shared/api.tsx
  41. 5 0
      dashboard/src/shared/types.tsx
  42. 1 1
      go.mod
  43. 2 6
      go.sum
  44. 4 4
      internal/kubernetes/prometheus/metrics.go
  45. 9 7
      internal/models/deployment_target.go
  46. 45 40
      internal/models/project.go
  47. 16 10
      internal/porter_app/revisions.go
  48. 2 2
      internal/porter_app/v1/yaml.go
  49. 20 13
      internal/porter_app/v2/yaml.go
  50. 1 1
      services/cli_install_script_container/install.sh

+ 2 - 0
api/server/handlers/datastore/status.go

@@ -69,6 +69,8 @@ func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_POSTGRESQL
 	case "rds-postgresql-aurora":
 		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_AURORA_POSTGRESQL
+	case "elasticache-redis":
+		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_ELASTICACHE_REDIS
 	default:
 		err := telemetry.Error(ctx, span, nil, "invalid datastore specified")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 6 - 4
api/types/deployment_target.go

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

+ 37 - 35
api/types/project.go

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

+ 1 - 0
cli/cmd/commands/helm.go

@@ -46,6 +46,7 @@ func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 
 	execCommand := exec.Command("helm", args...)
 
+	execCommand.Stdin = os.Stdin
 	execCommand.Stdout = os.Stdout
 	execCommand.Stderr = os.Stderr
 

+ 1 - 0
cli/cmd/commands/kubectl.go

@@ -64,6 +64,7 @@ func runKubectl(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 
 	execCommand := exec.Command("kubectl", args...)
 
+	execCommand.Stdin = os.Stdin
 	execCommand.Stdout = os.Stdout
 	execCommand.Stderr = os.Stderr
 

+ 7 - 7
dashboard/package-lock.json

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

+ 1 - 1
dashboard/package.json

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

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

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

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

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

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

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

+ 2 - 3
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -67,11 +67,10 @@ const defaultClusterNetworking = new GKENetwork({
 
 const instanceTypes = [
   { value: "e2-standard-2", label: "e2-standard-2" },
-  { value: "e2-standard-2", label: "e2-standard-4" },
+  { value: "e2-standard-4", label: "e2-standard-4" },
   { value: "e2-standard-8", label: "e2-standard-8" },
   { value: "e2-standard-16", label: "e2-standard-16" },
   { value: "e2-standard-32", label: "e2-standard-32" },
-  { value: "e2-standard-32", label: "e2-standard-32" },
   // { value: "n1-standard-1", label: "n1-standard-1" }, // start of GPU nodes. 
   // { value: "n1-standard-2", label: "n1-standard-2" },
   // { value: "n1-standard-4", label: "n1-standard-4" },
@@ -550,7 +549,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
             options={locationOptions}
             width="350px"
-            disabled={isReadOnly || isLoading}
+            disabled={true}
             value={region}
             scrollBuffer={true}
             dropdownMaxHeight="240px"

+ 1 - 1
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -28,7 +28,7 @@ const encodedContractValidator = z.object({
   cluster_id: z.number(),
   project_id: z.number(),
   condition: z.string(),
-  condition_metadata: z.record(z.any()),
+  condition_metadata: z.record(z.any()).nullable(),
 });
 
 export type NodeGroup = {

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

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

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

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

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

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

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

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

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

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

+ 0 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -38,7 +38,6 @@ type Props = {
   clusterId: number;
   gitCommitUrl: string;
   displayCommitSha: string;
-  isMostRecentDeployEvent: boolean;
 };
 
 const DeployEventCard: React.FC<Props> = ({

+ 44 - 32
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -33,25 +33,30 @@ const EventCard: React.FC<Props> = ({
       return "";
     }
 
-    return match(event)
-      .with({ type: "APP_EVENT" }, () => "")
-      .with({ type: "NOTIFICATION" }, () => "")
-      .with({ type: "BUILD" }, (event) =>
-        event.metadata.commit_sha
-          ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
-          : ""
-      )
-      .with({ type: "PRE_DEPLOY" }, (event) =>
-        event.metadata.commit_sha
-          ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
-          : ""
-      )
-      .with({ type: "DEPLOY" }, (event) =>
-        event.metadata.image_tag
-          ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.image_tag}`
-          : ""
-      )
-      .exhaustive();
+    return (
+      match(event)
+        .with({ type: "APP_EVENT" }, () => "")
+        .with({ type: "NOTIFICATION" }, () => "")
+        .with({ type: "BUILD" }, (event) =>
+          event.metadata.commit_sha
+            ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
+            : ""
+        )
+        // TODO: remove check for commit_sha when update flow is GA'd
+        .with({ type: "PRE_DEPLOY" }, (event) =>
+          event.metadata.commit_sha
+            ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
+            : event.metadata.image_tag
+            ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.image_tag}`
+            : ""
+        )
+        .with({ type: "DEPLOY" }, (event) =>
+          event.metadata.image_tag
+            ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.image_tag}`
+            : ""
+        )
+        .exhaustive()
+    );
   }, [JSON.stringify(event), porterApp]);
 
   const displayCommitSha = useMemo(() => {
@@ -59,19 +64,26 @@ const EventCard: React.FC<Props> = ({
       return "";
     }
 
-    return match(event)
-      .with({ type: "APP_EVENT" }, () => "")
-      .with({ type: "NOTIFICATION" }, () => "")
-      .with({ type: "BUILD" }, (event) =>
-        event.metadata.commit_sha ? event.metadata.commit_sha.slice(0, 7) : ""
-      )
-      .with({ type: "PRE_DEPLOY" }, (event) =>
-        event.metadata.commit_sha ? event.metadata.commit_sha.slice(0, 7) : ""
-      )
-      .with({ type: "DEPLOY" }, (event) =>
-        event.metadata.image_tag ? event.metadata.image_tag.slice(0, 7) : ""
-      )
-      .exhaustive();
+    return (
+      match(event)
+        .with({ type: "APP_EVENT" }, () => "")
+        .with({ type: "NOTIFICATION" }, () => "")
+        .with({ type: "BUILD" }, (event) =>
+          event.metadata.commit_sha ? event.metadata.commit_sha.slice(0, 7) : ""
+        )
+        // TODO: remove check for commit_sha when update flow is GA'd
+        .with({ type: "PRE_DEPLOY" }, (event) =>
+          event.metadata.commit_sha
+            ? event.metadata.commit_sha.slice(0, 7)
+            : event.metadata.image_tag
+            ? event.metadata.image_tag.slice(0, 7)
+            : ""
+        )
+        .with({ type: "DEPLOY" }, (event) =>
+          event.metadata.image_tag ? event.metadata.image_tag.slice(0, 7) : ""
+        )
+        .exhaustive()
+    );
   }, [JSON.stringify(event), porterApp]);
 
   return match(event)

+ 2 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts

@@ -41,7 +41,8 @@ const porterAppPreDeployEventMetadataValidator = z.object({
   start_time: z.string(),
   end_time: z.string().optional(),
   app_revision_id: z.string(),
-  commit_sha: z.string().optional(),
+  image_tag: z.string().optional(), // used by the update flow
+  commit_sha: z.string().optional(), // used by the apply flow. TODO: remove this field
 });
 
 const serviceNoticationValidator = z.object({

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

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

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

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

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

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

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

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

+ 32 - 29
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx

@@ -150,7 +150,7 @@ const Resources: React.FC<ResourcesProps> = ({
           <IntelligentSlider
             label="CPUs: "
             unit="Cores"
-            min={0}
+            min={0.01}
             max={maxCPU}
             color={"#3f51b5"}
             value={value.value.toString()}
@@ -192,7 +192,7 @@ const Resources: React.FC<ResourcesProps> = ({
           <IntelligentSlider
             label="RAM: "
             unit="MB"
-            min={0}
+            min={1}
             max={maxRAM}
             color={"#3f51b5"}
             value={value.value.toString()}
@@ -247,7 +247,7 @@ const Resources: React.FC<ResourcesProps> = ({
                           gpuCoresNvidia: {
                             ...value.gpuCoresNvidia,
                             value: value.enabled.value ? 0 : 1,
-                          }
+                          },
                         });
                       }}
                       inputProps={{ "aria-label": "controlled" }}
@@ -265,16 +265,18 @@ const Resources: React.FC<ResourcesProps> = ({
                           You cluster has no GPU nodes available.
                         </Text>
                         <Spacer inline x={0.5} />
-                        {currentCluster.status !== "UPDATING" && <Tag>
-                          <Link
-                            onClick={() => {
-                              setClusterModalVisible(true);
-                            }}
-                          >
-                            <TagIcon src={addCircle} />
-                            Add GPU nodes
-                          </Link>
-                        </Tag>}
+                        {currentCluster.status !== "UPDATING" && (
+                          <Tag>
+                            <Link
+                              onClick={() => {
+                                setClusterModalVisible(true);
+                              }}
+                            >
+                              <TagIcon src={addCircle} />
+                              Add GPU nodes
+                            </Link>
+                          </Tag>
+                        )}
                       </>
                     )}
                   </Container>
@@ -291,22 +293,23 @@ const Resources: React.FC<ResourcesProps> = ({
                 </>
               )}
             />
-            {(currentCluster.status === "UPDATING" && !clusterContainsGPUNodes) && (
-              <CheckItemContainer>
-                <CheckItemTop>
-                  <Loading offset="0px" width="20px" height="20px" />
-                  <Spacer inline x={1} />
-                  <Text>{"Cluster is updating..."}</Text>
-                  <Spacer inline x={1} />
-                  <Tag>
-                    <Link to={`/cluster-dashboard`}>
-                      <TagIcon src={infra} />
-                      View Status
-                    </Link>
-                  </Tag>
-                </CheckItemTop>
-              </CheckItemContainer>
-            )}
+            {currentCluster.status === "UPDATING" &&
+              !clusterContainsGPUNodes && (
+                <CheckItemContainer>
+                  <CheckItemTop>
+                    <Loading offset="0px" width="20px" height="20px" />
+                    <Spacer inline x={1} />
+                    <Text>{"Cluster is updating..."}</Text>
+                    <Spacer inline x={1} />
+                    <Tag>
+                      <Link to={`/cluster-dashboard`}>
+                        <TagIcon src={infra} />
+                        View Status
+                      </Link>
+                    </Tag>
+                  </CheckItemTop>
+                </CheckItemContainer>
+              )}
           </>
         )}
       {match(service.config)

+ 88 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,6 +28,9 @@ import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
 import { useStackEnvGroups } from "./useStackEnvGroups";
 import DeployStatusSection from "./deploy-status-section/DeployStatusSection";
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+
 
 type Props = {
   namespace: string;
@@ -48,6 +51,12 @@ const getReadableDate = (s: string) => {
   return `${time} on ${date}`;
 };
 
+const templateWhitelist = [
+  "elasticache-redis",
+  "rds-postgresql",
+  "rds-postgresql-aurora",
+];
+
 const ExpandedChart: React.FC<Props> = (props) => {
   const [currentChart, setCurrentChart] = useState<ChartType>(
     props.currentChart
@@ -79,6 +88,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [logData, setLogData] = useState<InitLogData>({});
   const [overrideCurrentTab, setOverrideCurrentTab] = useState("");
   const [isAgentInstalled, setIsAgentInstalled] = useState<boolean>(false);
+  const [databaseStatus, setDatabaseStatus] = useState<boolean>(true);
 
   const {
     isStack,
@@ -105,6 +115,26 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setOverrideCurrentTab("logs");
   };
 
+  const updateDatabaseStatuses = async (): Promise<void> => {
+    try {
+
+      const statusRes = await api.getDatabaseStatus("<token>", {
+        name: currentChart.name,
+        type: currentChart.chart.metadata.name
+      }, {
+        project_id: currentProject?.id ?? 0,
+        cluster_id: currentCluster?.id ?? 0,
+      });
+      if (statusRes.data.status === "available") {
+        setDatabaseStatus(true);
+      }
+      else {
+        setDatabaseStatus(false);
+      }
+    } catch (err) {
+      setDatabaseStatus(false);
+    }
+  };
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
     setIsLoadingChartData(true);
@@ -192,7 +222,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
           if (
             oldControllers &&
             oldControllers[object.metadata.uid]?.status?.conditions ==
-              object.status?.conditions
+            object.status?.conditions
           ) {
             return oldControllers;
           }
@@ -463,7 +493,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   being deployed
                 </Header>
                 {props.currentChart.git_action_config &&
-                props.currentChart.git_action_config.gitlab_integration_id ? (
+                  props.currentChart.git_action_config.gitlab_integration_id ? (
                   <>
                     Navigate to the{" "}
                     <A
@@ -776,6 +806,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
       });
   }, [currentChart]);
 
+
+
   useEffect(() => {
     if (logData.revision) {
       api
@@ -818,7 +850,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
           });
       });
     });
-
+    if (templateWhitelist.includes(currentChart.chart.metadata.name)) {
+      void updateDatabaseStatuses()
+    }
     return () => {
       closeAllWebsockets();
     };
@@ -881,6 +915,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         }
       })
       .catch(console.log);
+
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
 
@@ -899,7 +934,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
               isFullscreen={true}
               setIsFullscreen={setIsFullscreen}
               currentChart={currentChart}
-              setInitData={() => {}}
+              setInitData={() => { }}
             />
           ) : (
             <StyledExpandedChart>
@@ -937,15 +972,24 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     margin_left={"0px"}
                   />
                   */}
-                  <DeployStatusSection
-                    chart={currentChart}
-                    setLogData={renderLogsAtTimestamp}
-                  />
-                  <LastDeployed>
-                    <Dot>•</Dot>Last deployed
-                    {" " + getReadableDate(currentChart.info.last_deployed)}
-                  </LastDeployed>
+                  {!templateWhitelist.includes(currentChart.chart.metadata.name) &&
+                    <><DeployStatusSection
+                      chart={currentChart}
+                      setLogData={renderLogsAtTimestamp} /><LastDeployed>
+                        <Dot>•</Dot>Last deployed
+                        {" " + getReadableDate(currentChart.info.last_deployed)}
+                      </LastDeployed></>
+                  }
                 </InfoWrapper>
+
+                {!databaseStatus &&
+                  <>
+                    <Banner>
+                      <BannerContents>
+                        <b>Database is being created</b>
+                      </BannerContents>
+                      <Spacer inline width="5px" />
+                    </Banner></>}
               </HeaderWrapper>
               {deleting ? (
                 <>
@@ -976,7 +1020,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     shouldUpdate={
                       currentChart.latest_version &&
                       currentChart.latest_version !==
-                        currentChart.chart.metadata.version
+                      currentChart.chart.metadata.version
                     }
                     latestVersion={currentChart.latest_version}
                     upgradeVersion={handleUpgradeVersion}
@@ -1056,7 +1100,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </StyledExpandedChart>
           )}
         </>
-      )}
+      )
+      }
     </>
   );
 };
@@ -1186,11 +1231,11 @@ const TabButton = styled.div`
   border-radius: 20px;
   text-shadow: 0px 0px 8px
     ${(props: { devOpsMode: boolean }) =>
-      props.devOpsMode ? "#ffffff66" : "none"};
+    props.devOpsMode ? "#ffffff66" : "none"};
   cursor: pointer;
   :hover {
     color: ${(props: { devOpsMode: boolean }) =>
-      props.devOpsMode ? "" : "#aaaabb99"};
+    props.devOpsMode ? "" : "#aaaabb99"};
   }
 
   > i {
@@ -1279,3 +1324,30 @@ const A = styled.a`
   text-decoration: underline;
   cursor: pointer;
 `;
+
+
+const BannerContents = styled.div`
+  display: flex;
+  flex-direction: column;
+  row-gap: 0.5rem;
+`;
+
+const CloseButton = styled.div`
+  display: block;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;

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

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

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

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

+ 177 - 77
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -22,7 +22,6 @@ import api from "shared/api";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
 import { search } from "shared/search";
 
-import Loading from "components/Loading";
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import Fieldset from "components/porter/Fieldset";
@@ -37,6 +36,7 @@ import { readableDate } from "shared/string_utils";
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import loading from "assets/loading.gif";
 
 type Props = {};
 
@@ -46,7 +46,7 @@ const templateWhitelist = [
   "rds-postgresql-aurora",
 ];
 
-const Apps: React.FC<Props> = ({ 
+const Apps: React.FC<Props> = ({
 }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
@@ -57,6 +57,7 @@ const Apps: React.FC<Props> = ({
   // Placeholder (replace w useQuery)
   const [databases, setDatabases] = useState([]);
   const [status, setStatus] = useState("");
+  const [databaseStatuses, setDatabaseStatuses] = useState({});
 
   const filteredDatabases = useMemo(() => {
     const filteredBySearch = search(
@@ -71,6 +72,36 @@ const Apps: React.FC<Props> = ({
     return _.sortBy(filteredBySearch);
   }, [databases, searchValue]);
 
+  const updateDatabaseStatuses = async (): Promise<void> => {
+    const newStatuses = {};
+    for (const db of filteredDatabases) {
+      try {
+        if (databaseStatuses[db.name] !== "available") {
+          console.log(db)
+          const statusRes = await api.getDatabaseStatus("<token>", {
+            name: db.name,
+            type: db.chart.metadata.name
+          }, {
+            project_id: currentProject?.id ?? 0,
+            cluster_id: currentCluster?.id ?? 0,
+          });
+          if (statusRes.data.status === "available") {
+            newStatuses[db.name] = statusRes.data.status;
+          }
+          else {
+            newStatuses[db.name] = "updating";
+          }
+        }// Assuming status is returned in this field
+      } catch (err) {
+        console.error("Error fetching database status for", db.name, err);
+        newStatuses[db.name] = "error"; // Or some error state
+      }
+
+    }
+    setDatabaseStatuses(newStatuses);
+  };
+
+
   const getExpandedChartLinkURL = useCallback((x: any) => {
     const params = new Proxy(new URLSearchParams(window.location.search), {
       get: (searchParams, prop: string) => searchParams.get(prop),
@@ -126,13 +157,54 @@ const Apps: React.FC<Props> = ({
     };
   };
 
+  useEffect(() => {
+    // Call once when the component mounts
+    void updateDatabaseStatuses();
+
+    // Set up the interval for polling every 5 minutes
+    const intervalId = setInterval(() => {
+      void updateDatabaseStatuses();
+    }, 60000); // 60000 milliseconds = 5 minutes
+
+    // Clear interval on component unmount
+    return () => clearInterval(intervalId);
+  }, [filteredDatabases]);
+
   useEffect(() => {
     // currentCluster sometimes returns as -1 and passes null check
+
     if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
       getAddOns();
     }
   }, [currentCluster, currentProject]);
 
+  const renderStatusIcon = (dbName: string): JSX.Element => {
+    const status: string = databaseStatuses[dbName];
+    switch (status) {
+      case "available":
+        return <StatusIcon src={healthy} />;
+      case "":
+        return <></>;
+      case "error":
+        return <StatusText>
+          <StatusWrapper success={false}>
+            <Loading src={loading} />
+            {"Creating database"}
+          </StatusWrapper>
+        </StatusText>
+      case "updating":
+        return <StatusText>
+          <StatusWrapper success={false}>
+            <Loading src={loading} />
+            {"Creating database"}
+          </StatusWrapper>
+        </StatusText>
+      default:
+        return <></>;
+    }
+  };
+
+
   const renderContents = () => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
@@ -248,7 +320,7 @@ const Apps: React.FC<Props> = ({
                     <Text size={14}>{app.name}</Text>
                     <Spacer inline x={2} />
                   </Container>
-                  <StatusIcon src={healthy} />
+                  {renderStatusIcon(app.name)}
                   <Container row>
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
@@ -309,109 +381,137 @@ const Apps: React.FC<Props> = ({
 export default Apps;
 
 const MidIcon = styled.img<{ height?: string }>`
-  height: ${props => props.height || "18px"};
-  margin-right: 11px;
-`;
+          height: ${props => props.height || "18px"};
+          margin-right: 11px;
+          `;
 
 const Row = styled(Link) <{ isAtBottom?: boolean }>`
-  cursor: pointer;
-  display: block;
-  padding: 15px;
-  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${props => props.theme.clickable.bg};
-  position: relative;
-  border: 1px solid #494b4f;
-  border-radius: 5px;
-  margin-bottom: 15px;
-  animation: fadeIn 0.3s 0s;
-`;
+            cursor: pointer;
+            display: block;
+            padding: 15px;
+            border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
+            background: ${props => props.theme.clickable.bg};
+            position: relative;
+            border: 1px solid #494b4f;
+            border-radius: 5px;
+            margin-bottom: 15px;
+            animation: fadeIn 0.3s 0s;
+            `;
 
 const List = styled.div`
-  overflow: hidden;
-`;
+            overflow: hidden;
+            `;
 
 const SmallIcon = styled.img<{ opacity?: string }>`
-  margin-left: 2px;
-  height: 14px;
-  opacity: ${props => props.opacity || 1};
-  margin-right: 10px;
-`;
+              margin-left: 2px;
+              height: 14px;
+              opacity: ${props => props.opacity || 1};
+              margin-right: 10px;
+              `;
 
 const StatusIcon = styled.img`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  height: 18px;
-`;
+              position: absolute;
+              top: 20px;
+              right: 20px;
+              height: 18px;
+              `;
 
 const Icon = styled.img`
-  height: 20px;
-  margin-right: 13px;
-`;
+              height: 20px;
+              margin-right: 13px;
+              `;
 
 const Block = styled(Link)`
-  height: 110px;
-  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;
+              height: 110px;
+              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;
+              animation: fadeIn 0.3s 0s;
+              @keyframes fadeIn {
+                from {
+                opacity: 0;
     }
-    to {
-      opacity: 1;
+              to {
+                opacity: 1;
     }
   }
-`;
+              `;
 
 const GridList = styled.div`
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-`;
+              display: grid;
+              grid-column-gap: 25px;
+              grid-row-gap: 25px;
+              grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+              `;
 
 const PlaceholderIcon = styled.img`
-  height: 13px;
-  margin-right: 12px;
-  opacity: 0.65;
-`;
+              height: 13px;
+              margin-right: 12px;
+              opacity: 0.65;
+              `;
 
 const ToggleIcon = styled.img`
-  height: 12px;
-  margin: 0 5px;
-  min-width: 12px;
-`;
+              height: 12px;
+              margin: 0 5px;
+              min-width: 12px;
+              `;
 
 const I = styled.i`
-  color: white;
-  font-size: 14px;
+              color: white;
+              font-size: 14px;
+              display: flex;
+              align-items: center;
+              margin-right: 5px;
+              justify-content: center;
+              `;
+
+const StyledAppDashboard = styled.div`
+              width: 100%;
+              height: 100%;
+              `;
+
+const StatusText = styled.div`
+  position: absolute;
+  top: 20px;
+  right: 20px;
   display: flex;
   align-items: center;
-  margin-right: 5px;
   justify-content: center;
 `;
 
-const StyledAppDashboard = styled.div`
-  width: 100%;
-  height: 100%;
-`;
-
-const CentralContainer = styled.div`
+const StatusWrapper = styled.div<{
+  success?: boolean;
+}>`
   display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
+  line-height: 1.5;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-left: 15px;
+  text-overflow: ellipsis;
+  animation-fill-mode: forwards;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${(props) => (props.success ? "#4797ff" : "#fcba03")};
+  }
 `;
+const Loading = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;

+ 59 - 50
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -148,21 +148,21 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/integrations"}>
-              <Img src={integrations} />
-              Integrations
-            </NavButton>
-          )}
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-            <NavButton path={"/project-settings"}>
-              <Img src={settings} />
-              Project settings
-            </NavButton>
-          )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
 
           <br />
 
@@ -189,22 +189,22 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-              <NavButton path={"/project-settings"}>
-                <Img src={settings} />
-                Project settings
-              </NavButton>
-            )}
+                <NavButton path={"/project-settings"}>
+                  <Img src={settings} />
+                  Project settings
+                </NavButton>
+              )}
             {this.props.isAuthorized("integrations", "", [
               "get",
               "create",
               "update",
               "delete",
             ]) && (
-              <NavButton path={"/integrations"}>
-                <Img src={integrations} />
-                Integrations
-              </NavButton>
-            )}
+                <NavButton path={"/integrations"}>
+                  <Img src={integrations} />
+                  Integrations
+                </NavButton>
+              )}
             {currentCluster && (
               <>
                 <Spacer y={0.5} />
@@ -218,6 +218,15 @@ class Sidebar extends Component<PropsType, StateType> {
               <Img src={applications} />
               Applications
             </NavButton>
+            {currentProject.db_enabled && (
+              <NavButton
+                path="/databases"
+                active={window.location.pathname.startsWith("/apps")}
+              >
+                <Img src={database} />
+                Databases
+              </NavButton>
+            )}
             <NavButton
               path="/addons"
               active={window.location.pathname.startsWith("/addons")}
@@ -237,16 +246,16 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-              <NavButton
-                path={"/cluster-dashboard"}
-                active={window.location.pathname.startsWith(
-                  "/cluster-dashboard"
-                )}
-              >
-                <Img src={settings} />
-                Infrastructure
-              </NavButton>
-            )}
+                <NavButton
+                  path={"/cluster-dashboard"}
+                  active={window.location.pathname.startsWith(
+                    "/cluster-dashboard"
+                  )}
+                >
+                  <Img src={settings} />
+                  Infrastructure
+                </NavButton>
+              )}
 
             {currentProject.preview_envs_enabled && (
               <NavButton path="/preview-environments">
@@ -304,16 +313,16 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-              <NavButton
-                path={"/cluster-dashboard"}
-                active={window.location.pathname.startsWith(
-                  "/cluster-dashboard"
-                )}
-              >
-                <Img src={infra} />
-                Infrastructure
-              </NavButton>
-            )}
+                <NavButton
+                  path={"/cluster-dashboard"}
+                  active={window.location.pathname.startsWith(
+                    "/cluster-dashboard"
+                  )}
+                >
+                  <Img src={infra} />
+                  Infrastructure
+                </NavButton>
+              )}
 
             {currentProject.preview_envs_enabled && (
               <NavButton path="/preview-environments">
@@ -328,22 +337,22 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-              <NavButton path={"/integrations"}>
-                <Img src={integrations} />
-                Integrations
-              </NavButton>
-            )}
+                <NavButton path={"/integrations"}>
+                  <Img src={integrations} />
+                  Integrations
+                </NavButton>
+              )}
 
             {this.props.isAuthorized("settings", "", [
               "get",
               "update",
               "delete",
             ]) && (
-              <NavButton path={"/project-settings"}>
-                <Img src={settings} />
-                Project settings
-              </NavButton>
-            )}
+                <NavButton path={"/project-settings"}>
+                  <Img src={settings} />
+                  Project settings
+                </NavButton>
+              )}
 
             {/* Hacky workaround for setting currentCluster with legacy method */}
             <Clusters

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

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

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

@@ -371,9 +371,8 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   const { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
-    page || 1
-  }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
+    }`;
 });
 
 const createEnvironment = baseApi<
@@ -798,11 +797,9 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -833,11 +830,9 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -853,11 +848,9 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getPorterYamlContents = baseApi<
@@ -873,11 +866,9 @@ const getPorterYamlContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -925,11 +916,9 @@ const getBranchHead = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -961,23 +950,23 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-      name: string;
-      deployment_target_id: string;
-      type: "github";
-      git_repo_id: number;
-      git_branch: string;
-      git_repo_name: string;
-      porter_yaml_path: string;
-    }
+    name: string;
+    deployment_target_id: string;
+    type: "github";
+    git_repo_id: number;
+    git_branch: string;
+    git_repo_name: string;
+    porter_yaml_path: string;
+  }
   | {
-      name: string;
-      deployment_target_id: string;
-      type: "docker-registry";
-      image: {
-        repository: string;
-        tag: string;
-      };
-    },
+    name: string;
+    deployment_target_id: string;
+    type: "docker-registry";
+    image: {
+      repository: string;
+      tag: string;
+    };
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -1038,17 +1027,17 @@ const updateApp = baseApi<
 });
 
 const appRun = baseApi<
-    {
-        deployment_target_id: string;
-        service_name: string;
-    },
-    {
-        project_id: number;
-        cluster_id: number;
-        porter_app_name: string;
-    }
+  {
+    deployment_target_id: string;
+    service_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
 >("POST", (pathParams) => {
-    return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.porter_app_name}/run`;
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.porter_app_name}/run`;
 });
 
 const updateBuildSettings = baseApi<
@@ -1142,7 +1131,7 @@ const getRevision = baseApi<
 
 const porterYamlFromRevision = baseApi<
   {
-      should_format_for_export: boolean;
+    should_format_for_export: boolean;
   },
   {
     project_id: number;
@@ -1169,7 +1158,8 @@ const listAppRevisions = baseApi<
 
 const getLatestAppRevisions = baseApi<
   {
-    deployment_target_id: string;
+    deployment_target_id: string | undefined;
+    ignore_preview_apps: boolean;
   },
   {
     project_id: number;
@@ -1879,6 +1869,20 @@ const updateDatabaseStatus = baseApi<
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/infras/${pathParams.infra_id}/database`;
 });
+// GET /api/projects/{project_id}/clusters/{cluster_id}/datastore/status
+const getDatabaseStatus = baseApi<
+  {
+    name: string;
+    type: string
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/datastore/status`;
+});
 
 const getRepoIntegrations = baseApi("GET", "/api/integrations/repo");
 
@@ -2138,11 +2142,9 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${
-    pathParams.cluster_id
-  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
-    pathParams.version ? "&version=" + pathParams.version : ""
-  }`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
+    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
+    }`;
 });
 
 const getConfigMap = baseApi<
@@ -3200,7 +3202,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3497,4 +3499,5 @@ export default {
 
   // STATUS
   getGithubStatus,
+  getDatabaseStatus
 };

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

@@ -32,6 +32,9 @@ export type DetailedIngressError = {
   message: string;
   error: string;
 };
+export type Annotations = {
+  category: string;
+}
 
 export type ChartType = {
   stack_id: string;
@@ -54,6 +57,7 @@ export type ChartType = {
       description: string;
       icon: string;
       apiVersion: string;
+      annotations?: Annotations;
     };
     files?: Array<{
       data: string;
@@ -288,6 +292,7 @@ export type ProjectType = {
   soc2_controls_enabled: boolean;
   stacks_enabled: boolean;
   validate_apply_v2: boolean;
+  managed_deployment_targets_enabled: boolean;
   roles: Array<{
     id: number;
     kind: string;

+ 1 - 1
go.mod

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

+ 2 - 6
go.sum

@@ -1520,12 +1520,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.55 h1:H8RvD004mX4uWrlRVcL8kzo7ZtFQyZDN+X9j+2bW7fc=
-github.com/porter-dev/api-contracts v0.2.55/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.56 h1:zym8eomipCj7BVQVlnCY4RjNv1tkduY68gsmwVJIfaQ=
-github.com/porter-dev/api-contracts v0.2.56/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.58 h1:8h5ORZtaq2f1vyEdYD88ZOcHQmYJb77zs+lQXY82yrU=
-github.com/porter-dev/api-contracts v0.2.58/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.59 h1:EV2xr9a5FpPHnTsz77W3dV/qWC8MnE0kOD+tBZuwhvE=
+github.com/porter-dev/api-contracts v0.2.59/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

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

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

+ 9 - 7
internal/models/deployment_target.go

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

+ 45 - 40
internal/models/project.go

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

+ 16 - 10
internal/porter_app/revisions.go

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

+ 2 - 2
internal/porter_app/v1/yaml.go

@@ -102,7 +102,7 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.Po
 }
 
 func protoEnumFromType(name string, service Service) porterv1.ServiceType {
-	serviceType := porterv1.ServiceType_SERVICE_TYPE_WORKER
+	serviceType := porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED
 
 	if strings.Contains(name, "web") {
 		serviceType = porterv1.ServiceType_SERVICE_TYPE_WEB
@@ -202,7 +202,7 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 	default:
 		return nil, fmt.Errorf("invalid service type '%s'", serviceType)
 	case porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED:
-		return nil, errors.New("KubernetesService type unspecified")
+		return nil, errors.New("service type unspecified")
 	case porterv1.ServiceType_SERVICE_TYPE_WEB:
 		webConfig, err := webConfigProtoFromConfig(service)
 		if err != nil {

+ 20 - 13
internal/porter_app/v2/yaml.go

@@ -303,7 +303,7 @@ func ProtoFromApp(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp
 }
 
 func protoEnumFromType(name string, service Service) porterv1.ServiceType {
-	serviceType := porterv1.ServiceType_SERVICE_TYPE_WORKER
+	serviceType := porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED
 
 	if strings.Contains(name, "web") {
 		serviceType = porterv1.ServiceType_SERVICE_TYPE_WEB
@@ -481,7 +481,9 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 	}
 
 	for _, envGroup := range appProto.EnvGroups {
-		porterApp.EnvGroups = append(porterApp.EnvGroups, fmt.Sprintf("%s:v%d", envGroup.Name, envGroup.Version))
+		if envGroup != nil {
+			porterApp.EnvGroups = append(porterApp.EnvGroups, fmt.Sprintf("%s:v%d", envGroup.Name, envGroup.Version))
+		}
 	}
 
 	if appProto.EfsStorage != nil {
@@ -494,19 +496,24 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 }
 
 func appServiceFromProto(service *porterv1.Service) (Service, error) {
-	appService := Service{
-		Name:              service.Name,
-		Run:               service.RunOptional,
-		Instances:         service.InstancesOptional,
-		CpuCores:          service.CpuCores,
-		RamMegabytes:      int(service.RamMegabytes),
-		GpuCoresNvidia:    service.GpuCoresNvidia, // nolint:staticcheck // https://linear.app/porter/issue/POR-2137/support-new-gpu-field-in-porteryaml
-		Port:              int(service.Port),
-		SmartOptimization: service.SmartOptimization,
-		GPU: &GPU{
+	var gpu *GPU
+	if service.Gpu != nil {
+		gpu = &GPU{
 			Enabled:        service.Gpu.Enabled,
 			GpuCoresNvidia: int(service.Gpu.GpuCoresNvidia),
-		},
+		}
+	}
+
+	appService := Service{
+		Name:                          service.Name,
+		Run:                           service.RunOptional,
+		Instances:                     service.InstancesOptional,
+		CpuCores:                      service.CpuCores,
+		RamMegabytes:                  int(service.RamMegabytes),
+		GpuCoresNvidia:                service.GpuCoresNvidia, // nolint:staticcheck // https://linear.app/porter/issue/POR-2137/support-new-gpu-field-in-porteryaml
+		Port:                          int(service.Port),
+		SmartOptimization:             service.SmartOptimization,
+		GPU:                           gpu,
 		TerminationGracePeriodSeconds: service.TerminationGracePeriodSeconds,
 	}
 

+ 1 - 1
services/cli_install_script_container/install.sh

@@ -16,7 +16,7 @@ download_and_install() {
     echo "[INFO] Please make sure /usr/local/bin is included in your PATH."
 
     curl -L https://github.com/porter-dev/porter/releases/download/{{ .TagName }}/porter_{{ .TagName }}_${osname}_x86_64.zip --output porter.zip
-    unzip -a porter.zip
+    unzip -o -a porter.zip
     rm porter.zip
 
     chmod +x ./porter