Sfoglia il codice sorgente

Merge branch 'master' of https://github.com/porter-dev/porter

Justin Rhee 3 anni fa
parent
commit
23ab92decd
100 ha cambiato i file con 4940 aggiunte e 1427 eliminazioni
  1. 6 4
      api/client/k8s.go
  2. 54 0
      api/server/authz/preview_environment.go
  3. 1 1
      api/server/handlers/cluster/create_namespace.go
  4. 1 1
      api/server/handlers/cluster/install_agent.go
  5. 4 0
      api/server/handlers/cluster/update.go
  6. 126 0
      api/server/handlers/environment/common.go
  7. 23 33
      api/server/handlers/environment/create.go
  8. 3 20
      api/server/handlers/environment/create_deployment.go
  9. 4 2
      api/server/handlers/environment/delete.go
  10. 12 10
      api/server/handlers/environment/delete_deployment.go
  11. 24 18
      api/server/handlers/environment/enable_pull_request.go
  12. 45 19
      api/server/handlers/environment/finalize_deployment.go
  13. 36 6
      api/server/handlers/environment/finalize_deployment_with_errors.go
  14. 6 14
      api/server/handlers/environment/get_deployment.go
  15. 7 11
      api/server/handlers/environment/get_deployment_by_env.go
  16. 12 0
      api/server/handlers/environment/list_deployments_by_cluster.go
  17. 0 15
      api/server/handlers/environment/reenable_deployment.go
  18. 41 8
      api/server/handlers/environment/update_deployment.go
  19. 45 3
      api/server/handlers/environment/update_deployment_status.go
  20. 118 0
      api/server/handlers/environment/update_environment_settings.go
  21. 30 32
      api/server/handlers/webhook/github_incoming.go
  22. 40 0
      api/server/router/cluster.go
  23. 9 0
      api/server/router/git_installation.go
  24. 5 0
      api/server/router/router.go
  25. 8 0
      api/types/cluster.go
  26. 47 19
      api/types/environment.go
  27. 19 15
      api/types/policy.go
  28. 84 22
      cli/cmd/apply.go
  29. 176 2
      cli/cmd/deploy.go
  30. 42 0
      cmd/migrate/enable_cluster_preview_envs/enable.go
  31. 73 0
      cmd/migrate/enable_cluster_preview_envs/enable_test.go
  32. 182 0
      cmd/migrate/enable_cluster_preview_envs/helpers_test.go
  33. 64 0
      cmd/migrate/main.go
  34. 11 0
      cmd/migrate/startup_migrations/doc.go
  35. 18 0
      cmd/migrate/startup_migrations/global_map.go
  36. 68 0
      dashboard/package-lock.json
  37. 5 2
      dashboard/package.json
  38. 13 8
      dashboard/src/App.tsx
  39. 7 1
      dashboard/src/components/DocsHelper.tsx
  40. 34 0
      dashboard/src/components/OldPlaceholder.tsx
  41. 426 0
      dashboard/src/components/OldTable.tsx
  42. 40 5
      dashboard/src/components/Placeholder.tsx
  43. 1 1
      dashboard/src/components/ProvisionerStatus.tsx
  44. 30 11
      dashboard/src/components/SearchSelector.tsx
  45. 25 350
      dashboard/src/components/Table.tsx
  46. 16 2
      dashboard/src/components/TitleSection.tsx
  47. 16 4
      dashboard/src/components/form-components/CheckboxRow.tsx
  48. 1 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  49. 5 4
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  50. 3 23
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  51. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  52. 51 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  53. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  54. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  55. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  56. 1 1
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  57. 2 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  58. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx
  59. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx
  60. 252 62
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  61. 0 99
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx
  62. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  63. 8 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  64. 56 16
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx
  65. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts
  66. 168 20
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  67. 90 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx
  68. 1 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  69. 164 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/NamespaceAnnotations.tsx
  70. 98 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PorterYAMLErrorsModal.tsx
  71. 14 13
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  72. 213 60
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  73. 284 82
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  74. 358 266
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  75. 0 33
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  76. 461 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx
  77. 11 12
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  78. 484 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx
  79. 31 51
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  80. 3 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/errors.ts
  81. 13 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  82. 6 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  83. 50 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts
  84. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  85. 6 3
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  86. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  87. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  88. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  89. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  90. 4 2
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  91. 17 10
      dashboard/src/main/home/dashboard/Dashboard.tsx
  92. 1 1
      dashboard/src/main/home/infrastructure/ExpandedInfra.tsx
  93. 2 2
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  94. 1 1
      dashboard/src/main/home/infrastructure/components/DeployList.tsx
  95. 1 1
      dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx
  96. 1 1
      dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx
  97. 1 1
      dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx
  98. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx
  99. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx
  100. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx

+ 6 - 4
api/client/k8s.go

@@ -37,8 +37,12 @@ func (c *Client) CreateNewK8sNamespace(
 	ctx context.Context,
 	projectID uint,
 	clusterID uint,
-	name string,
+	req *types.CreateNamespaceRequest,
 ) (*types.NamespaceResponse, error) {
+	if req == nil {
+		return nil, fmt.Errorf("invalid request body for creating namespace")
+	}
+
 	resp := &types.NamespaceResponse{}
 
 	err := c.postRequest(
@@ -46,9 +50,7 @@ func (c *Client) CreateNewK8sNamespace(
 			"/projects/%d/clusters/%d/namespaces/create",
 			projectID, clusterID,
 		),
-		&types.CreateNamespaceRequest{
-			Name: name,
-		},
+		req,
 		resp,
 	)
 

+ 54 - 0
api/server/authz/preview_environment.go

@@ -0,0 +1,54 @@
+package authz
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+var (
+	errPreviewProjectDisabled = errors.New("preview environments are not enabled for this project")
+	errPreviewClusterDisabled = errors.New("preview environments are not enabled for this cluster")
+)
+
+type PreviewEnvironmentScopedFactory struct {
+	config *config.Config
+}
+
+func NewPreviewEnvironmentScopedFactory(
+	config *config.Config,
+) *PreviewEnvironmentScopedFactory {
+	return &PreviewEnvironmentScopedFactory{config}
+}
+
+func (p *PreviewEnvironmentScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &PreviewEnvironmentScopedMiddleware{next, p.config}
+}
+
+type PreviewEnvironmentScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *PreviewEnvironmentScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.PreviewEnvsEnabled {
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r,
+			apierrors.NewErrForbidden(errPreviewProjectDisabled), true)
+		return
+	} else if !cluster.PreviewEnvsEnabled {
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r,
+			apierrors.NewErrForbidden(errPreviewClusterDisabled), true)
+		return
+	}
+
+	// FIXME: use this middleware to also get values for environment_id and deployment_id
+
+	p.next.ServeHTTP(w, r)
+}

+ 1 - 1
api/server/handlers/cluster/create_namespace.go

@@ -55,7 +55,7 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	namespace, err := agent.CreateNamespace(request.Name)
+	namespace, err := agent.CreateNamespace(request.Name, request.Annotations)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 1 - 1
api/server/handlers/cluster/install_agent.go

@@ -75,7 +75,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// create namespace if not exists
-	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system")
+	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system", nil)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 4 - 0
api/server/handlers/cluster/update.go

@@ -65,6 +65,10 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		cluster.AgentIntegrationEnabled = *request.AgentIntegrationEnabled
 	}
 
+	if request.PreviewEnvsEnabled != nil {
+		cluster.PreviewEnvsEnabled = *request.PreviewEnvsEnabled
+	}
+
 	if request.Name != "" && cluster.Name != request.Name {
 		cluster.Name = request.Name
 	}

+ 126 - 0
api/server/handlers/environment/common.go

@@ -0,0 +1,126 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+var (
+	errDeploymentNotFound  = errors.New("no such deployment exists")
+	errEnvironmentNotFound = errors.New("no such environment exists")
+	errGithubAPI           = errors.New("error communicating with the github API")
+)
+
+func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+
+	if err != nil {
+		return nil, fmt.Errorf("malformed GITHUB_APP_ID in server configuration: %w", err)
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(ghAppId),
+		int64(env.GitInstallationID),
+		config.ServerConf.GithubAppSecret,
+	)
+
+	if err != nil {
+		return nil, fmt.Errorf("error in creating github client from preview environment: %w", err)
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}
+
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}
+
+func isGithubPRClosed(
+	client *github.Client,
+	owner, name string,
+	prNumber int,
+) (bool, error) {
+	ghPR, _, err := client.PullRequests.Get(
+		context.Background(), owner, name, prNumber,
+	)
+
+	if err != nil {
+		return false, fmt.Errorf("%v: %w", errGithubAPI, err)
+	}
+
+	return ghPR.GetState() == "closed", nil
+}
+
+func validateGetDeploymentRequest(
+	projectID, clusterID, envID uint,
+	owner, name string,
+	request *types.GetDeploymentRequest,
+	repo repository.Repository,
+) (*models.Deployment, apierrors.RequestError) {
+	if request.PRNumber == 0 && request.DeploymentID == 0 && request.Namespace == "" {
+		return nil, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("one of id, pr_number or namespace must be present in request body"), http.StatusBadRequest,
+		)
+	}
+
+	var depl *models.Deployment
+	var err error
+
+	// read the deployment
+	if request.DeploymentID != 0 {
+		depl, err = repo.Environment().ReadDeploymentByID(projectID, clusterID, request.DeploymentID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	} else if request.PRNumber != 0 {
+		depl, err = repo.Environment().ReadDeploymentByGitDetails(envID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	} else if request.Namespace != "" {
+		depl, err = repo.Environment().ReadDeployment(envID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	}
+
+	if depl == nil {
+		return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+	}
+
+	return depl, nil
+}

+ 23 - 33
api/server/handlers/environment/create.go

@@ -5,10 +5,8 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"strconv"
 	"strings"
 
-	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -60,7 +58,8 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	webhookUID, err := encryption.GenerateRandomBytes(32)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating webhook UID for new preview "+
+			"environment: %w", err)))
 		return
 	}
 
@@ -71,9 +70,20 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:                request.Name,
 		GitRepoOwner:        owner,
 		GitRepoName:         name,
+		GitRepoBranches:     strings.Join(request.GitRepoBranches, ","),
 		Mode:                request.Mode,
 		WebhookID:           string(webhookUID),
-		NewCommentsDisabled: false,
+		NewCommentsDisabled: request.DisableNewComments,
+	}
+
+	if len(request.NamespaceAnnotations) > 0 {
+		var annotations []string
+
+		for k, v := range request.NamespaceAnnotations {
+			annotations = append(annotations, fmt.Sprintf("%s=%s", k, v))
+		}
+
+		env.NamespaceAnnotations = []byte(strings.Join(annotations, ","))
 	}
 
 	// write Github actions files to the repo
@@ -118,7 +128,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating environment: %w", err)))
 		return
 	}
 
@@ -137,11 +147,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
 
 		if deleteErr != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting created preview environment: %w",
+				deleteErr)))
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
 		return
 	}
 
@@ -159,11 +170,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
 
 		if deleteErr != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting created preview environment: %w",
+				deleteErr)))
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
 		return
 	}
 
@@ -190,7 +202,8 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
 			}
 		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error setting up preview environment in the github "+
+				"repo: %w", err)))
 			return
 		}
 	}
@@ -198,29 +211,6 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }
 
-func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
-	// get the github app client
-	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	// authenticate as github app installation
-	itr, err := ghinstallation.New(
-		http.DefaultTransport,
-		int64(ghAppId),
-		int64(env.GitInstallationID),
-		config.ServerConf.GithubAppSecret,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return github.NewClient(&http.Client{Transport: itr}), nil
-}
-
 func getGithubWebhookURLFromUID(serverURL, webhookUID string) string {
 	return fmt.Sprintf("%s/api/github/incoming_webhook/%s", serverURL, string(webhookUID))
 }

+ 3 - 20
api/server/handlers/environment/create_deployment.go

@@ -19,8 +19,6 @@ import (
 	"gorm.io/gorm"
 )
 
-var errGithubAPI = errors.New("error communicating with the github API")
-
 type CreateDeploymentHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -60,7 +58,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
-				fmt.Errorf("error creating deployment: no environment found")),
+				fmt.Errorf("error creating deployment: %w", errEnvironmentNotFound)),
 			)
 			return
 		}
@@ -87,7 +85,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if prClosed {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("cannot create deployment for closed github PR"), http.StatusConflict,
+			fmt.Errorf("attempting to create deployment for a closed github PR"), http.StatusConflict,
 		))
 		return
 	}
@@ -129,22 +127,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// create the backing namespace
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	_, err = agent.CreateNamespace(depl.Namespace)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating deployment: %w", err)))
 		return
 	}
 

+ 4 - 2
api/server/handlers/environment/delete.go

@@ -52,7 +52,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -76,7 +76,9 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	}
 
 	for _, depl := range depls {
-		agent.DeleteNamespace(depl.Namespace)
+		if !isSystemNamespace(depl.Namespace) {
+			agent.DeleteNamespace(depl.Namespace)
+		}
 	}
 
 	ghWebhookID := env.GithubWebhookID

+ 12 - 10
api/server/handlers/environment/delete_deployment.go

@@ -5,7 +5,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -51,7 +50,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment id not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 			return
 		}
 
@@ -67,12 +66,13 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	// make sure we don't delete default or kube-system by checking for prefix, for now
-	if strings.Contains(depl.Namespace, "pr-") {
+	// make sure we do not delete any kubernetes "system" namespaces
+	if !isSystemNamespace(depl.Namespace) {
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting preview deployment namespace: %w",
+				err)))
 			return
 		}
 	}
@@ -82,7 +82,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -90,12 +90,14 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	depl.Status = types.DeploymentStatusInactive
-
-	// update the deployment to mark it inactive
-	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+	_, err = c.Repo().Environment().DeleteDeployment(depl)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 24 - 18
api/server/handlers/environment/enable_pull_request.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -37,6 +36,7 @@ func NewEnablePullRequestHandler(
 func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
 	request := &types.PullRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -55,6 +55,27 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	envType := env.ToEnvironmentType()
+
+	if len(envType.GitRepoBranches) > 0 {
+		found := false
+
+		for _, branch := range env.ToEnvironmentType().GitRepoBranches {
+			if branch == request.BranchInto {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it in the settings page",
+					request.BranchInto), http.StatusBadRequest,
+			))
+			return
+		}
+	}
+
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
@@ -115,12 +136,10 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ToLower(strings.ReplaceAll(env.GitRepoName, "_", "-")))
-
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
-		Namespace:     namespace,
+		Namespace:     "",
 		Status:        types.DeploymentStatusCreating,
 		PullRequestID: request.Number,
 		RepoOwner:     request.RepoOwner,
@@ -135,18 +154,5 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	// create the backing namespace
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	_, err = agent.CreateNamespace(depl.Namespace)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+	c.WriteResult(w, r, depl.ToDeploymentType())
 }

+ 45 - 19
api/server/handlers/environment/finalize_deployment.go

@@ -2,6 +2,7 @@ package environment
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -16,6 +17,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
 )
 
 type FinalizeDeploymentHandler struct {
@@ -49,19 +51,59 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 
@@ -253,19 +295,3 @@ func updateGithubComment(
 
 	return err
 }
-
-func isGithubPRClosed(
-	client *github.Client,
-	owner, name string,
-	prNumber int,
-) (bool, error) {
-	ghPR, _, err := client.PullRequests.Get(
-		context.Background(), owner, name, prNumber,
-	)
-
-	if err != nil {
-		return false, fmt.Errorf("%v: %w", errGithubAPI, err)
-	}
-
-	return ghPR.GetState() == "closed", nil
-}

+ 36 - 6
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -49,6 +49,13 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
 	if len(request.Errors) == 0 {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			fmt.Errorf("at least one error is required to report"), http.StatusPreconditionFailed,
@@ -56,12 +63,14 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	var err error
+
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -69,16 +78,37 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no deployment found for environment ID: %d, namespace: %s", env.ID, request.Namespace)))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 

+ 6 - 14
api/server/handlers/environment/get_deployment.go

@@ -2,7 +2,6 @@ package environment
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -51,26 +50,19 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
 	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("environment not found: is the environment enabled for this git installation?"),
-			http.StatusNotFound,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 		return
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("deployment not found"),
-			http.StatusNotFound,
-		))
-		return
-	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

+ 7 - 11
api/server/handlers/environment/get_deployment_by_env.go

@@ -2,7 +2,6 @@ package environment
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,11 +45,11 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	_, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment with id %d not found", envID)))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -58,15 +57,12 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment not found for namespace: %s", request.Namespace)))
-			return
-		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

+ 12 - 0
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -235,6 +235,12 @@ func fetchOpenPullRequests(
 	env *models.Environment,
 	deplInfoMap map[string]bool,
 ) ([]*types.PullRequest, error) {
+	branchesMap := make(map[string]bool)
+
+	for _, br := range env.ToEnvironmentType().GitRepoBranches {
+		branchesMap[br] = true
+	}
+
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{
@@ -269,6 +275,12 @@ func fetchOpenPullRequests(
 	}
 
 	for _, pr := range openPRs {
+		if len(branchesMap) > 0 {
+			if _, ok := branchesMap[pr.GetBase().GetRef()]; !ok {
+				continue
+			}
+		}
+
 		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
 			prs = append(prs, &types.PullRequest{
 				Title:      pr.GetTitle(),

+ 0 - 15
api/server/handlers/environment/reenable_deployment.go

@@ -97,21 +97,6 @@ func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	// create the backing namespace
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	_, err = agent.CreateNamespace(depl.Namespace)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
 		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
 		github.CreateWorkflowDispatchEventRequest{

+ 41 - 8
api/server/handlers/environment/update_deployment.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
 )
 
 type UpdateDeploymentHandler struct {
@@ -48,6 +50,15 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
@@ -56,11 +67,37 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 
@@ -96,14 +133,10 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	depl.Namespace = request.Namespace
 	depl.GHDeploymentID = ghDeployment.GetID()
 	depl.CommitSHA = request.CommitSHA
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 

+ 45 - 3
api/server/handlers/environment/update_deployment_status.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
 )
 
 type UpdateDeploymentStatusHandler struct {
@@ -48,19 +50,59 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 

+ 118 - 0
api/server/handlers/environment/update_environment_settings.go

@@ -0,0 +1,118 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateEnvironmentSettingsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateEnvironmentSettingsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateEnvironmentSettingsHandler {
+	return &UpdateEnvironmentSettingsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.UpdateEnvironmentSettingsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newBranches []string
+
+	for _, br := range request.GitRepoBranches {
+		name := strings.TrimSpace(br)
+
+		if len(name) > 0 {
+			newBranches = append(newBranches, name)
+		}
+	}
+
+	changed := !reflect.DeepEqual(env.ToEnvironmentType().GitRepoBranches, newBranches)
+
+	if changed {
+		env.GitRepoBranches = strings.Join(request.GitRepoBranches, ",")
+	}
+
+	if request.DisableNewComments != env.NewCommentsDisabled {
+		env.NewCommentsDisabled = request.DisableNewComments
+		changed = true
+	}
+
+	if request.Mode != env.Mode {
+		env.Mode = request.Mode
+		changed = true
+	}
+
+	if len(request.NamespaceAnnotations) > 0 {
+		var annotations []string
+
+		for k, v := range request.NamespaceAnnotations {
+			annotations = append(annotations, fmt.Sprintf("%s=%s", k, v))
+		}
+
+		env.NamespaceAnnotations = []byte(strings.Join(annotations, ","))
+
+		changed = true
+	} else {
+		env.NamespaceAnnotations = []byte{}
+
+		changed = true
+	}
+
+	if changed {
+		env, err = c.Repo().Environment().UpdateEnvironment(env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}

+ 30 - 32
api/server/handlers/webhook/github_incoming.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 	"sync"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
@@ -89,6 +88,23 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			webhookID, owner, repo, err)
 	}
 
+	envType := env.ToEnvironmentType()
+
+	if len(envType.GitRepoBranches) > 0 {
+		found := false
+
+		for _, br := range envType.GitRepoBranches {
+			if br == event.GetPullRequest().GetHead().GetRef() {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			return nil
+		}
+	}
+
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
@@ -100,8 +116,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	if env.Mode == "auto" && event.GetAction() == "opened" {
 		depl := &models.Deployment{
 			EnvironmentID: env.ID,
-			Namespace: fmt.Sprintf("pr-%d-%s", event.GetPullRequest().GetNumber(),
-				strings.ToLower(strings.ReplaceAll(repo, "_", "-"))),
+			Namespace:     "",
 			Status:        types.DeploymentStatusCreating,
 			PullRequestID: uint(event.GetPullRequest().GetNumber()),
 			PRName:        event.GetPullRequest().GetTitle(),
@@ -119,29 +134,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 				"error creating new deployment: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
 
-		cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
-
-		if err != nil {
-			return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster when creating new deployment: %w",
-				env.ProjectID, env.ClusterID, err)
-		}
-
-		// create the backing namespace
-		agent, err := c.GetAgent(r, cluster, "")
-
-		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
-				"error getting k8s agent: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
-		}
-
-		_, err = agent.CreateNamespace(depl.Namespace)
-
-		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
-				"error creating k8s namespace: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
-		}
-
-		_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
 			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
 			github.CreateWorkflowDispatchEventRequest{
 				Ref: event.GetPullRequest().GetHead().GetRef(),
@@ -292,8 +285,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		return err
 	}
 
-	// make sure we don't delete default or kube-system by checking for prefix, for now
-	if strings.Contains(depl.Namespace, "pr-") {
+	// make sure we do not delete any kubernetes "system" namespaces
+	if !isSystemNamespace(depl.Namespace) {
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
@@ -317,10 +310,7 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		&deploymentStatusRequest,
 	)
 
-	depl.Status = types.DeploymentStatusInactive
-
-	// update the deployment to mark it inactive
-	_, err = c.Repo().Environment().UpdateDeployment(depl)
+	_, err = c.Repo().Environment().DeleteDeployment(depl)
 
 	if err != nil {
 		return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error updating deployment: %w",
@@ -330,6 +320,14 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	return nil
 }
 
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

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

@@ -303,6 +303,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -331,6 +332,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -359,6 +361,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -388,6 +391,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -417,6 +421,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -446,6 +451,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -475,6 +481,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -504,6 +511,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -533,6 +541,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -563,6 +572,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -579,6 +589,36 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/settings ->
+		// environment.NewUpdateEnvironmentSettingsHandler
+		updateEnvironmentSettingsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/settings",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateEnvironmentSettingsHandler := environment.NewUpdateEnvironmentSettingsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: updateEnvironmentSettingsEndpoint,
+			Handler:  updateEnvironmentSettingsHandler,
+			Router:   r,
+		})
+
 	}
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler

+ 9 - 0
api/server/router/git_installation.go

@@ -135,6 +135,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -171,6 +172,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -207,6 +209,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -243,6 +246,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -279,6 +283,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -315,6 +320,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -351,6 +357,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -387,6 +394,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -423,6 +431,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)

+ 5 - 0
api/server/router/router.go

@@ -232,6 +232,9 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// gitlab integration middleware to handle gitlab integrations for a specific project
 	gitlabIntFactory := authz.NewGitlabIntegrationScopedFactory(config)
 
+	// preview environment middleware to handle previw environments for a specific project-cluster pair
+	previewEnvFactory := authz.NewPreviewEnvironmentScopedFactory(config)
+
 	for _, route := range routes {
 		atomicGroup := route.Router.Group(nil)
 
@@ -271,6 +274,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(stackFactory.Middleware)
 			case types.GitlabIntegrationScope:
 				atomicGroup.Use(gitlabIntFactory.Middleware)
+			case types.PreviewEnvironmentScope:
+				atomicGroup.Use(previewEnvFactory.Middleware)
 			}
 		}
 

+ 8 - 0
api/types/cluster.go

@@ -35,6 +35,9 @@ type Cluster struct {
 
 	// (optional) The aws cluster id, if available
 	AWSClusterID string `json:"aws_cluster_id,omitempty"`
+
+	// Whether preview environments is enabled on this cluster
+	PreviewEnvsEnabled bool `json:"preview_envs_enabled"`
 }
 
 type ClusterCandidate struct {
@@ -224,6 +227,9 @@ type CreateNamespaceRequest struct {
 	// the name of the namespace to create
 	// example: sampleNS
 	Name string `json:"name" form:"required"`
+
+	// annotations for the kubernetes namespace, if any
+	Annotations map[string]string `json:"annotations,omitempty"`
 }
 
 type GetTemporaryKubeconfigResponse struct {
@@ -270,6 +276,8 @@ type UpdateClusterRequest struct {
 	AWSClusterID string `json:"aws_cluster_id"`
 
 	AgentIntegrationEnabled *bool `json:"agent_integration_enabled"`
+
+	PreviewEnvsEnabled *bool `json:"preview_envs_enabled"`
 }
 
 type ListClusterResponse []*Cluster

+ 47 - 19
api/types/environment.go

@@ -3,23 +3,28 @@ package types
 import "time"
 
 type Environment struct {
-	ID                uint   `json:"id"`
-	ProjectID         uint   `json:"project_id"`
-	ClusterID         uint   `json:"cluster_id"`
-	GitInstallationID uint   `json:"git_installation_id"`
-	GitRepoOwner      string `json:"git_repo_owner"`
-	GitRepoName       string `json:"git_repo_name"`
-
-	Name                 string `json:"name"`
-	Mode                 string `json:"mode"`
-	DeploymentCount      uint   `json:"deployment_count"`
-	LastDeploymentStatus string `json:"last_deployment_status"`
-	NewCommentsDisabled  bool   `json:"new_comments_disabled"`
+	ID                uint     `json:"id"`
+	ProjectID         uint     `json:"project_id"`
+	ClusterID         uint     `json:"cluster_id"`
+	GitInstallationID uint     `json:"git_installation_id"`
+	GitRepoOwner      string   `json:"git_repo_owner"`
+	GitRepoName       string   `json:"git_repo_name"`
+	GitRepoBranches   []string `json:"git_repo_branches"`
+
+	Name                 string            `json:"name"`
+	Mode                 string            `json:"mode"`
+	DeploymentCount      uint              `json:"deployment_count"`
+	LastDeploymentStatus string            `json:"last_deployment_status"`
+	NewCommentsDisabled  bool              `json:"new_comments_disabled"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations,omitempty"`
 }
 
 type CreateEnvironmentRequest struct {
-	Name string `json:"name" form:"required"`
-	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
+	Name                 string            `json:"name" form:"required"`
+	Mode                 string            `json:"mode" form:"oneof=auto manual" default:"manual"`
+	DisableNewComments   bool              `json:"disable_new_comments"`
+	GitRepoBranches      []string          `json:"git_repo_branches"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
 }
 
 type GitHubMetadata struct {
@@ -76,15 +81,21 @@ type SuccessfullyDeployedResource struct {
 }
 
 type FinalizeDeploymentRequest struct {
-	Namespace           string                          `json:"namespace" form:"required"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Subdomain           string                          `json:"subdomain"`
+	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type FinalizeDeploymentWithErrorsRequest struct {
-	Namespace           string                          `json:"namespace" form:"required"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Errors              map[string]string               `json:"errors" form:"required"`
+	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type UpdateDeploymentRequest struct {
@@ -92,7 +103,10 @@ type UpdateDeploymentRequest struct {
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	CommitSHA    string `json:"commit_sha" form:"required"`
-	Namespace    string `json:"namespace" form:"required"`
+	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type ListDeploymentRequest struct {
@@ -104,7 +118,10 @@ type UpdateDeploymentStatusRequest struct {
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace    string `json:"namespace" form:"required"`
+	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type DeleteDeploymentRequest struct {
@@ -112,7 +129,11 @@ type DeleteDeploymentRequest struct {
 }
 
 type GetDeploymentRequest struct {
-	Namespace string `schema:"namespace" form:"required"`
+	DeploymentID uint `schema:"id"`
+	PRNumber     uint `schema:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `schema:"namespace"`
 }
 
 type PullRequest struct {
@@ -137,3 +158,10 @@ type ValidatePorterYAMLRequest struct {
 type ValidatePorterYAMLResponse struct {
 	Errors []string `json:"errors"`
 }
+
+type UpdateEnvironmentSettingsRequest struct {
+	Mode                 string            `json:"mode" form:"oneof=auto manual"`
+	DisableNewComments   bool              `json:"disable_new_comments"`
+	GitRepoBranches      []string          `json:"git_repo_branches"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
+}

+ 19 - 15
api/types/policy.go

@@ -5,20 +5,21 @@ import "time"
 type PermissionScope string
 
 const (
-	UserScope              PermissionScope = "user"
-	ProjectScope           PermissionScope = "project"
-	ClusterScope           PermissionScope = "cluster"
-	RegistryScope          PermissionScope = "registry"
-	InviteScope            PermissionScope = "invite"
-	HelmRepoScope          PermissionScope = "helm_repo"
-	InfraScope             PermissionScope = "infra"
-	OperationScope         PermissionScope = "operation"
-	GitInstallationScope   PermissionScope = "git_installation"
-	NamespaceScope         PermissionScope = "namespace"
-	SettingsScope          PermissionScope = "settings"
-	ReleaseScope           PermissionScope = "release"
-	StackScope             PermissionScope = "stack"
-	GitlabIntegrationScope PermissionScope = "gitlab_integration"
+	UserScope               PermissionScope = "user"
+	ProjectScope            PermissionScope = "project"
+	ClusterScope            PermissionScope = "cluster"
+	RegistryScope           PermissionScope = "registry"
+	InviteScope             PermissionScope = "invite"
+	HelmRepoScope           PermissionScope = "helm_repo"
+	InfraScope              PermissionScope = "infra"
+	OperationScope          PermissionScope = "operation"
+	GitInstallationScope    PermissionScope = "git_installation"
+	NamespaceScope          PermissionScope = "namespace"
+	SettingsScope           PermissionScope = "settings"
+	ReleaseScope            PermissionScope = "release"
+	StackScope              PermissionScope = "stack"
+	GitlabIntegrationScope  PermissionScope = "gitlab_integration"
+	PreviewEnvironmentScope PermissionScope = "preview_environment"
 )
 
 type NameOrUInt struct {
@@ -35,7 +36,9 @@ type PolicyDocument struct {
 
 type ScopeTree map[PermissionScope]ScopeTree
 
-/* ScopeHeirarchy describes the tree of scopes, i.e. Cluster, Registry, and Settings
+/*
+	ScopeHeirarchy describes the tree of scopes, i.e. Cluster, Registry, and Settings
+
 are children of Project, Namespace is a child of Cluster, etc.
 */
 var ScopeHeirarchy = ScopeTree{
@@ -45,6 +48,7 @@ var ScopeHeirarchy = ScopeTree{
 				StackScope:   {},
 				ReleaseScope: {},
 			},
+			PreviewEnvironmentScope: {},
 		},
 		RegistryScope:        {},
 		HelmRepoScope:        {},

+ 84 - 22
cli/cmd/apply.go

@@ -23,7 +23,7 @@ import (
 	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
-	"github.com/porter-dev/switchboard/pkg/models"
+	switchboardModels "github.com/porter-dev/switchboard/pkg/models"
 	"github.com/porter-dev/switchboard/pkg/parser"
 	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
 	switchboardWorker "github.com/porter-dev/switchboard/pkg/worker"
@@ -67,6 +67,10 @@ applying a configuration:
 		err := checkLoginAndRun(args, apply)
 
 		if err != nil {
+			if strings.Contains(err.Error(), "Forbidden") {
+				color.New(color.FgRed).Fprintf(os.Stderr, "You may have to update your GitHub secret token")
+			}
+
 			os.Exit(1)
 		}
 	},
@@ -228,7 +232,7 @@ type DeployDriver struct {
 	logger      *zerolog.Logger
 }
 
-func NewDeployDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+func NewDeployDriver(resource *switchboardModels.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
 	driver := &DeployDriver{
 		lookupTable: opts.DriverLookupTable,
 		logger:      opts.Logger,
@@ -254,11 +258,11 @@ func NewDeployDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 	return driver, nil
 }
 
-func (d *DeployDriver) ShouldApply(_ *models.Resource) bool {
+func (d *DeployDriver) ShouldApply(_ *switchboardModels.Resource) bool {
 	return true
 }
 
-func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboardModels.Resource, error) {
 	client := config.GetAPIClient()
 
 	_, err := client.GetRelease(
@@ -283,7 +287,7 @@ func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error
 }
 
 // Simple apply for addons
-func (d *DeployDriver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyAddon(resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
 	addonConfig, err := d.getAddonConfig(resource)
 
 	if err != nil {
@@ -340,7 +344,7 @@ func (d *DeployDriver) applyAddon(resource *models.Resource, client *api.Client,
 	return resource, nil
 }
 
-func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyApplication(resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
 	if resource == nil {
 		return nil, fmt.Errorf("nil resource")
 	}
@@ -462,7 +466,7 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 	return resource, err
 }
 
-func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) createApplication(resource *switchboardModels.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*switchboardModels.Resource, error) {
 	// create new release
 	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
 
@@ -548,7 +552,7 @@ func (d *DeployDriver) createApplication(resource *models.Resource, client *api.
 	return resource, handleSubdomainCreate(subdomain, err)
 }
 
-func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) updateApplication(resource *switchboardModels.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*switchboardModels.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 	if len(appConf.Build.Env) > 0 {
@@ -614,7 +618,7 @@ func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.
 	return resource, nil
 }
 
-func (d *DeployDriver) assignOutput(resource *models.Resource, client *api.Client) error {
+func (d *DeployDriver) assignOutput(resource *switchboardModels.Resource, client *api.Client) error {
 	release, err := client.GetRelease(
 		context.Background(),
 		d.target.Project,
@@ -636,7 +640,7 @@ func (d *DeployDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*previewInt.ApplicationConfig, error) {
+func (d *DeployDriver) getApplicationConfig(resource *switchboardModels.Resource) (*previewInt.ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -663,7 +667,7 @@ func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*preview
 	return appConf, nil
 }
 
-func (d *DeployDriver) getAddonConfig(resource *models.Resource) (map[string]interface{}, error) {
+func (d *DeployDriver) getAddonConfig(resource *switchboardModels.Resource) (map[string]interface{}, error) {
 	return drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -751,6 +755,10 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 }
 
 func (t *DeploymentHook) PreApply() error {
+	if isSystemNamespace(t.namespace) {
+		color.New(color.FgYellow).Printf("attempting to deploy to system namespace '%s'\n", t.namespace)
+	}
+
 	envList, err := t.client.ListEnvironments(
 		context.Background(), t.projectID, t.clusterID,
 	)
@@ -760,12 +768,14 @@ func (t *DeploymentHook) PreApply() error {
 	}
 
 	envs := *envList
+	var deplEnv *types.Environment
 
 	for _, env := range envs {
 		if strings.EqualFold(env.GitRepoOwner, t.repoOwner) &&
 			strings.EqualFold(env.GitRepoName, t.repoName) &&
 			env.GitInstallationID == t.gitInstallationID {
 			t.envID = env.ID
+			deplEnv = env
 			break
 		}
 	}
@@ -774,12 +784,54 @@ func (t *DeploymentHook) PreApply() error {
 		return fmt.Errorf("could not find environment for deployment")
 	}
 
+	nsList, err := t.client.GetK8sNamespaces(
+		context.Background(), t.projectID, t.clusterID,
+	)
+
+	if err != nil {
+		return fmt.Errorf("error fetching namespaces: %w", err)
+	}
+
+	found := false
+
+	for _, ns := range *nsList {
+		if ns.Name == t.namespace {
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		if isSystemNamespace(t.namespace) {
+			return fmt.Errorf("attempting to deploy to system namespace '%s' which does not exist, please create it "+
+				"to continue", t.namespace)
+		}
+
+		createNS := &types.CreateNamespaceRequest{
+			Name: t.namespace,
+		}
+
+		if len(deplEnv.NamespaceAnnotations) > 0 {
+			createNS.Annotations = deplEnv.NamespaceAnnotations
+		}
+
+		// create the new namespace
+		_, err := t.client.CreateNewK8sNamespace(context.Background(), t.projectID, t.clusterID, createNS)
+
+		if err != nil && !strings.Contains(err.Error(), "namespace already exists") {
+			// ignore the error if the namespace already exists
+			//
+			// this might happen if someone creates the namespace in between this operation
+			return fmt.Errorf("error creating namespace: %w", err)
+		}
+	}
+
 	// attempt to read the deployment -- if it doesn't exist, create it
 	_, err = t.client.GetDeployment(
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
@@ -812,6 +864,7 @@ func (t *DeploymentHook) PreApply() error {
 			t.repoOwner, t.repoName,
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
+				PRNumber:  t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 					ActionID: t.actionID,
 				},
@@ -900,7 +953,7 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	}
 
 	req := &types.FinalizeDeploymentRequest{
-		Namespace: t.namespace,
+		PRNumber:  t.prID,
 		Subdomain: strings.Join(subdomains, ", "),
 	}
 
@@ -926,23 +979,24 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	return err
 }
 
-func (t *DeploymentHook) OnError(err error) {
+func (t *DeploymentHook) OnError(error) {
 	// if the deployment exists, throw an error for that deployment
-	_, getDeplErr := t.client.GetDeployment(
+	_, err := t.client.GetDeployment(
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
-	if getDeplErr == nil {
-		_, err = t.client.UpdateDeploymentStatus(
+	if err == nil {
+		// FIXME: try to use the error with a custom logger
+		t.client.UpdateDeploymentStatus(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
 			t.repoOwner, t.repoName,
 			&types.UpdateDeploymentStatusRequest{
-				Namespace: t.namespace,
+				PRNumber: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 					ActionID: t.actionID,
 				},
@@ -959,14 +1013,14 @@ func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
 	if getDeplErr == nil {
 		req := &types.FinalizeDeploymentWithErrorsRequest{
-			Namespace: t.namespace,
-			Errors:    make(map[string]string),
+			PRNumber: t.prID,
+			Errors:   make(map[string]string),
 		}
 
 		for _, res := range t.resourceGroup.Resources {
@@ -1107,3 +1161,11 @@ func getReleaseType(res *switchboardTypes.Resource) string {
 
 	return ""
 }
+
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}

+ 176 - 2
cli/cmd/deploy.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
 	"time"
 
@@ -18,6 +19,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/util/homedir"
 )
 
@@ -224,7 +226,7 @@ var updateEnvGroupCmd = &cobra.Command{
 	Aliases: []string{"eg", "envgroup", "env-groups", "envgroups"},
 	Short:   "Updates an environment group's variables, specified by the --name flag.",
 	Run: func(cmd *cobra.Command, args []string) {
-		color.New(color.FgRed).Println("need to specify an operation to continue")
+		color.New(color.FgRed).Fprintln(os.Stderr, "need to specify an operation to continue")
 	},
 }
 
@@ -268,6 +270,7 @@ var version uint
 var varType string
 var normalEnvGroupVars []string
 var secretEnvGroupVars []string
+var waitForSuccessfulDeploy bool
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -372,6 +375,13 @@ func init() {
 
 	updateCmd.PersistentFlags().MarkDeprecated("force-push", "--force-push is now deprecated")
 
+	updateCmd.PersistentFlags().BoolVar(
+		&waitForSuccessfulDeploy,
+		"wait",
+		false,
+		"set this to wait and be notified when a deployment is successful, otherwise time out",
+	)
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -480,6 +490,14 @@ func updateFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		return err
 	}
 
+	if waitForSuccessfulDeploy {
+		err := checkDeploymentStatus(client)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -588,7 +606,21 @@ func updateUpgrade(_ *types.GetAuthenticatedUserResponse, client *api.Client, ar
 		return err
 	}
 
-	return updateUpgradeWithAgent(updateAgent)
+	err = updateUpgradeWithAgent(updateAgent)
+
+	if err != nil {
+		return err
+	}
+
+	if waitForSuccessfulDeploy {
+		err := checkDeploymentStatus(client)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
 func updateSetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -1029,3 +1061,145 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 
 	return nil
 }
+
+func checkDeploymentStatus(client *api.Client) error {
+	color.New(color.FgBlue).Println("waiting for deployment to be ready, this may take a few minutes and will time out if it takes longer than 30 minutes")
+
+	sharedConf := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err := sharedConf.setSharedConfig()
+
+	if err != nil {
+		return fmt.Errorf("could not retrieve kubernetes credentials: %w", err)
+	}
+
+	prevRefresh := time.Now()
+	timeWait := prevRefresh.Add(30 * time.Minute)
+	success := false
+
+	depls, err := sharedConf.Clientset.AppsV1().Deployments(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", app),
+		},
+	)
+
+	if err != nil {
+		return fmt.Errorf("could not get deployments for app %s: %w", app, err)
+	}
+
+	if len(depls.Items) == 0 {
+		return fmt.Errorf("could not find any deployments for app %s", app)
+	}
+
+	sort.Slice(depls.Items, func(i, j int) bool {
+		return depls.Items[i].CreationTimestamp.After(depls.Items[j].CreationTimestamp.Time)
+	})
+
+	depl := depls.Items[0]
+
+	// determine if the deployment has an appropriate number of ready replicas
+	minAvailable := *(depl.Spec.Replicas) - getMaxUnavailable(depl)
+
+	var revision string
+
+	for k, v := range depl.Spec.Template.ObjectMeta.Annotations {
+		if k == "helm.sh/revision" {
+			revision = v
+			break
+		}
+	}
+
+	if revision == "" {
+		return fmt.Errorf("could not find revision for deployment")
+	}
+
+	pods, err := sharedConf.Clientset.CoreV1().Pods(namespace).List(
+		context.Background(), metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", app),
+		},
+	)
+
+	if err != nil {
+		return fmt.Errorf("error fetching pods for app %s: %w", app, err)
+	}
+
+	if len(pods.Items) == 0 {
+		return fmt.Errorf("could not find any pods for app %s", app)
+	}
+
+	var rsName string
+
+	for _, pod := range pods.Items {
+		if pod.ObjectMeta.Annotations["helm.sh/revision"] == revision {
+			for _, ref := range pod.OwnerReferences {
+				if ref.Kind == "ReplicaSet" {
+					rs, err := sharedConf.Clientset.AppsV1().ReplicaSets(namespace).Get(
+						context.Background(),
+						ref.Name,
+						metav1.GetOptions{},
+					)
+
+					if err != nil {
+						return fmt.Errorf("error fetching new replicaset: %w", err)
+					}
+
+					rsName = rs.Name
+
+					break
+				}
+			}
+
+			if rsName != "" {
+				break
+			}
+		}
+	}
+
+	if rsName == "" {
+		return fmt.Errorf("could not find replicaset for app %s", app)
+	}
+
+	for time.Now().Before(timeWait) {
+		// refresh the client every 10 minutes
+		if time.Now().After(prevRefresh.Add(10 * time.Minute)) {
+			err = sharedConf.setSharedConfig()
+
+			if err != nil {
+				return fmt.Errorf("could not retrieve kube credentials: %s", err.Error())
+			}
+
+			prevRefresh = time.Now()
+		}
+
+		rs, err := sharedConf.Clientset.AppsV1().ReplicaSets(namespace).Get(
+			context.Background(),
+			rsName,
+			metav1.GetOptions{},
+		)
+
+		if err != nil {
+			return fmt.Errorf("error fetching new replicaset: %w", err)
+		}
+
+		if minAvailable <= rs.Status.ReadyReplicas {
+			success = true
+		}
+
+		if success {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+	}
+
+	if success {
+		color.New(color.FgGreen).Printf("%s has been successfully deployed on the cluster\n", app)
+	} else {
+		return fmt.Errorf("timed out waiting for deployment to be ready, please check the Porter dashboard for more information")
+	}
+
+	return nil
+}

+ 42 - 0
cmd/migrate/enable_cluster_preview_envs/enable.go

@@ -0,0 +1,42 @@
+package enable_cluster_preview_envs
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	_gorm "gorm.io/gorm"
+)
+
+func EnableClusterPreviewEnvs(db *_gorm.DB, logger *lr.Logger) error {
+	logger.Info().Msg("starting to enable preview envs for existing clusters whose parent projects have preview envs enabled")
+
+	var clusters []*models.Cluster
+
+	if err := db.Find(&clusters).Error; err != nil {
+		logger.Error().Msgf("failed to get clusters: %v", err)
+		return err
+	}
+
+	for _, c := range clusters {
+		project := &models.Project{}
+
+		if err := db.Model(project).Where("id = ?", c.ProjectID).First(project).Error; err != nil {
+			logger.Error().Msgf("failed to get project ID %d for cluster ID %d: %v", c.ProjectID, c.ID, err)
+			continue
+		}
+
+		if project.PreviewEnvsEnabled {
+			c.PreviewEnvsEnabled = true
+
+			if err := db.Save(c).Error; err != nil {
+				logger.Error().Msgf("failed to update cluster ID %d: %v", c.ID, err)
+				return err
+			}
+
+			logger.Info().Msgf("enabled preview envs for cluster ID %d", c.ID)
+		}
+	}
+
+	logger.Info().Msg("cluster preview envs migration completed")
+
+	return nil
+}

+ 73 - 0
cmd/migrate/enable_cluster_preview_envs/enable_test.go

@@ -0,0 +1,73 @@
+package enable_cluster_preview_envs
+
+import (
+	"testing"
+
+	lr "github.com/porter-dev/porter/pkg/logger"
+)
+
+func TestEnableForProjectEnabled(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_cluster_preview_envs_enabled.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	initProjectPreviewEnabled(tester, t)
+	initCluster(tester, t)
+
+	err := EnableClusterPreviewEnvs(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	cluster, err := tester.repo.Cluster().ReadCluster(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	if !cluster.PreviewEnvsEnabled {
+		t.Fatalf("expected preview envs to be enabled, got disabled")
+	}
+}
+
+func TestEnableForProjectDisabled(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_cluster_preview_envs_disabled.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	initProjectPreviewDisabled(tester, t)
+	initCluster(tester, t)
+
+	err := EnableClusterPreviewEnvs(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	cluster, err := tester.repo.Cluster().ReadCluster(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	if cluster.PreviewEnvsEnabled {
+		t.Fatalf("expected preview envs to be disabled, got enabled")
+	}
+}

+ 182 - 0
cmd/migrate/enable_cluster_preview_envs/helpers_test.go

@@ -0,0 +1,182 @@
+package enable_cluster_preview_envs
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+	_gorm "gorm.io/gorm"
+)
+
+type tester struct {
+	Key *[32]byte
+	DB  *_gorm.DB
+
+	repo       repository.Repository
+	dbFileName string
+	key        *[32]byte
+
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initClusters []*models.Cluster
+	initKIs      []*ints.KubeIntegration
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&env.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.User{},
+		&models.Cluster{},
+		&ints.KubeIntegration{},
+		&ints.ClusterTokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+	tester.Key = &key
+	tester.DB = db
+
+	tester.repo = gorm.NewRepository(db, &key, nil)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User().CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	cluster, err := tester.repo.Cluster().CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initProjectPreviewEnabled(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name:               "project-test",
+		PreviewEnvsEnabled: true,
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initProjectPreviewDisabled(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:             ints.KubeLocal,
+		ProjectID:             tester.initProjects[0].ID,
+		UserID:                tester.initUsers[0].ID,
+		Kubeconfig:            []byte("current-context: testing\n"),
+		ClientCertificateData: []byte("clientcertdata"),
+		ClientKeyData:         []byte("clientkeydata"),
+		Token:                 []byte("token"),
+		Username:              []byte("username"),
+		Password:              []byte("password"),
+	}
+
+	ki, err := tester.repo.KubeIntegration().CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}

+ 64 - 0
cmd/migrate/main.go

@@ -1,17 +1,21 @@
 package main
 
 import (
+	"errors"
 	"log"
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
+	"github.com/porter-dev/porter/cmd/migrate/startup_migrations"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	lr "github.com/porter-dev/porter/pkg/logger"
 
 	"github.com/joeshaw/envdecode"
+	pgorm "gorm.io/gorm"
 )
 
 func main() {
@@ -48,6 +52,66 @@ func main() {
 		return
 	}
 
+	tx := db.Begin()
+
+	switch tx.Dialector.Name() {
+	case "sqlite":
+		if err := tx.Raw("PRAGMA schema.locking_mode = EXCLUSIVE").Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("error acquiring lock on db_migrations")
+			return
+		}
+	case "postgres":
+		if err := tx.Raw("LOCK TABLE db_migrations IN SHARE ROW EXCLUSIVE MODE").Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("error acquiring lock on db_migrations")
+			return
+		}
+	}
+
+	dbMigration := &models.DbMigration{}
+
+	if err := tx.Model(&models.DbMigration{}).First(dbMigration).Error; err != nil {
+		if errors.Is(err, pgorm.ErrRecordNotFound) {
+			dbMigration.Version = 0
+		} else {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("failed to check for db migration version")
+			return
+		}
+	}
+
+	latestMigrationVersion := startup_migrations.LatestMigrationVersion
+
+	if dbMigration.Version < latestMigrationVersion {
+		for ver, fn := range startup_migrations.StartupMigrations {
+			if ver > dbMigration.Version {
+				err := fn(tx, logger)
+
+				if err != nil {
+					tx.Rollback()
+
+					logger.Fatal().Err(err).Msg("failed to run startup migration script")
+					return
+				}
+			}
+		}
+
+		dbMigration.Version = latestMigrationVersion
+
+		if err := tx.Save(dbMigration).Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("failed to update migration version to latest")
+			return
+		}
+	}
+
+	tx.Commit()
+
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {
 		oldKey := [32]byte{}
 		newKey := [32]byte{}

+ 11 - 0
cmd/migrate/startup_migrations/doc.go

@@ -0,0 +1,11 @@
+/*
+                   === Mandatory Migrations at Startup ===
+
+   This package contains the migrations that are run at startup. Such migrations are
+   mandatory by nature, especially for self-hosted customers.
+
+   A globally accessible map structure shall be maintained and updated with the respective
+   migration scripts (functions) attached with the migration version they should be run with.
+*/
+
+package startup_migrations

+ 18 - 0
cmd/migrate/startup_migrations/global_map.go

@@ -0,0 +1,18 @@
+package startup_migrations
+
+import (
+	"github.com/porter-dev/porter/cmd/migrate/enable_cluster_preview_envs"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	"gorm.io/gorm"
+)
+
+// this should be incremented with every new startup migration script
+const LatestMigrationVersion uint = 1
+
+type migrationFunc func(db *gorm.DB, logger *lr.Logger) error
+
+var StartupMigrations = make(map[uint]migrationFunc)
+
+func init() {
+	StartupMigrations[1] = enable_cluster_preview_envs.EnableClusterPreviewEnvs
+}

+ 68 - 0
dashboard/package-lock.json

@@ -1526,6 +1526,38 @@
       "integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==",
       "dev": true
     },
+    "@tanstack/match-sorter-utils": {
+      "version": "8.5.14",
+      "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.5.14.tgz",
+      "integrity": "sha512-lVNhzTcOJ2bZ4IU+PeCPQ36vowBHvviJb2ZfdRFX5uhy7G0jM8N34zAMbmS5ZmVH8D2B7oU82OWo0e/5ZFzQrw==",
+      "requires": {
+        "remove-accents": "0.4.2"
+      }
+    },
+    "@tanstack/query-core": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.13.0.tgz",
+      "integrity": "sha512-PzmLQcEgC4rl2OzkiPHYPC9O79DFcMGaKsOzDEP+U4PJ+tbkcEP+Z+FQDlfvX8mCwYC7UNH7hXrQ5EdkGlJjVg=="
+    },
+    "@tanstack/react-query": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.13.0.tgz",
+      "integrity": "sha512-dI/5hJ/pGQ74P5hxBLC9h6K0/Cap2T3k0ZjjjFLBCNnohDYgl7LNmMopzrRzBHk2mMjf2hgXHIzcKNG8GOZ5hg==",
+      "requires": {
+        "@tanstack/query-core": "4.13.0",
+        "use-sync-external-store": "^1.2.0"
+      }
+    },
+    "@tanstack/react-query-devtools": {
+      "version": "4.13.5",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.13.5.tgz",
+      "integrity": "sha512-ItXysFt7a2WJcodeoZ52/NFoMeWUgwzSaKL3NXhHvdyDOFsX945/AN/+Q9WgXRDG+YgqDJm6f/ozYIfFaORoZQ==",
+      "requires": {
+        "@tanstack/match-sorter-utils": "^8.1.1",
+        "superjson": "^1.10.0",
+        "use-sync-external-store": "^1.2.0"
+      }
+    },
     "@testing-library/dom": {
       "version": "6.16.0",
       "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.16.0.tgz",
@@ -3884,6 +3916,14 @@
       "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=",
       "dev": true
     },
+    "copy-anything": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz",
+      "integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==",
+      "requires": {
+        "is-what": "^4.1.6"
+      }
+    },
     "copy-concurrently": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -5433,6 +5473,11 @@
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
       "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
     },
+    "fuse.js": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
+      "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
+    },
     "gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6313,6 +6358,11 @@
         "call-bind": "^1.0.0"
       }
     },
+    "is-what": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz",
+      "integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ=="
+    },
     "is-windows": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
@@ -8400,6 +8450,11 @@
       "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
       "dev": true
     },
+    "remove-accents": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+      "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
+    },
     "remove-trailing-separator": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -9557,6 +9612,14 @@
         "supports-color": "^5.5.0"
       }
     },
+    "superjson": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.11.0.tgz",
+      "integrity": "sha512-6PfAg1FKhqkwWvPb2uXhH4MkMttdc17eJ91+Aoz4s1XUEDZFmLfFx/xVA3wgkPxAGy5dpozgGdK6V/n20Wj9yg==",
+      "requires": {
+        "copy-anything": "^3.0.2"
+      }
+    },
     "supports-color": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -10105,6 +10168,11 @@
       "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
       "dev": true
     },
+    "use-sync-external-store": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
+    },
     "util": {
       "version": "0.11.1",
       "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

+ 5 - 2
dashboard/package.json

@@ -9,6 +9,8 @@
     "@material-ui/lab": "^4.0.0-alpha.61",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
+    "@tanstack/react-query": "^4.13.0",
+    "@tanstack/react-query-devtools": "^4.13.5",
     "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
@@ -35,6 +37,7 @@
     "d3-time-format": "^3.0.0",
     "dayjs": "^1.11.5",
     "dotenv": "^8.2.0",
+    "fuse.js": "^6.6.2",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
@@ -63,9 +66,9 @@
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "npx webpack-dev-server",
+    "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js",
     "build": "NODE_ENV=\"production\" webpack",
-    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" webpack"
+    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" ./node_modules/webpack/bin/webpack.js"
   },
   "devDependencies": {
     "@babel/core": "^7.15.0",

+ 13 - 8
dashboard/src/App.tsx

@@ -2,20 +2,25 @@ import React, { Component } from "react";
 import { BrowserRouter } from "react-router-dom";
 import PorterErrorBoundary from "shared/error_handling/PorterErrorBoundary";
 import styled, { createGlobalStyle } from "styled-components";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
 import MainWrapper from "./main/MainWrapper";
 
+const queryClient = new QueryClient();
+
 export default class App extends Component {
   render() {
     return (
-      <StyledMain>
-        <GlobalStyle />
-        <PorterErrorBoundary errorBoundaryLocation="globalErrorBoundary">
-          <BrowserRouter>
-            <MainWrapper />
-          </BrowserRouter>
-        </PorterErrorBoundary>
-      </StyledMain>
+      <QueryClientProvider client={queryClient}>
+        <StyledMain>
+          <GlobalStyle />
+          <PorterErrorBoundary errorBoundaryLocation="globalErrorBoundary">
+            <BrowserRouter>
+              <MainWrapper />
+            </BrowserRouter>
+          </PorterErrorBoundary>
+        </StyledMain>
+      </QueryClientProvider>
     );
   }
 }

+ 7 - 1
dashboard/src/components/DocsHelper.tsx

@@ -39,7 +39,7 @@ const DocsHelper: React.FC<Props> = ({
       >
         <div>
           <HelperButton onClick={handleTooltipToggle}>
-            <i className="material-icons">help_outline</i>
+            <Icon className="material-icons">help_outline</Icon>
           </HelperButton>
           {open && (
             <Tooltip placement={placement}>
@@ -167,3 +167,9 @@ const DocsHelperContainer = styled.div<{ disableMargin: boolean }>`
   }}
   position: relative;
 `;
+
+const Icon = styled.i`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 34 - 0
dashboard/src/components/OldPlaceholder.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  height?: string;
+  minHeight?: string;
+  children: React.ReactNode;
+}
+
+const OldPlaceholder: React.FC<Props> = ({ height, minHeight, children }) => {
+  return (
+    <StyledPlaceholder height={height} minHeight={minHeight}>
+      {children}
+    </StyledPlaceholder>
+  );
+};
+
+export default OldPlaceholder;
+
+const StyledPlaceholder = styled.div<{
+  height: string;
+  minHeight: string;
+}>`
+  width: 100%;
+  height: ${(props) => props.height || "100px"};
+  minheight: ${(props) => props.minHeight || ""};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+  border-radius: 5px;
+  background: #ffffff11;
+`;

+ 426 - 0
dashboard/src/components/OldTable.tsx

@@ -0,0 +1,426 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
+import Loading from "components/Loading";
+import Selector from "./Selector";
+import loading from "assets/loading.gif";
+
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+  disableHover?: boolean;
+  enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
+};
+
+const MIN_PAGE_SIZE = 1;
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+  disableHover,
+  enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    page,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter,
+    usePagination
+  );
+
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length || MIN_PAGE_SIZE);
+    }
+  }, [data, enablePagination]);
+
+  const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!page.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {page.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              disableHover={disableHover}
+              {...row.getRowProps()}
+              enablePointer={!!onRowClick}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
+              {row.cells.map((cell) => {
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
+  :hover {
+    background: ${(props: StyledTrProps) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+  user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
+  position: sticky;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 21px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 7px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 18px;
+  }
+`;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 40 - 5
dashboard/src/components/Placeholder.tsx

@@ -5,30 +5,65 @@ interface Props {
   height?: string;
   minHeight?: string;
   children: React.ReactNode;
+  title?: string;
 }
 
-const Placeholder: React.FC<Props> = ({ height, minHeight, children }) => {
+const Placeholder: React.FC<Props> = ({ 
+  height, 
+  minHeight, 
+  children,
+  title,
+}) => {
   return (
     <StyledPlaceholder height={height} minHeight={minHeight}>
-      {children}
+      <Wrapper>
+        <Title>{title}</Title>
+        <Flex>{children}</Flex>
+      </Wrapper>
     </StyledPlaceholder>
   );
 };
 
 export default Placeholder;
 
+const Flex = styled.div`
+  display: flex;
+  margin-top: 10px;
+  align-items: center;
+`;
+
+const Wrapper = styled.div`
+  margin-bottom: 10px;
+`;
+
+const Title = styled.div`
+  font-size: 16px;
+  color: white;
+  font-weight: 500;
+`;
+
 const StyledPlaceholder = styled.div<{
   height: string;
   minHeight: string;
 }>`
   width: 100%;
   height: ${(props) => props.height || "100px"};
-  minheight: ${(props) => props.minHeight || ""};
+  min-height: ${(props) => props.minHeight || ""};
   display: flex;
   align-items: center;
+  color: #8D949E;
+  padding: 50px;
   justify-content: center;
   font-size: 13px;
-  color: #ffffff44;
   border-radius: 5px;
-  background: #ffffff11;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  padding-bottom: 60px;
+
+  > div {
+    > i {
+      font-size: 16px;
+      margin-right: 12px;
+    }
+  }
 `;

+ 1 - 1
dashboard/src/components/ProvisionerStatus.tsx

@@ -12,7 +12,7 @@ import {
   TFState,
 } from "shared/types";
 import api from "shared/api";
-import Placeholder from "./Placeholder";
+import Placeholder from "./OldPlaceholder";
 import Loading from "./Loading";
 import { Context } from "shared/Context";
 import { useWebsockets } from "shared/hooks/useWebsockets";

+ 30 - 11
dashboard/src/components/SearchSelector.tsx

@@ -1,22 +1,25 @@
 import _ from "lodash";
 import React, { useMemo, useState } from "react";
 import styled from "styled-components";
+import Loading from "./Loading";
 
-type Props = {
-  options: any[];
-  onSelect: (option: any) => void;
+type Props<T = any> = {
+  options: T[];
+  onSelect: (option: T) => void;
   label?: string;
   dropdownLabel?: string;
-  getOptionLabel?: (option: any) => string;
-  filterBy?: ((option: any) => string) | string;
+  getOptionLabel?: (option: T) => string;
+  filterBy?: ((option: T) => string) | string;
   noOptionsText?: string;
   dropdownMaxHeight?: string;
   renderAddButton?: any;
   className?: string;
-  renderOptionIcon?: (option: any) => React.ReactNode;
+  renderOptionIcon?: (option: T) => React.ReactNode;
+  placeholder?: string;
+  showLoading?: boolean;
 };
 
-const SearchSelector = ({
+function SearchSelector<O = any>({
   options,
   onSelect,
   label,
@@ -28,7 +31,9 @@ const SearchSelector = ({
   renderAddButton,
   className,
   renderOptionIcon,
-}: Props) => {
+  placeholder = "Find or add a tag...", // legacy value to not break existing code
+  showLoading = false,
+}: Props<O>) {
   const [isExpanded, setIsExpanded] = useState(false);
   const [filter, setFilter] = useState("");
 
@@ -57,9 +62,22 @@ const SearchSelector = ({
       );
     }
 
-    return options.filter((option) => option.includes(filter));
+    return options.filter((option) =>
+      typeof option === "string" ? option.includes(filter) : true
+    );
   }, [filter, options]);
 
+  if (showLoading) {
+    return (
+      <>
+        {label?.length ? <Label>{label}</Label> : null}
+        <InputWrapper className={className}>
+          <Loading />
+        </InputWrapper>
+      </>
+    );
+  }
+
   return (
     <>
       {label?.length ? <Label>{label}</Label> : null}
@@ -71,7 +89,7 @@ const SearchSelector = ({
       >
         <Input
           value={filter}
-          placeholder="Find or add a tag..."
+          placeholder={placeholder}
           onClick={(e) => {
             setIsExpanded(false);
             e.stopPropagation();
@@ -139,7 +157,7 @@ const SearchSelector = ({
       </InputWrapper>
     </>
   );
-};
+}
 
 export default SearchSelector;
 
@@ -152,6 +170,7 @@ const InputWrapper = styled.div`
   background: #ffffff11;
   position: relative;
   width: 100%;
+  min-height: 37px;
 `;
 
 const Input = styled.input`

+ 25 - 350
dashboard/src/components/Table.tsx

@@ -1,5 +1,5 @@
-import React, { useEffect } from "react";
-import styled from "styled-components";
+import Placeholder from "components/Placeholder";
+import React from "react";
 import {
   Column,
   Row,
@@ -7,96 +7,37 @@ import {
   usePagination,
   useTable,
 } from "react-table";
-import Loading from "components/Loading";
-import Selector from "./Selector";
-import loading from "assets/loading.gif";
-
-const GlobalFilter: React.FunctionComponent<any> = ({
-  setGlobalFilter,
-  onRefresh,
-  isRefreshing,
-}) => {
-  const [value, setValue] = React.useState("");
-  const onChange = (value: string) => {
-    setValue(value);
-    setGlobalFilter(value || undefined);
-  };
-
-  return (
-    <SearchRowWrapper>
-      <SearchRow>
-        <i className="material-icons">search</i>
-        <SearchInput
-          value={value}
-          onChange={(e: any) => {
-            onChange(e.target.value);
-          }}
-          placeholder="Search"
-        />
-      </SearchRow>
-      {typeof onRefresh === "function" && (
-        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
-          {isRefreshing ? (
-            <>
-              <img src={loading} alt="loading icon" />
-            </>
-          ) : (
-            <i className="material-icons">refresh</i>
-          )}
-        </RefreshButton>
-      )}
-    </SearchRowWrapper>
-  );
-};
+import {
+  StyledTd,
+  StyledTable,
+  StyledTHead,
+  StyledTh,
+  StyledTBody,
+} from "../main/home/cluster-dashboard/expanded-chart/events/styles";
 
 export type TableProps = {
   columns: Column<any>[];
   data: any[];
   onRowClick?: (row: Row) => void;
-  isLoading: boolean;
-  disableGlobalFilter?: boolean;
-  disableHover?: boolean;
-  enablePagination?: boolean;
-  hasError?: boolean;
-  errorMessage?: string;
-  onRefresh?: () => void;
-  isRefreshing?: boolean;
+  placeholder?: string;
 };
 
-const MIN_PAGE_SIZE = 1;
-
 const Table: React.FC<TableProps> = ({
   columns: columnsData,
   data,
   onRowClick,
-  isLoading,
-  disableGlobalFilter = false,
-  disableHover,
-  enablePagination,
-  hasError,
-  errorMessage = "An unexpected error occurred, please try again.",
-  onRefresh,
-  isRefreshing = false,
+  placeholder,
 }) => {
+  if (!data || data.length == 0) {
+    return <Placeholder>{placeholder}</Placeholder>;
+  }
+
   const {
+    rows,
     getTableProps,
     getTableBodyProps,
-    page,
-    setGlobalFilter,
     prepareRow,
     headerGroups,
-    visibleColumns,
-
-    // Pagination options
-    canPreviousPage,
-    canNextPage,
-    pageOptions,
-    pageCount,
-    gotoPage,
-    nextPage,
-    previousPage,
-    setPageSize,
-    state: { pageIndex, pageSize },
   } = useTable(
     {
       columns: columnsData,
@@ -106,57 +47,19 @@ const Table: React.FC<TableProps> = ({
     usePagination
   );
 
-  useEffect(() => {
-    if (!enablePagination) {
-      setPageSize(data.length || MIN_PAGE_SIZE);
-    }
-  }, [data, enablePagination]);
-
   const renderRows = () => {
-    if (hasError) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            {errorMessage}
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (isLoading) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} height="150px">
-            <Loading />
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (!page.length) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            No data available
-          </StyledTd>
-        </StyledTr>
-      );
-    }
     return (
       <>
-        {page.map((row) => {
+        {rows.map((row: any) => {
           prepareRow(row);
 
           return (
-            <StyledTr
-              disableHover={disableHover}
+            <tr
               {...row.getRowProps()}
-              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
-              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => {
+              {row.cells.map((cell: any) => {
                 return (
                   <StyledTd
                     {...cell.getCellProps()}
@@ -168,7 +71,7 @@ const Table: React.FC<TableProps> = ({
                   </StyledTd>
                 );
               })}
-            </StyledTr>
+            </tr>
           );
         })}
       </>
@@ -176,251 +79,23 @@ const Table: React.FC<TableProps> = ({
   };
 
   return (
-    <TableWrapper>
-      {!disableGlobalFilter && (
-        <GlobalFilter
-          setGlobalFilter={setGlobalFilter}
-          onRefresh={onRefresh}
-          isRefreshing={isRefreshing}
-        />
-      )}
+    <>
       <StyledTable {...getTableProps()}>
         <StyledTHead>
           {headerGroups.map((headerGroup) => (
-            <StyledTr
-              {...headerGroup.getHeaderGroupProps()}
-              disableHover={true}
-            >
+            <tr {...headerGroup.getHeaderGroupProps()}>
               {headerGroup.headers.map((column) => (
                 <StyledTh {...column.getHeaderProps()}>
                   {column.render("Header")}
                 </StyledTh>
               ))}
-            </StyledTr>
+            </tr>
           ))}
         </StyledTHead>
-        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
       </StyledTable>
-      {enablePagination && (
-        <FlexEnd style={{ marginTop: "15px" }}>
-          <PageCountWrapper>
-            Page size:
-            <Selector
-              activeValue={String(pageSize)}
-              options={[
-                {
-                  label: "10",
-                  value: "10",
-                },
-                {
-                  label: "20",
-                  value: "20",
-                },
-                {
-                  label: "50",
-                  value: "50",
-                },
-                {
-                  label: "100",
-                  value: "100",
-                },
-              ]}
-              setActiveValue={(val) => setPageSize(Number(val))}
-              width="70px"
-            ></Selector>
-          </PageCountWrapper>
-          <PaginationActionsWrapper>
-            <PaginationAction
-              disabled={!canPreviousPage}
-              onClick={previousPage}
-            >
-              {"<"}
-            </PaginationAction>
-            <PageCounter>
-              {pageIndex + 1} of {pageCount}
-            </PageCounter>
-            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
-              {">"}
-            </PaginationAction>
-          </PaginationActionsWrapper>
-        </FlexEnd>
-      )}
-    </TableWrapper>
+    </>
   );
 };
 
 export default Table;
-
-const TableWrapper = styled.div`
-  padding-bottom: 20px;
-`;
-
-const FlexEnd = styled.div`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  width: 100%;
-`;
-
-const PaginationActionsWrapper = styled.div``;
-
-const PageCountWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-width: 160px;
-  margin-right: 10px;
-`;
-
-const PaginationAction = styled.button`
-  border: none;
-  background: unset;
-  color: white;
-  padding: 10px;
-  cursor: pointer;
-  border-radius: 5px;
-  :hover {
-    background: #ffffff40;
-  }
-
-  :disabled {
-    color: #ffffff88;
-    cursor: unset;
-    :hover {
-      background: unset;
-    }
-  }
-`;
-
-const PageCounter = styled.span`
-  margin: 0 5px;
-`;
-
-type StyledTrProps = {
-  enablePointer?: boolean;
-  disableHover?: boolean;
-  selected?: boolean;
-};
-
-export const StyledTr = styled.tr`
-  line-height: 2.2em;
-  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
-  :hover {
-    background: ${(props: StyledTrProps) =>
-      props.disableHover ? "" : "#ffffff22"};
-  }
-  cursor: ${(props: StyledTrProps) =>
-    props.enablePointer ? "pointer" : "unset"};
-`;
-
-export const StyledTd = styled.td`
-  font-size: 13px;
-  color: #ffffff;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-  user-select: text;
-
-  ${(props: { align?: "center" | "left" }) => {
-    if (props.align) {
-      return `text-align:${props.align};`;
-    }
-  }}
-`;
-
-export const StyledTHead = styled.thead`
-  width: 100%;
-  border-top: 1px solid #aaaabb22;
-  border-bottom: 1px solid #aaaabb22;
-  position: sticky;
-`;
-
-export const StyledTh = styled.th`
-  text-align: left;
-  font-size: 13px;
-  font-weight: 500;
-  color: #aaaabb;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-`;
-
-export const StyledTable = styled.table`
-  width: 100%;
-  min-width: 500px;
-  border-collapse: collapse;
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  padding: 0;
-  height: 21px;
-`;
-
-const SearchRow = styled.div`
-  display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
-  align-items: center;
-  padding: 7px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  background: #ffffff11;
-
-  i {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 18px;
-  }
-`;
-
-const SearchRowWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-  margin-top: 0px;
-`;
-
-const RefreshButton = styled.button`
-  justify-self: flex-end;
-  border: 1px solid #ffffff00;
-  border-radius: 50%;
-  background: inherit;
-  color: #ffffff;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 35px;
-  height: 35px;
-
-  > i {
-    font-size: 20px;
-  }
-  > img {
-    width: 20px;
-    height: 20px;
-  }
-
-  :hover {
-    color: #ffffff88;
-    border-color: #ffffff88;
-  }
-`;

+ 16 - 2
dashboard/src/components/TitleSection.tsx

@@ -9,6 +9,7 @@ interface Props {
   className?: string;
   materialIconClass?: string;
   handleNavBack?: () => void;
+  onClick?: any;
 }
 
 const TitleSection: React.FC<Props> = ({
@@ -19,6 +20,7 @@ const TitleSection: React.FC<Props> = ({
   handleNavBack,
   className,
   materialIconClass,
+  onClick,
 }) => {
   return (
     <StyledTitleSection className={className}>
@@ -39,7 +41,12 @@ const TitleSection: React.FC<Props> = ({
           <Icon width={iconWidth} src={icon} />
         ))}
 
-      <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
+      <StyledTitle
+        capitalize={capitalize}
+        onClick={onClick}
+      >
+        {children}
+      </StyledTitle>
     </StyledTitleSection>
   );
 };
@@ -78,13 +85,20 @@ const MaterialIcon = styled.span<{ width: string }>`
   margin-right: 16px;
 `;
 
-const StyledTitle = styled.div<{ capitalize: boolean }>`
+const StyledTitle = styled.div<{ 
+  capitalize: boolean;
+  onClick?: any;
+}>`
   font-size: 21px;
   font-weight: 600;
   user-select: text;
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
   display: flex;
   align-items: center;
+  cursor: ${props => props.onClick ? "pointer" : ""};
+  :hover {
+    text-decoration: ${props => props.onClick ? "underline" : ""};
+  }
 
   > i {
     margin-left: 10px;

+ 16 - 4
dashboard/src/components/form-components/CheckboxRow.tsx

@@ -7,6 +7,9 @@ type PropsType = {
   toggle: () => void;
   isRequired?: boolean;
   disabled?: boolean;
+  wrapperStyles?: {
+    disableMargin?: boolean;
+  };
 };
 
 type StateType = {};
@@ -14,7 +17,9 @@ type StateType = {};
 export default class CheckboxRow extends Component<PropsType, StateType> {
   render() {
     return (
-      <StyledCheckboxRow>
+      <StyledCheckboxRow
+        disableMargin={this.props.wrapperStyles?.disableMargin}
+      >
         <CheckboxWrapper
           disabled={this.props.disabled}
           onClick={!this.props.disabled ? this.props.toggle : undefined}
@@ -65,9 +70,16 @@ const Checkbox = styled.div<{ checked: boolean }>`
   }
 `;
 
-const StyledCheckboxRow = styled.div`
+const StyledCheckboxRow = styled.div<{ disableMargin?: boolean }>`
   display: flex;
   align-items: center;
-  margin-bottom: 15px;
-  margin-top: 20px;
+  ${({ disableMargin }) => {
+    if (disableMargin) {
+      return "";
+    }
+    return `
+      margin-bottom: 15px;
+      margin-top: 20px;
+    `;
+  }}
 `;

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

@@ -436,7 +436,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
         )}
         {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
           <>
-            <Heading>Synced Environment Groups</Heading>
+            <Heading>Synced environment groups</Heading>
             <Br />
             {state.synced_env_groups?.map((envGroup: any) => {
               return (

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -6,11 +6,12 @@ import { Context } from "shared/Context";
 import TitleSection from "components/TitleSection";
 
 type PropsType = {
-  image: any;
-  title: string;
+  image?: any;
+  title: any;
   description?: string;
   materialIconClass?: string;
   disableLineBreak?: boolean;
+  capitalize?: boolean;
 };
 
 type StateType = {};
@@ -20,7 +21,7 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
     return (
       <>
         <TitleSection
-          capitalize={true}
+          capitalize={this.props.capitalize === undefined || this.props.capitalize}
           icon={this.props.image}
           materialIconClass={this.props.materialIconClass}
         >
@@ -88,7 +89,7 @@ const InfoLabel = styled.div`
 `;
 
 const InfoSection = styled.div`
-  margin-top: 20px;
+  margin-top: 15px;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
   margin-bottom: 35px;

+ 3 - 23
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -17,6 +17,7 @@ import Chart from "./Chart";
 import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import CronParser from "cron-parser";
+import Placeholder from "components/Placeholder";
 
 type Props = {
   currentCluster: ClusterType;
@@ -445,13 +446,13 @@ const ChartList: React.FunctionComponent<Props> = ({
       );
     } else if (isError) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
     } else if (filteredCharts?.length === 0) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">category</i> No
           {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
           filters.
@@ -486,27 +487,6 @@ const ChartList: React.FunctionComponent<Props> = ({
 
 export default ChartList;
 
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 370px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding-top: 100px;
 `;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -1,6 +1,6 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
 import { CellProps, Column, Row } from "react-table";
 import api from "shared/api";

+ 51 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -32,6 +32,10 @@ const ClusterSettings: React.FC = () => {
     currentCluster.agent_integration_enabled
   );
   const [agentLoading, setAgentLoading] = useState(false);
+  const [enablePreviewEnvs, setEnablePreviewEnvs] = useState(
+    currentCluster.preview_envs_enabled
+  );
+  const [previewEnvsLoading, setPreviewEnvsLoading] = useState(false);
 
   let rotateCredentials = () => {
     api
@@ -99,6 +103,29 @@ const ClusterSettings: React.FC = () => {
       });
   };
 
+  let updatePreviewEnvironmentsEnabled = () => {
+    setPreviewEnvsLoading(true);
+
+    api
+      .updateCluster(
+        "<token>",
+        {
+          preview_envs_enabled: enablePreviewEnvs,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setCurrentCluster(data);
+        setPreviewEnvsLoading(false);
+      })
+      .catch(() => {
+        setPreviewEnvsLoading(false);
+      });
+  };
+
   let helperText = (
     <Helper>
       Delete this cluster and underlying infrastructure. To ensure that
@@ -233,6 +260,28 @@ const ClusterSettings: React.FC = () => {
     enableAgentIntegration = <Loading />;
   }
 
+  let enablePreviewEnvironments = null;
+
+  if (currentProject.preview_envs_enabled) {
+    if (previewEnvsLoading) {
+      enablePreviewEnvironments = <Loading />;
+    } else {
+      enablePreviewEnvironments = (
+        <div>
+          <Heading>Enable Preview Environments</Heading>
+          <CheckboxRow
+            label={"Create preview environments on this cluster"}
+            toggle={() => setEnablePreviewEnvs(!enablePreviewEnvs)}
+            checked={enablePreviewEnvs}
+          />
+          <Button color="#616FEEcc" onClick={updatePreviewEnvironmentsEnabled}>
+            Save
+          </Button>
+        </div>
+      );
+    }
+  }
+
   if (capabilities.version == "production") {
     enableAgentIntegration = null;
   }
@@ -251,6 +300,8 @@ const ClusterSettings: React.FC = () => {
       <StyledSettingsSection>
         {enableAgentIntegration}
         <DarkMatter />
+        {enablePreviewEnvironments}
+        <DarkMatter />
         {keyRotationSection}
         <DarkMatter />
         {renameClusterSection}

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import settings from "assets/settings.svg";
 import TabSelector from "components/TabSelector";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,5 +1,5 @@
 import React, { useMemo } from "react";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -1,5 +1,5 @@
 import CopyToClipboard from "components/CopyToClipboard";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useRouteMatch } from "react-router";
 import { Link } from "react-router-dom";

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

@@ -129,9 +129,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         <>
           <DashboardHeader
             image={sliders}
-            title="Environment Groups"
+            title="Environment groups"
             description="Groups of environment variables for storing secrets and configuration."
             disableLineBreak
+            capitalize={false}
           />
           {this.renderBody()}
         </>

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -2,7 +2,7 @@ import { DeviconsNameList } from "assets/devicons-name-list";
 import Helper from "components/form-components/Helper";
 import SelectRow from "components/form-components/SelectRow";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
 import { differenceBy } from "lodash";
 import React, {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -141,7 +141,7 @@ const DropdownWrapper = styled.div<{
   position: absolute;
   left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
   right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
-  z-index: 5;
+  z-index: 1000;
   top: calc(100% + 7px);
   width: 35%;
   min-width: 400px;

+ 252 - 62
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useContext } from "react";
 import { CellProps } from "react-table";
 
 import styled from "styled-components";
-import EventTable from "./EventTable";
+import Table from "components/Table";
 import Loading from "components/Loading";
 import danger from "assets/danger.svg";
 import rocket from "assets/rocket.png";
@@ -16,21 +16,113 @@ import Modal from "main/home/modals/Modal";
 import time from "assets/time.svg";
 import { Context } from "shared/Context";
 import { InitLogData } from "../logs-section/LogsSection";
-import { setServers } from "dns";
+import { Direction, Log, parseLogs } from "../logs-section/useAgentLogs";
+import dayjs from "dayjs";
+import Anser from "anser";
 
 type Props = {
+  namespace: string;
   filters: any;
   setLogData?: (logData: InitLogData) => void;
 };
 
-const EventList: React.FC<Props> = ({ filters, setLogData }) => {
+interface ExpandedIncidentLogsProps {
+  logs: Log[];
+  onViewMore: () => void;
+}
+
+const ExpandedIncidentLogs = ({
+  logs,
+  onViewMore,
+}: ExpandedIncidentLogsProps) => {
+  if (!logs.length) {
+    return (
+      <LogsLoadWrapper>
+        <Loading />
+      </LogsLoadWrapper>
+    );
+  }
+
+  return (
+    <LogsSectionWrapper>
+      <StyledLogsSection>
+        {logs?.map((log, i) => {
+          return (
+            <LogSpan key={[log.lineNumber, i].join(".")}>
+              <span className="line-number">{log.lineNumber}.</span>
+              <span className="line-timestamp">
+                {dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")}
+              </span>
+              <LogOuter key={[log.lineNumber, i].join(".")}>
+                {log.line?.map((ansi, j) => {
+                  if (ansi.clearLine) {
+                    return null;
+                  }
+
+                  return (
+                    <LogInnerSpan
+                      key={[log.lineNumber, i, j].join(".")}
+                      ansi={ansi}
+                    >
+                      {ansi.content.replace(/ /g, "\u00a0")}
+                    </LogInnerSpan>
+                  );
+                })}
+              </LogOuter>
+            </LogSpan>
+          );
+        })}
+      </StyledLogsSection>
+      <ViewLogsWrapper>
+        <DocsLink
+          onClick={(e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            onViewMore();
+          }}
+        >
+          View complete log history
+          <i className="material-icons">open_in_new</i>{" "}
+        </DocsLink>
+      </ViewLogsWrapper>
+    </LogsSectionWrapper>
+  );
+};
+
+const EventList: React.FC<Props> = ({ filters, namespace, setLogData }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [events, setEvents] = useState([]);
+  const [logs, setLogs] = useState<Log[]>([]);
   const [expandedEvent, setExpandedEvent] = useState(null);
   const [expandedIncidentEvents, setExpandedIncidentEvents] = useState(null);
   const [isLoading, setIsLoading] = useState(true);
   const [refresh, setRefresh] = useState(true);
 
+  const redirectToLogs = (incident: any) => {
+    api
+      .getIncidentEvents(
+        "<token>",
+        {
+          incident_id: incident.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        const podName = res.data?.events[0]?.pod_name;
+        const timestamp = res.data?.events[0]?.last_seen;
+        const revision = res.data?.events[0]?.revision;
+
+        setLogData({
+          podName,
+          timestamp,
+          revision,
+        });
+      });
+  };
+
   useEffect(() => {
     if (!refresh) {
       return;
@@ -78,34 +170,46 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
         }
       )
       .then((res) => {
-        setExpandedIncidentEvents(res.data.events);
-      });
-  }, [expandedEvent]);
-
-  const redirectToLogs = (incident: any) => {
-    api
-      .getIncidentEvents(
-        "<token>",
-        {
-          incident_id: incident.id,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
+        if (!expandedEvent.should_view_logs) {
+          setExpandedIncidentEvents(res.data.events);
+          return null;
         }
-      )
-      .then((res) => {
-        const podName = res.data?.events[0]?.pod_name;
-        const timestamp = res.data?.events[0]?.last_seen;
-        const revision = res.data?.events[0]?.revision;
 
-        setLogData({
-          podName,
-          timestamp,
-          revision,
-        });
+        const events = res.data?.events ?? [];
+
+        api
+          .getLogs(
+            "<token>",
+            {
+              pod_selector: events[0]?.pod_name,
+              namespace,
+              revision: events[0]?.revision,
+              start_range: dayjs(events[0]?.updated_at)
+                .subtract(14, "day")
+                .toISOString(),
+              end_range: dayjs(events[0]?.updated_at).toISOString(),
+              limit: 100,
+              direction: Direction.backward,
+              search_param: "",
+            },
+            {
+              cluster_id: currentCluster.id,
+              project_id: currentProject.id,
+            }
+          )
+          .then((res) => {
+            const logs = parseLogs(
+              res.data.logs
+                ?.filter(Boolean)
+                .map((logLine: any) => logLine.line)
+                .reverse()
+            );
+            setLogs(logs);
+          });
+
+        setExpandedIncidentEvents(res.data.events);
       });
-  };
+  }, [expandedEvent]);
 
   const renderExpandedEventMessage = () => {
     if (!expandedIncidentEvents) {
@@ -113,10 +217,18 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
     }
 
     return (
-      <Message>
-        <img src={document} />
-        {expandedIncidentEvents[0].detail}
-      </Message>
+      <>
+        <Message>
+          <img src={document} />
+          {expandedIncidentEvents[0].detail}
+        </Message>
+        {expandedEvent.should_view_logs ? (
+          <ExpandedIncidentLogs
+            logs={logs}
+            onViewMore={() => redirectToLogs(expandedEvent)}
+          />
+        ) : null}
+      </>
     );
   };
 
@@ -185,7 +297,7 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
             },
           },
           {
-            Header: "Last Seen",
+            Header: "Last seen",
             accessor: "timestamp",
             width: 140,
             Cell: ({ row }: CellProps<any>) => {
@@ -213,32 +325,6 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
               return null;
             },
           },
-          {
-            id: "logs",
-            accessor: "",
-            width: 30,
-            Cell: ({ row }: CellProps<any>) => {
-              if (row.original.type != "incident") {
-                return null;
-              }
-
-              if (!row.original.data.should_view_logs) {
-                return null;
-              }
-
-              return (
-                <TableButton
-                  width="102px"
-                  onClick={() => {
-                    redirectToLogs(row.original.data);
-                  }}
-                >
-                  <Icon src={document} />
-                  View logs
-                </TableButton>
-              );
-            },
-          },
         ],
       },
     ],
@@ -248,7 +334,13 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
   return (
     <>
       {expandedEvent && (
-        <Modal onRequestClose={() => setExpandedEvent(null)} height="auto">
+        <Modal
+          onRequestClose={() => {
+            setExpandedEvent(null);
+            setLogs([]);
+          }}
+          height="auto"
+        >
           <TitleSection icon={danger}>
             <Text>{expandedEvent.release_name}</Text>
           </TitleSection>
@@ -268,7 +360,7 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
           </InfoRow>
           {expandedEvent?.porter_doc_link && (
             <DocsLink target="_blank" href={expandedEvent?.porter_doc_link}>
-              View troubleshooting steps{" "}
+              View troubleshooting steps
               <i className="material-icons">open_in_new</i>{" "}
             </DocsLink>
           )}
@@ -281,7 +373,11 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
         </LoadWrapper>
       ) : (
         <TableWrapper>
-          <EventTable columns={columns} data={events} />
+          <Table 
+            columns={columns} 
+            data={events} 
+            placeholder="No events found."
+          />
           <FlexRow>
             <Flex>
               <Button
@@ -303,6 +399,10 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
 
 export default EventList;
 
+const LogsLoadWrapper = styled.div`
+  height: 50px;
+`;
+
 const Message = styled.div`
   padding: 20px;
   background: #26292e;
@@ -369,6 +469,7 @@ const TableButton = styled.div<{ width?: string }>`
   justify-content: center;
   background: #ffffff11;
   border: 1px solid #aaaabb33;
+  margin-right: -17px;
   cursor: pointer;
   :hover {
     border: 1px solid #7a7b80;
@@ -503,5 +604,94 @@ const DocsLink = styled.a`
 
   > i {
     font-size: 12px;
+    margin-left: 5px;
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledLogsSection = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  max-height: 400px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  border-top: none;
+  background: #101420;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const LogSpan = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
   }
 `;
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;
+
+export const ViewLogsWrapper = styled.div`
+  margin-bottom: -15px;
+  margin-top: 15px;
+`;

+ 0 - 99
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx

@@ -1,99 +0,0 @@
-import Placeholder from "components/Placeholder";
-import React from "react";
-import {
-  Column,
-  Row,
-  useGlobalFilter,
-  usePagination,
-  useTable,
-} from "react-table";
-import {
-  StyledTd,
-  StyledTable,
-  StyledTHead,
-  StyledTh,
-  StyledTBody,
-} from "./styles";
-
-export type TableProps = {
-  columns: Column<any>[];
-  data: any[];
-  onRowClick?: (row: Row) => void;
-};
-
-const EventTable: React.FC<TableProps> = ({
-  columns: columnsData,
-  data,
-  onRowClick,
-}) => {
-  if (!data || data.length == 0) {
-    return <Placeholder>No events found.</Placeholder>;
-  }
-
-  const {
-    rows,
-    getTableProps,
-    getTableBodyProps,
-    prepareRow,
-    headerGroups,
-  } = useTable(
-    {
-      columns: columnsData,
-      data,
-    },
-    useGlobalFilter,
-    usePagination
-  );
-
-  const renderRows = () => {
-    return (
-      <>
-        {rows.map((row: any) => {
-          prepareRow(row);
-
-          return (
-            <tr
-              {...row.getRowProps()}
-              onClick={() => onRowClick && onRowClick(row)}
-              selected={false}
-            >
-              {row.cells.map((cell: any) => {
-                return (
-                  <StyledTd
-                    {...cell.getCellProps()}
-                    style={{
-                      width: cell.column.totalWidth,
-                    }}
-                  >
-                    {cell.render("Cell")}
-                  </StyledTd>
-                );
-              })}
-            </tr>
-          );
-        })}
-      </>
-    );
-  };
-
-  return (
-    <>
-      <StyledTable {...getTableProps()}>
-        <StyledTHead>
-          {headerGroups.map((headerGroup) => (
-            <tr {...headerGroup.getHeaderGroupProps()}>
-              {headerGroup.headers.map((column) => (
-                <StyledTh {...column.getHeaderProps()}>
-                  {column.render("Header")}
-                </StyledTh>
-              ))}
-            </tr>
-          ))}
-        </StyledTHead>
-        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
-      </StyledTable>
-    </>
-  );
-};
-
-export default EventTable;

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -147,7 +147,11 @@ const EventsTab: React.FC<Props> = ({
 
   return (
     <EventsPageWrapper>
-      <EventList setLogData={setLogData} filters={getFilters()} />
+      <EventList
+        namespace={currentChart.namespace}
+        setLogData={setLogData}
+        filters={getFilters()}
+      />
     </EventsPageWrapper>
   );
 };

+ 8 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -14,7 +14,7 @@ import DeploymentType from "../DeploymentType";
 import JobMetricsSection from "../metrics/JobMetricsSection";
 import Logs from "../status/Logs";
 import { useRouting } from "shared/routing";
-import LogsSection from "../logs-section/LogsSection";
+import LogsSection, { InitLogData } from "../logs-section/LogsSection";
 import EventsTab from "../events/EventsTab";
 import { getPodStatus } from "../deploy-status-section/util";
 import { capitalize } from "shared/string_utils";
@@ -229,6 +229,12 @@ const ExpandedJobRun = ({
       );
     }
 
+    let initData: InitLogData = {};
+
+    if (run.status.completionTime) {
+      initData.timestamp = run.status.completionTime;
+    }
+
     return (
       <JobLogsWrapper>
         <DeprecatedWarning>
@@ -247,6 +253,7 @@ const ExpandedJobRun = ({
           setIsFullscreen={() => {}}
           overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
           currentChart={currentChart}
+          initData={initData}
         />
       </JobLogsWrapper>
     );

+ 56 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx

@@ -138,32 +138,72 @@ const LogsSection: React.FC<Props> = ({
     selectedDate
   );
 
-  useEffect(() => {
+  const refreshPodLogsValues = async () => {
     if (overridingPodName) {
       return;
     }
 
-    api
-      .getLogPodValues(
+    const filters = {
+      namespace: currentChart.namespace,
+      revision: initData.revision ?? currentChart.version.toString(),
+      match_prefix: currentChart.name,
+    };
+
+    // if the current chart is set to a blue-green deployment, we don't set a revision, but instead
+    // we set the match prefix to the current chart and the active image tag.
+    if (currentChart.config.bluegreen?.enabled) {
+      filters.revision = null;
+
+      if (currentChart?.name.includes("web")) {
+        filters.match_prefix = `${currentChart.name}-${currentChart.config.bluegreen?.activeImageTag}`;
+      } else {
+        filters.match_prefix = `${currentChart.name}-web-${currentChart.config.bluegreen?.activeImageTag}`;
+      }
+    }
+
+    const logPodValuesResp = await api.getLogPodValues("<TOKEN>", filters, {
+      project_id: currentProject.id,
+      cluster_id: currentCluster.id,
+    });
+
+    if (logPodValuesResp.data?.length != 0) {
+      setPodFilterOpts(_.uniq(logPodValuesResp.data ?? []));
+
+      // only set pod filter if the current pod is not found in the resulting data
+      if (!logPodValuesResp.data?.includes(podFilter)) {
+        setPodFilter(logPodValuesResp.data[0]);
+      }
+
+      return;
+    }
+
+    // if we're on the latest revision and no pod values were returned, query for all release pods
+    if (currentChart.info.status == "deployed") {
+      const allReleasePodsResp = await api.getAllReleasePods(
         "<TOKEN>",
+        {},
         {
-          namespace: currentChart?.namespace,
-          revision: initData.revision ?? currentChart.version.toString(),
-          match_prefix: currentChart.name,
-        },
-        {
-          project_id: currentProject.id,
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
           cluster_id: currentCluster.id,
         }
-      )
-      .then((res: any) => {
-        setPodFilterOpts(_.uniq(res.data ?? []));
+      );
 
-        // only set pod filter if the current pod is not found in the resulting data
-        if (!res.data?.includes(podFilter)) {
-          setPodFilter(res.data[0]);
-        }
+      let podList = allReleasePodsResp.data.map((pod: any) => {
+        return pod.metadata.name;
       });
+
+      setPodFilterOpts(podList);
+
+      if (!podFilter || !podList.includes(podFilter)) {
+        setPodFilter(podList[0]);
+      }
+    }
+  };
+
+  useEffect(() => {
+    refreshPodLogsValues();
   }, [initData]);
 
   useEffect(() => {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

@@ -17,7 +17,7 @@ export enum Direction {
   backward = "backward",
 }
 
-interface Log {
+export interface Log {
   line: AnserJsonEntry[];
   lineNumber: number;
   timestamp: string;
@@ -29,7 +29,7 @@ interface LogLine {
   time: string;
 }
 
-const parseLogs = (logs: string[] = []): Log[] => {
+export const parseLogs = (logs: string[] = []): Log[] => {
   return logs
     .filter(Boolean)
     .filter(isJSON)

+ 168 - 20
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -3,9 +3,7 @@ import Heading from "components/form-components/Heading";
 import RepoList from "components/repo-selector/RepoList";
 import SaveButton from "components/SaveButton";
 import DocsHelper from "components/DocsHelper";
-import { ActionConfigType } from "shared/types";
-import TitleSection from "components/TitleSection";
-import { useRouteMatch } from "react-router";
+import { GithubActionConfigType } from "shared/types";
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
@@ -15,6 +13,11 @@ import { Environment } from "./types";
 import DashboardHeader from "../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import CheckboxRow from "components/form-components/CheckboxRow";
+import BranchFilterSelector from "./components/BranchFilterSelector";
+import Helper from "components/form-components/Helper";
+import NamespaceAnnotations, {
+  KeyValueType,
+} from "./components/NamespaceAnnotations";
 
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
@@ -30,16 +33,26 @@ const ConnectNewRepo: React.FC = () => {
   const { pushFiltered } = useRouting();
 
   // NOTE: git_repo_id is a misnomer as this actually refers to the github app's installation id.
-  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+  const [actionConfig, setActionConfig] = useState<GithubActionConfigType>({
     git_repo: null,
     image_repo_uri: null,
     git_branch: null,
     git_repo_id: 0,
+    kind: "github",
   });
 
-  useEffect(() => {}, [repo]);
+  // Branch selector data
+  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+  const [isLoadingBranches, setIsLoadingBranches] = useState(false);
 
-  const { url } = useRouteMatch();
+  // Disable new comments data
+  const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
+
+  // Namespace annotations
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
 
   useEffect(() => {
     api
@@ -65,15 +78,80 @@ const ConnectNewRepo: React.FC = () => {
       .catch(() => {});
   }, []);
 
+  useEffect(() => {
+    if (!actionConfig.git_repo || !actionConfig.git_repo_id) {
+      return;
+    }
+
+    let isSubscribed = true;
+    const repoName = actionConfig.git_repo.split("/")[1];
+    const repoOwner = actionConfig.git_repo.split("/")[0];
+    setIsLoadingBranches(true);
+    api
+      .getBranches<string[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          kind: "github",
+          name: repoName,
+          owner: repoOwner,
+          git_repo_id: actionConfig.git_repo_id,
+        }
+      )
+      .then(({ data }) => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setAvailableBranches(data);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setCurrentError(
+            "Couldn't load branches for this repository, using all branches by default."
+          );
+        }
+      });
+  }, [actionConfig]);
+
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
+    let annotations: Record<string, string> = {};
+
     setStatus("loading");
+
+    namespaceAnnotations
+      .filter((elem: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are duplicates
+        let numCollisions = self.reduce((n, _elem: KeyValueType) => {
+          return n + (_elem.key === elem.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex((_elem: KeyValueType) => _elem.key === elem.key)
+          );
+        }
+      })
+      .forEach((elem: KeyValueType) => {
+        if (elem.key !== "" && elem.value !== "") {
+          annotations[elem.key] = elem.value;
+        }
+      });
+
     api
       .createEnvironment(
         "<token>",
         {
           name: `preview`,
           mode: enableAutomaticDeployments ? "auto" : "manual",
+          disable_new_comments: isNewCommentsDisabled,
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         {
           project_id: currentProject.id,
@@ -98,7 +176,8 @@ const ConnectNewRepo: React.FC = () => {
     <>
       <DashboardHeader
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
+        capitalize={false}
         description="Create full-stack preview environments for your pull requests."
       />
 
@@ -107,14 +186,28 @@ const ConnectNewRepo: React.FC = () => {
           <i className="material-icons">keyboard_backspace</i>
           Back
         </Button>
-        <Title>Enable Preview Environments on a Repository</Title>
+        <Title>
+          <div
+            style={{
+              display: "flex",
+              alignItems: "center",
+            }}
+          >
+            Enable Preview Environments on a Repository
+            <DocsHelper
+              tooltipText="Learn more about preview environments"
+              link="https://docs.porter.run/preview-environments/overview/"
+              placement="top-end"
+            />
+          </div>
+        </Title>
       </HeaderSection>
 
       <Heading>Select a Repository</Heading>
       <br />
       <RepoList
         actionConfig={actionConfig}
-        setActionConfig={(a: ActionConfigType) => {
+        setActionConfig={(a: GithubActionConfigType) => {
           setActionConfig(a);
           setRepo(a.git_repo);
         }}
@@ -131,20 +224,69 @@ const ConnectNewRepo: React.FC = () => {
         />
       </HelperContainer>
 
-      <FlexWrap>
+      <Heading>Automatic pull request deployments</Heading>
+      <Helper>
+        If you enable this option, the new pull requests will be automatically
+        deployed.
+      </Helper>
+      <CheckboxWrapper>
         <CheckboxRow
-          label="Enable automatic deployments"
+          label="Enable automatic deploys"
           checked={enableAutomaticDeployments}
-          toggle={() => setEnableAutomaticDeployments((prev) => !prev)}
+          toggle={() =>
+            setEnableAutomaticDeployments(!enableAutomaticDeployments)
+          }
+          wrapperStyles={{
+            disableMargin: true,
+          }}
         />
-        <Div>
-          <DocsHelper
-            disableMargin
-            tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
-            placement="top-start"
-          />
-        </Div>
-      </FlexWrap>
+      </CheckboxWrapper>
+
+      <Heading>Disable new comments for new deployments</Heading>
+      <Helper>
+        When enabled new comments will not be created for new deployments.
+        Instead the last comment will be updated.
+      </Helper>
+      <CheckboxWrapper>
+        <CheckboxRow
+          label="Disable new comments for deployments"
+          checked={isNewCommentsDisabled}
+          toggle={() => setIsNewCommentsDisabled(!isNewCommentsDisabled)}
+          wrapperStyles={{
+            disableMargin: true,
+          }}
+        />
+      </CheckboxWrapper>
+
+      <Heading>Select allowed branches</Heading>
+      <Helper>
+        If the pull request has a base branch included in this list, it will be
+        allowed to be deployed.
+        <br />
+        (Leave empty to allow all branches)
+      </Helper>
+      <BranchFilterSelector
+        onChange={setSelectedBranches}
+        options={availableBranches}
+        value={selectedBranches}
+        showLoading={isLoadingBranches}
+      />
+
+      <Heading>Namespace annotations</Heading>
+      <Helper>
+        Custom annotations to be injected into the Kubernetes namespace created
+        for each deployment.
+      </Helper>
+      <NamespaceAnnotations
+        values={namespaceAnnotations}
+        setValues={(x: KeyValueType[]) => {
+          let annotations: KeyValueType[] = [];
+          x.forEach((entry) => {
+            annotations.push({ key: entry.key, value: entry.value });
+          });
+          setNamespaceAnnotations(annotations);
+        }}
+      />
 
       <ActionContainer>
         <SaveButton
@@ -273,3 +415,9 @@ const HeaderSection = styled.div`
     margin-right: 7px;
   }
 `;
+
+const CheckboxWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;

+ 90 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx

@@ -0,0 +1,90 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useMemo } from "react";
+import styled from "styled-components";
+
+const BranchFilterSelector = ({
+  value,
+  options,
+  onChange,
+  showLoading,
+}: {
+  value: string[];
+  options: string[];
+  onChange: (value: string[]) => void;
+  showLoading?: boolean;
+}) => {
+  const filteredBranches = useMemo(() => {
+    if (!options.length) {
+      return [];
+    }
+
+    if (value.find((branch) => branch === "")) {
+      return options;
+    }
+
+    return options.filter((branch) => !value.includes(branch));
+  }, [options, value]);
+
+  const handleAddBranch = (branch: string) => {
+    const newSelectedBranches = [...value, branch];
+
+    onChange(newSelectedBranches);
+  };
+
+  const handleDeleteBranch = (branch: string) => {
+    const newSelectedBranches = value.filter(
+      (selectedBranch) => selectedBranch !== branch
+    );
+
+    onChange(newSelectedBranches);
+  };
+
+  const placeholder = options?.length
+    ? "Find or add a branch..."
+    : "No branches found for current repository.";
+
+  return (
+    <>
+      <SearchSelector
+        options={filteredBranches}
+        onSelect={(newBranch) => handleAddBranch(newBranch)}
+        getOptionLabel={(option) => option}
+        placeholder={placeholder}
+        showLoading={showLoading}
+      />
+      {/* List selected branches  */}
+
+      <BranchRowList>
+      {value.map((branch) => (
+        <BranchRow key={branch}>
+          <div>{branch}</div>
+          <RemoveBranchButton onClick={() => handleDeleteBranch(branch)}>
+            x
+          </RemoveBranchButton>
+        </BranchRow>
+      ))}
+      </BranchRowList>
+    </>
+  );
+};
+
+export default BranchFilterSelector;
+
+const BranchRowList = styled.div`
+  margin-block: 15px;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const BranchRow = styled.div`
+  padding-inline: 8px;
+  gap: 10px;
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+`;
+
+const RemoveBranchButton = styled.div`
+  cursor: pointer;
+`;

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -137,6 +137,7 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
@@ -169,6 +170,4 @@ const Button = styled(DynamicLink)`
 `;
 
 const Container = styled.div`
-  width: 50%;
-  display: flex;
 `;

+ 164 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/NamespaceAnnotations.tsx

@@ -0,0 +1,164 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+};
+
+type PropsType = {
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+};
+
+const NamespaceAnnotations = ({ values, setValues }: PropsType) => {
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <StyledInputArray>
+        {!!values?.length &&
+          values.map((entry: KeyValueType, i: number) => {
+            return (
+              <InputWrapper key={i}>
+                <Input
+                  placeholder="ex: key"
+                  width="270px"
+                  value={entry.key}
+                  onChange={(e: any) => {
+                    let _values = values;
+                    _values[i].key = e.target.value;
+                    setValues(_values);
+                  }}
+                />
+                <Spacer />
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={entry.value}
+                  onChange={(e: any) => {
+                    let _values = values;
+                    _values[i].value = e.target.value;
+                    setValues(_values);
+                  }}
+                />
+                <DeleteButton
+                  onClick={() => {
+                    let _values = values;
+                    _values = _values.filter((val) => val.key !== entry.key);
+                    setValues(_values);
+                  }}
+                >
+                  <i className="material-icons">cancel</i>
+                </DeleteButton>
+              </InputWrapper>
+            );
+          })}
+        <InputWrapper>
+          <AddRowButton
+            onClick={() => {
+              let _values = values;
+              _values.push({
+                key: "",
+                value: "",
+              });
+              setValues(_values);
+            }}
+          >
+            <i className="material-icons">add</i> Add Row
+          </AddRowButton>
+          <Spacer />
+        </InputWrapper>
+      </StyledInputArray>
+    </>
+  );
+};
+
+export default NamespaceAnnotations;
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 98 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PorterYAMLErrorsModal.tsx

@@ -0,0 +1,98 @@
+import React from "react";
+import styled from "styled-components";
+import TitleSection from "components/TitleSection";
+import danger from "assets/danger.svg";
+import info from "assets/info.svg";
+import Modal from "main/home/modals/Modal";
+
+interface PorterYAMLErrorsModalProps {
+  errors: string[];
+  onClose: (...args: any[]) => void;
+  repo: string;
+  branch?: string;
+}
+
+const PorterYAMLErrorsModal = ({
+  errors,
+  onClose,
+  repo,
+  branch,
+}: PorterYAMLErrorsModalProps) => {
+  if (!errors.length) {
+    return null;
+  }
+
+  return (
+    <Modal onRequestClose={() => onClose()} height="auto">
+      <TitleSection icon={danger}>
+        <Text>porter.yaml</Text>
+      </TitleSection>
+      <InfoRow>
+        <InfoTab>
+          <img src={info} /> <Bold>Repo:</Bold>
+          {repo}
+        </InfoTab>
+        {branch ? (
+          <InfoTab>
+            <img src={info} /> <Bold>Branch:</Bold>
+            {branch}
+          </InfoTab>
+        ) : null}
+      </InfoRow>
+      <Message>
+        {errors.map((el) => {
+          return (
+            <div>
+              {"- "}
+              {el}
+            </div>
+          );
+        })}
+      </Message>
+    </Modal>
+  );
+};
+
+const Text = styled.div`
+  font-weight: 500;
+  font-size: 18px;
+  z-index: 999;
+`;
+
+const InfoTab = styled.div`
+  display: flex;
+  align-items: center;
+  opacity: 50%;
+  font-size: 13px;
+  margin-right: 15px;
+  justify-content: center;
+
+  > img {
+    width: 13px;
+    margin-right: 7px;
+  }
+`;
+
+const InfoRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  margin-bottom: 12px;
+`;
+
+const Bold = styled.div`
+  font-weight: 500;
+  margin-right: 5px;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+export default PorterYAMLErrorsModal;

+ 14 - 13
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import api from "shared/api";
+import Banner from "components/Banner";
 
 export const PreviewEnvironmentsHeader = () => {
   const [githubStatus, setGithubStatus] = useState<string>(
@@ -24,28 +25,28 @@ export const PreviewEnvironmentsHeader = () => {
     <>
       <DashboardHeader
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
         description="Create full-stack preview environments for your pull requests."
         disableLineBreak
+        capitalize={false}
       />
       {githubStatus != "no active incidents" ? (
-        <AlertCard>
-          <AlertCardIcon className="material-icons">error</AlertCardIcon>
-          <AlertCardContent className="content">
-            <AlertCardTitle className="title">
-              Github has an ongoing incident
-            </AlertCardTitle>
-            Active incident:{" "}
-            <a href={`${githubStatus}`} target="_blank">
-              {githubStatus}
-            </a>
-          </AlertCardContent>
-        </AlertCard>
+        <Banner type="error">
+          GitHub has an ongoing incident.
+          <StyledLink href={`${githubStatus}`} target="_blank">
+            View details
+          </StyledLink>
+        </Banner>
       ) : null}
     </>
   );
 };
 
+const StyledLink = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;  
+`;
+
 const AlertCard = styled.div`
   transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
   border-radius: 4px;

+ 213 - 60
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
 import styled, { keyframes } from "styled-components";
 import { DeploymentStatus, PRDeployment } from "../types";
 import pr_icon from "assets/pull_request_icon.svg";
@@ -11,6 +11,78 @@ import Loading from "components/Loading";
 import { ActionButton } from "../components/ActionButton";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import MaterialTooltip from "@material-ui/core/Tooltip";
+import _ from "lodash";
+
+interface DeploymentCardAction {
+  active: boolean;
+  label: string;
+  action: (...args: any) => void;
+}
+
+interface DeploymentCardActionsDropdownProps {
+  options: DeploymentCardAction[];
+}
+
+const DeploymentCardActionsDropdown = ({
+  options,
+}: DeploymentCardActionsDropdownProps) => {
+  const wrapperRef = useRef<HTMLDivElement>();
+  const [expanded, setExpanded] = useState(false);
+
+  const handleOutsideClick = (event: any) => {
+    if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
+      setExpanded(false);
+    }
+  };
+
+  useEffect(() => {
+    document.addEventListener("mousedown", handleOutsideClick.bind(this));
+
+    return () => {
+      document.removeEventListener("mousedown", handleOutsideClick.bind(this));
+    };
+  }, []);
+
+  return (
+    <div
+      style={{
+        position: "relative",
+      }}
+    >
+      <I
+        className="material-icons"
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          setExpanded((expanded) => !expanded);
+        }}
+      >
+        more_vert
+      </I>
+      <ActionsDropdownWrapper expanded={expanded}>
+        <ActionsDropdown ref={wrapperRef}>
+          {options.length ? (
+            <ActionsScrollableWrapper>
+              {options
+                .filter((option) => option.active)
+                .map(({ label, action }, idx) => {
+                  return (
+                    <ActionsRow
+                      isLast={idx === options.length - 1}
+                      onClick={action}
+                      key={label}
+                    >
+                      <ActionsRowText>{label}</ActionsRowText>
+                    </ActionsRow>
+                  );
+                })}
+            </ActionsScrollableWrapper>
+          ) : null}
+        </ActionsDropdown>
+      </ActionsDropdownWrapper>
+    </div>
+  );
+};
 
 const DeploymentCard: React.FC<{
   deployment: PRDeployment;
@@ -100,13 +172,49 @@ const DeploymentCard: React.FC<{
     }
   };
 
+  const DeploymentCardActions = [
+    {
+      active: !!deployment.last_workflow_run_url,
+      label: "View last workflow",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        window.open(deployment.last_workflow_run_url, "_blank");
+      },
+    },
+    {
+      active: true,
+      label: "Delete",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        deleteDeployment();
+      },
+    },
+  ];
+
   return (
-    <DeploymentCardWrapper>
+    <DeploymentCardWrapper
+      to={`/preview-environments/details/${deployment.id}?environment_id=${deployment.environment_id}`}
+    >
       <DataContainer>
         <PRName>
           <PRIcon src={pr_icon} alt="pull request icon" />
           <EllipsisTextWrapper tooltipText={deployment.gh_pr_name}>
-            {deployment.gh_pr_name}
+            <StyledLink
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                window.open(
+                  `https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`,
+                  "_blank"
+                );
+              }}
+              to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
+              target="_blank"
+            >
+              {deployment.gh_pr_name}
+            </StyledLink>
           </EllipsisTextWrapper>
           {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
             <MergeInfoWrapper>
@@ -126,19 +234,6 @@ const DeploymentCard: React.FC<{
               )}
             </MergeInfoWrapper>
           ) : null}
-          <RepoLink
-            to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
-            target="_blank"
-          >
-            <i className="material-icons">open_in_new</i>
-            View PR
-          </RepoLink>
-          {deployment.last_workflow_run_url ? (
-            <RepoLink to={deployment.last_workflow_run_url} target="_blank">
-              <i className="material-icons">open_in_new</i>
-              View last workflow
-            </RepoLink>
-          ) : null}
         </PRName>
 
         <Flex>
@@ -176,55 +271,40 @@ const DeploymentCard: React.FC<{
               </>
             ) : null}
 
-            {deployment.status !== DeploymentStatus.Creating &&
-              deployment.status !== DeploymentStatus.Inactive && (
-                <>
-                  <RowButton
-                    to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                    key={deployment.id}
-                  >
-                    <i className="material-icons-outlined">info</i>
-                    Details
-                  </RowButton>
+            {deployment.status !== DeploymentStatus.Creating && (
+              <>
+                {deployment.subdomain &&
+                deployment.status === DeploymentStatus.Created ? (
                   <RowButton
-                    to={deployment.subdomain}
+                    onClick={(e) => {
+                      e.preventDefault();
+                      e.stopPropagation();
+
+                      window.open(deployment.subdomain, "_blank");
+                    }}
                     key={deployment.subdomain}
-                    target="_blank"
                   >
                     <i className="material-icons">open_in_new</i>
                     View Live
                   </RowButton>
-                </>
-              )}
-            {deployment.status === DeploymentStatus.Inactive ? (
-              <ActionButton
-                onClick={reEnablePreviewEnvironment}
-                disabled={isLoading}
-                hasError={hasErrorOnReEnabling}
-              >
-                {isLoading ? (
-                  <Loading width="198px" height="14px" />
-                ) : (
-                  <>
-                    <i className="material-icons">play_arrow</i>
-                    Activate Preview Environment
-                  </>
-                )}
-              </ActionButton>
-            ) : (
-              <Button
-                onClick={() => {
-                  setCurrentOverlay({
-                    message: `Are you sure you want to delete this deployment?`,
-                    onYes: deleteDeployment,
-                    onNo: () => setCurrentOverlay(null),
-                  });
-                }}
-              >
-                <i className="material-icons">delete</i>
-                Delete
-              </Button>
+                ) : null}
+                <DeploymentCardActionsDropdown
+                  options={DeploymentCardActions}
+                />
+              </>
             )}
+            {/* <Button
+              onClick={() => {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete this deployment?`,
+                  onYes: deleteDeployment,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              }}
+            >
+              <i className="material-icons">delete</i>
+              Delete
+            </Button> */}
           </>
         ) : (
           <DeleteMessage>
@@ -308,7 +388,7 @@ const PRName = styled.div`
   margin-bottom: 10px;
 `;
 
-const DeploymentCardWrapper = styled.div`
+const DeploymentCardWrapper = styled(DynamicLink)`
   display: flex;
   justify-content: space-between;
   font-size: 13px;
@@ -351,7 +431,7 @@ const PRIcon = styled.img`
   opacity: 50%;
 `;
 
-const RowButton = styled(DynamicLink)`
+const RowButton = styled.button`
   white-space: nowrap;
   font-size: 12px;
   padding: 8px 10px;
@@ -516,3 +596,76 @@ const MergeInfo = styled.div`
     margin: 0 2px;
   }
 `;
+
+const I = styled.i`
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  cursor: pointer;
+  border-radius: 40px;
+  font-size: 18px;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  &:hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
+`;
+
+const ActionsDropdown = styled.div`
+  width: 150px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const ActionsDropdownWrapper = styled.div<{ expanded: boolean }>`
+  display: ${(props) => (props.expanded ? "block" : "none")};
+  position: absolute;
+  right: calc(-100%);
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const ActionsScrollableWrapper = styled.div`
+  overflow-y: auto;
+  max-height: 350px;
+`;
+
+const ActionsRow = styled.div<{ isLast: boolean; selected?: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const ActionsRowText = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  margin-right: 10px;
+  color: white;
+`;
+
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
+`;

+ 284 - 82
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -4,7 +4,8 @@ import TitleSection from "components/TitleSection";
 import pr_icon from "assets/pull_request_icon.svg";
 import { useRouteMatch, useLocation } from "react-router";
 import DynamicLink from "components/DynamicLink";
-import { PRDeployment } from "../types";
+import { DeploymentStatus, PRDeployment } from "../types";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -12,14 +13,22 @@ import ChartList from "../../chart/ChartList";
 import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
-import leftArrow from "assets/left-arrow.svg";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import { validatePorterYAML } from "../utils";
+import Placeholder from "components/Placeholder";
+import GithubIcon from "assets/GithubIcon";
 
 const DeploymentDetail = () => {
-  const { params } = useRouteMatch<{ namespace: string }>();
+  const { params } = useRouteMatch<{ id: string }>();
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [environmentId, setEnvironmentId] = useState("");
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
   const { currentProject, currentCluster } = useContext(Context);
 
@@ -31,10 +40,10 @@ const DeploymentDetail = () => {
     let environment_id = parseInt(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     api
-      .getPRDeploymentByEnvironment(
+      .getPRDeploymentByID(
         "<token>",
         {
-          namespace: params.namespace,
+          id: parseInt(params.id),
         },
         {
           project_id: currentProject.id,
@@ -46,7 +55,6 @@ const DeploymentDetail = () => {
         if (!isSubscribed) {
           return;
         }
-
         setPRDeployment(data);
       })
       .catch((err) => {
@@ -57,112 +65,294 @@ const DeploymentDetail = () => {
       });
   }, [params]);
 
+  useEffect(() => {
+    if (!prDeployment) {
+      return;
+    }
+
+    const isSubscribed = true;
+    const environment_id = parseInt(searchParams.get("environment_id"));
+
+    validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: environment_id,
+      branch: prDeployment.gh_pr_branch_from,
+    })
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setPorterYAMLErrors(data.errors ?? []);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setPorterYAMLErrors([]);
+        }
+      });
+  }, [prDeployment]);
+
   if (!prDeployment) {
     return <Loading />;
   }
 
-  let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
+  const repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
+
+  if (
+    !prDeployment.namespace &&
+    ["creating", "updating"].includes(prDeployment.status)
+  ) {
+    return (
+      <>
+        <BreadcrumbRow>
+          <Breadcrumb to={`/preview-environments/deployments/settings`}>
+            <ArrowIcon src={PullRequestIcon} />
+            <Wrap>Preview environments</Wrap>
+          </Breadcrumb>
+          <Slash>/</Slash>
+          <Breadcrumb
+            to={`/preview-environments/deployments/${environmentId}/${repository}`}
+          >
+            <GitIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+            <Wrap>{repository}</Wrap>
+          </Breadcrumb>
+        </BreadcrumbRow>
+        <StyledExpandedChart>
+          <HeaderWrapper>
+            <Title
+              icon={pr_icon}
+              iconWidth="25px"
+              onClick={() =>
+                window.open(
+                  `https://github.com/${repository}/pull/${prDeployment.pull_request_id}`,
+                  "_blank"
+                )
+              }
+            >
+              {prDeployment.gh_pr_name}
+            </Title>
+            <InfoWrapper>
+              {prDeployment.subdomain && (
+                <PRLink to={prDeployment.subdomain} target="_blank">
+                  <i className="material-icons">link</i>
+                  {prDeployment.subdomain}
+                </PRLink>
+              )}
+            </InfoWrapper>
+            <Flex>
+              <Status>
+                <StatusDot status={prDeployment.status} />
+                {capitalize(prDeployment.status)}
+              </Status>
+              <Dot>•</Dot>
+              <DeploymentImageContainer>
+                <DeploymentTypeIcon src={integrationList.repo.icon} />
+                <RepositoryName
+                  onMouseOver={() => {
+                    setShowRepoTooltip(true);
+                  }}
+                  onMouseOut={() => {
+                    setShowRepoTooltip(false);
+                  }}
+                >
+                  {repository}
+                </RepositoryName>
+                {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+              </DeploymentImageContainer>
+              <Dot>•</Dot>
+              <GHALink
+                to={`https://github.com/${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}/pulls/${prDeployment.pull_request_id}`}
+                target="_blank"
+              >
+                <GithubIcon />
+                View PR
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            </Flex>
+            <LinkToActionsWrapper></LinkToActionsWrapper>
+          </HeaderWrapper>
+          <ChartListWrapper>
+            <Placeholder height="370px">
+              This preview deployment has not been created yet.{" "}
+              <ViewLastWorkflowLink
+                to={`https://github.com/${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}/actions`}
+                target="_blank"
+              >
+                View last workflow
+              </ViewLastWorkflowLink>
+            </Placeholder>
+          </ChartListWrapper>
+        </StyledExpandedChart>
+      </>
+    );
+  }
 
   return (
-    <StyledExpandedChart>
+    <>
+      {expandedPorterYAMLErrors.length > 0 && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors([])}
+          height="auto"
+        >
+          <Message>
+            {expandedPorterYAMLErrors.map((el) => {
+              return (
+                <div>
+                  {"- "}
+                  {el}
+                </div>
+              );
+            })}
+          </Message>
+        </Modal>
+      )}
       <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
         <Breadcrumb
           to={`/preview-environments/deployments/${environmentId}/${repository}`}
         >
-          <ArrowIcon src={leftArrow} />
-          <Wrap>Back</Wrap>
+          <GitIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{repository}</Wrap>
         </Breadcrumb>
       </BreadcrumbRow>
-      <HeaderWrapper>
-        <Title icon={pr_icon} iconWidth="25px">
-          {prDeployment.gh_pr_name}
-        </Title>
-        <InfoWrapper>
-          {prDeployment.subdomain && (
-            <PRLink to={prDeployment.subdomain} target="_blank">
-              <i className="material-icons">link</i>
-              {prDeployment.subdomain}
-            </PRLink>
-          )}
-          <TagWrapper>
-            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
-          </TagWrapper>
-        </InfoWrapper>
-        <Flex>
-          <Status>
-            <StatusDot status={prDeployment.status} />
-            {capitalize(prDeployment.status)}
-          </Status>
-          <Dot>•</Dot>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-          </DeploymentImageContainer>
-          <Dot>•</Dot>
-          <GHALink
-            to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
-            target="_blank"
+      <StyledExpandedChart>
+        <HeaderWrapper>
+          <Title
+            icon={pr_icon}
+            iconWidth="25px"
+            onClick={() =>
+              window.open(
+                `https://github.com/${repository}/pull/${prDeployment.pull_request_id}`,
+                "_blank"
+              )
+            }
           >
-            <img src={github} /> GitHub PR
-            <i className="material-icons">open_in_new</i>
-          </GHALink>
-          {prDeployment.last_workflow_run_url ? (
-            <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
-              <span className="material-icons-outlined">
-                play_circle_outline
-              </span>
-              Last workflow run
-              <i className="material-icons">open_in_new</i>
-            </GHALink>
-          ) : null}
-        </Flex>
-        <LinkToActionsWrapper></LinkToActionsWrapper>
-      </HeaderWrapper>
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={context.currentCluster}
-          currentView="cluster-dashboard"
-          sortType="Newest"
-          namespace={params.namespace}
-          disableBottomPadding
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </StyledExpandedChart>
+            {prDeployment.gh_pr_name}
+          </Title>
+          <InfoWrapper>
+            {prDeployment.subdomain && (
+              <PRLink to={prDeployment.subdomain} target="_blank">
+                <i className="material-icons">link</i>
+                {prDeployment.subdomain}
+              </PRLink>
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{prDeployment.namespace}</NamespaceTag>
+            </TagWrapper>
+          </InfoWrapper>
+          <Flex>
+            <Status>
+              <StatusDot status={prDeployment.status} />
+              {capitalize(prDeployment.status)}
+            </Status>
+            <Dot>•</Dot>
+            <DeploymentImageContainer>
+              <DeploymentTypeIcon src={integrationList.repo.icon} />
+              <RepositoryName
+                onMouseOver={() => {
+                  setShowRepoTooltip(true);
+                }}
+                onMouseOut={() => {
+                  setShowRepoTooltip(false);
+                }}
+              >
+                {repository}
+              </RepositoryName>
+              {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+            </DeploymentImageContainer>
+            <Dot>•</Dot>
+            {prDeployment.last_workflow_run_url ? (
+              <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
+                <img src={github} /> View last workflow run
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            ) : null}
+          </Flex>
+          <LinkToActionsWrapper></LinkToActionsWrapper>
+        </HeaderWrapper>
+        {porterYAMLErrors.length > 0 ? (
+          <ErrorBannerWrapper>
+            <Banner type="error">
+              Your porter.yaml file has errors. Please fix them before
+              deploying.
+              <LinkButton
+                onClick={() => {
+                  setExpandedPorterYAMLErrors(porterYAMLErrors);
+                }}
+              >
+                View details
+              </LinkButton>
+            </Banner>
+          </ErrorBannerWrapper>
+        ) : null}
+        <ChartListWrapper>
+          <ChartList
+            currentCluster={context.currentCluster}
+            currentView="cluster-dashboard"
+            sortType="Newest"
+            namespace={prDeployment.namespace}
+            disableBottomPadding
+            closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+          />
+        </ChartListWrapper>
+      </StyledExpandedChart>
+    </>
   );
 };
 
 export default DeploymentDetail;
 
+const ErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
 const ArrowIcon = styled.img`
   width: 15px;
   margin-right: 8px;
   opacity: 50%;
 `;
 
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
 const BreadcrumbRow = styled.div`
   width: 100%;
   display: flex;
+  margin-top: -5px;
   justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 15px;
 `;
 
 const Breadcrumb = styled(DynamicLink)`
   color: #aaaabb88;
   font-size: 13px;
-  margin-bottom: 15px;
   display: flex;
   align-items: center;
-  margin-top: -10px;
   z-index: 999;
   padding: 5px;
   padding-right: 7px;
@@ -192,7 +382,6 @@ const GHALink = styled(DynamicLink)`
   align-items: center;
 
   :hover {
-    text-decoration: underline;
     color: white;
   }
 
@@ -201,10 +390,7 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-left: 5px;
 
-    text-decoration: none;
-
     :hover {
-      text-decoration: underline;
       color: white;
     }
   }
@@ -222,6 +408,18 @@ const GHALink = styled(DynamicLink)`
   }
 `;
 
+const ViewLastWorkflowLink = styled(DynamicLink)`
+  display: flex;
+  align-items: center;
+  text-decoration: underline;
+  margin-left: 7px;
+  color: currentcolor;
+
+  :hover {
+    color: white;
+  }
+`;
+
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 1px;
@@ -309,6 +507,11 @@ const Icon = styled.img`
   width: 100%;
 `;
 
+const GitIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
 const StyledExpandedChart = styled.div`
   width: 100%;
   z-index: 0;
@@ -373,7 +576,6 @@ const PRLink = styled(DynamicLink)`
 const ChartListWrapper = styled.div`
   width: 100%;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
 `;
 

+ 358 - 266
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -2,48 +2,59 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
-import Selector from "components/Selector";
-
 import Loading from "components/Loading";
-
 import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
 import { Environment, PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
-import PullRequestCard from "./PullRequestCard";
 import DynamicLink from "components/DynamicLink";
-import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
-import SearchBar from "components/SearchBar";
-import CheckboxRow from "components/form-components/CheckboxRow";
-import DocsHelper from "components/DocsHelper";
+import DashboardHeader from "../../DashboardHeader";
+import RadioFilter from "components/RadioFilter";
+import Placeholder from "components/Placeholder";
+import Banner from "components/Banner";
+
+import pullRequestIcon from "assets/pull_request_icon.svg";
+import filterOutline from "assets/filter-outline.svg";
+import sort from "assets/sort.svg";
+import { search } from "shared/search";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import { PorterYAMLErrors } from "../errors";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
 
 const AvailableStatusFilters = [
   "all",
+  "creating",
   "created",
   "failed",
-  "active",
-  "inactive",
-  "not_deployed",
+  "timed_out",
+  "updating",
 ];
 
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 
 const DeploymentList = () => {
+  const [sortOrder, setSortOrder] = useState("Newest");
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
   const [
     statusSelectorVal,
     setStatusSelectorVal,
-  ] = useState<AvailableStatusFiltersType>("active");
+  ] = useState<AvailableStatusFiltersType>("all");
 
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
@@ -55,20 +66,6 @@ const DeploymentList = () => {
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
-  const getPRDeploymentList = () => {
-    return api.getPRDeploymentList(
-      "<token>",
-      {
-        environment_id: Number(environment_id),
-      },
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-      }
-    );
-    // return mockRequest();
-  };
-
   const getEnvironment = () => {
     return api.getEnvironment(
       "<token>",
@@ -102,68 +99,80 @@ const DeploymentList = () => {
     let isSubscribed = true;
     setIsLoading(true);
 
-    Promise.allSettled([getPRDeploymentList(), getEnvironment()]).then(
-      ([getDeploymentsResponse, getEnvironmentResponse]) => {
-        const deploymentList =
-          getDeploymentsResponse.status === "fulfilled"
-            ? getDeploymentsResponse.value.data
-            : {};
-        const environmentList =
-          getEnvironmentResponse.status === "fulfilled"
-            ? getEnvironmentResponse.value.data
-            : {};
-
-        if (!isSubscribed) {
-          return;
-        }
-
-        setDeploymentList(deploymentList.deployments || []);
-        setPullRequests(deploymentList.pull_requests || []);
-
-        setNewCommentsDisabled(environmentList.new_comments_disabled || false);
+    Promise.allSettled([
+      validatePorterYAML({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getEnvironment(),
+    ])
+      .then(
+        ([
+          validatePorterYAMLResponse,
+          getDeploymentsResponse,
+          getEnvironmentResponse,
+        ]) => {
+          const deploymentList =
+            getDeploymentsResponse.status === "fulfilled"
+              ? getDeploymentsResponse.value.data
+              : {};
+          const environmentList =
+            getEnvironmentResponse.status === "fulfilled"
+              ? getEnvironmentResponse.value.data
+              : {};
+          const porterYAMLErrors =
+            validatePorterYAMLResponse.status === "fulfilled"
+              ? validatePorterYAMLResponse.value.data.errors
+              : [];
+
+          if (!isSubscribed) {
+            return;
+          }
+
+          setPorterYAMLErrors(porterYAMLErrors);
+          setDeploymentList(deploymentList.deployments ?? []);
+          setPullRequests(deploymentList.pull_requests || []);
+
+          setNewCommentsDisabled(
+            environmentList.new_comments_disabled || false
+          );
 
-        setIsLoading(false);
-      }
-    );
+          setIsLoading(false);
+        }
+      )
+      .catch((err) => {
+        setDeploymentList([]);
+        setCurrentError(err);
+      });
 
     return () => {
       isSubscribed = false;
     };
-  }, [currentCluster, currentProject]);
+  }, [currentCluster, currentProject, environment_id]);
 
   const handleRefresh = async () => {
     setIsLoading(true);
     try {
-      const { data } = await getPRDeploymentList();
-      setDeploymentList(data.deployments || []);
+      const { data } = await getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      });
+      setDeploymentList(data.deployments ?? []);
       setPullRequests(data.pull_requests || []);
     } catch (error) {
       setHasError(true);
       console.error(error);
     }
-    try {
-      const { data } = await getEnvironment();
-      setNewCommentsDisabled(data.new_comments_disabled || false);
-    } catch (error) {
-      setHasError(true);
-      console.error(error);
-    }
     setIsLoading(false);
   };
 
-  const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
-    setPullRequests((prev) => {
-      return prev.filter((pr) => {
-        return (
-          pr.pr_title === pullRequest.pr_title &&
-          `${pr.repo_owner}/${pr.repo_name}` ===
-            `${pullRequest.repo_owner}/${pullRequest.repo_name}`
-        );
-      });
-    });
-    handleRefresh();
-  };
-
   const searchFilter = (value: string | number) => {
     const val = String(value);
 
@@ -171,29 +180,41 @@ const DeploymentList = () => {
   };
 
   const filteredDeployments = useMemo(() => {
-    // Only filter out inactive when status filter is "active"
-    if (statusSelectorVal === "active") {
-      return deploymentList
-        .filter((d) => {
-          return d.status !== "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+    const filteredByStatus = deploymentList.filter((d) => {
+      if (["deleted", "inactive"].includes(d.status)) {
+        return false;
+      }
 
-    if (statusSelectorVal === "inactive") {
-      return deploymentList
-        .filter((d) => {
-          return d.status === "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+      if (statusSelectorVal === "all") {
+        return true;
+      }
+
+      if (d.status === statusSelectorVal) {
+        return true;
+      }
 
-    return deploymentList;
-  }, [statusSelectorVal, deploymentList, searchValue]);
+      return false;
+    });
+
+    const filteredBySearch = search<PRDeployment>(
+      filteredByStatus,
+      searchValue,
+      {
+        isCaseSensitive: false,
+        keys: ["gh_pr_name", "gh_repo_name", "gh_repo_owner"],
+      }
+    );
+
+    switch (sortOrder) {
+      case "Newest":
+        return _.sortBy(filteredBySearch, "updated_at").reverse();
+      case "Oldest":
+        return _.sortBy(filteredBySearch, "updated_at");
+      case "Alphabetical":
+      default:
+        return _.sortBy(filteredBySearch, "gh_pr_name");
+    }
+  }, [statusSelectorVal, deploymentList, searchValue, sortOrder]);
 
   const filteredPullRequests = useMemo(() => {
     if (statusSelectorVal !== "inactive") {
@@ -208,41 +229,32 @@ const DeploymentList = () => {
   const renderDeploymentList = () => {
     if (isLoading) {
       return (
-        <Placeholder>
+        <LoadingWrapper>
           <Loading />
-        </Placeholder>
+        </LoadingWrapper>
       );
     }
 
-    if (!deploymentList.length && !pullRequests.length) {
+    if (!deploymentList.length) {
       return (
-        <Placeholder>
-          No preview apps have been found. Open a PR to create a new preview
-          app.
+        <Placeholder height="calc(100vh - 400px)">
+          No preview developments have been found. Open a PR to create a new
+          preview app.
         </Placeholder>
       );
     }
 
-    if (!filteredDeployments.length && !filteredPullRequests.length) {
+    if (!filteredDeployments.length) {
       return (
-        <Placeholder>
-          No preview apps have been found with the given filter.
+        <Placeholder height="calc(100vh - 400px)">
+          No preview developments have been found with the given filter.
         </Placeholder>
       );
     }
 
     return (
       <>
-        {filteredPullRequests.map((pr) => {
-          return (
-            <PullRequestCard
-              key={pr.pr_title}
-              pullRequest={pr}
-              onCreation={handlePreviewEnvironmentManualCreation}
-            />
-          );
-        })}
-        {filteredDeployments.map((d) => {
+        {filteredDeployments.map((d: any) => {
           return (
             <DeploymentCard
               key={d.id}
@@ -257,49 +269,63 @@ const DeploymentList = () => {
     );
   };
 
-  const handleStatusFilterChange = (value: string) => {
-    pushQueryParams({ status_filter: value });
-    setStatusSelectorVal(value);
-  };
-
-  const handleToggleCommentStatus = (currentlyDisabled: boolean) => {
-    api
-      .toggleNewCommentForEnvironment(
-        "<token>",
-        {
-          disable: !currentlyDisabled,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          environment_id: Number(environment_id),
-        }
-      )
-      .then(() => {
-        setNewCommentsDisabled(!currentlyDisabled);
-      });
-  };
+  useEffect(() => {
+    pushQueryParams({ status_filter: statusSelectorVal });
+  }, [statusSelectorVal]);
 
   return (
     <>
-      <PreviewEnvironmentsHeader />
-      <Flex>
-        <BackButton to={"/preview-environments"} className="material-icons">
-          keyboard_backspace
-        </BackButton>
-
-        <Icon
-          src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
-          alt="git repository icon"
-        />
-        <Title>{selectedRepo}</Title>
-
-        <ActionsWrapper>
-          <StyledStatusSelector>
-            <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-              <i className="material-icons">refresh</i>
-            </RefreshButton>
-            <SearchRow>
+      <PorterYAMLErrorsModal
+        errors={expandedPorterYAMLErrors}
+        onClose={() => setExpandedPorterYAMLErrors([])}
+        repo={selectedRepo}
+      />
+
+      <BreadcrumbRow>
+        <Breadcrumb to="/preview-environments">
+          <ArrowIcon src={pullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+        title={
+          <Flex>
+            <StyledLink
+              to={`https://github.com/${selectedRepo}`}
+              target="_blank"
+            >
+              {selectedRepo}
+            </StyledLink>
+            <DynamicLink
+              to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/settings`}
+            >
+              <I className="material-icons">more_vert</I>
+            </DynamicLink>
+          </Flex>
+        }
+        description={`Preview environments for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      {porterYAMLErrors.length > 0 ? (
+        <PorterYAMLBannerWrapper>
+          <Banner type="warning">
+            We found some errors in the porter.yaml file in the default branch.
+            <LinkButton
+              onClick={() => {
+                setExpandedPorterYAMLErrors(porterYAMLErrors);
+              }}
+            >
+              Learn more
+            </LinkButton>
+          </Banner>
+        </PorterYAMLBannerWrapper>
+      ) : null}
+      <FlexRow>
+        <Flex>
+          <SearchRowWrapper>
+            <SearchBarWrapper>
               <i className="material-icons">search</i>
               <SearchInput
                 value={searchValue}
@@ -308,46 +334,44 @@ const DeploymentList = () => {
                 }}
                 placeholder="Search"
               />
-            </SearchRow>
-            <Selector
-              activeValue={statusSelectorVal}
-              setActiveValue={handleStatusFilterChange}
-              options={[
-                {
-                  value: "active",
-                  label: "Active",
-                },
-                {
-                  value: "inactive",
-                  label: "Inactive",
-                },
-              ]}
-              dropdownLabel="Status"
-              width="150px"
-              dropdownWidth="230px"
-              closeOverlay={true}
-            />
-          </StyledStatusSelector>
-        </ActionsWrapper>
-      </Flex>
-      <Flex>
-        <ActionsWrapper>
-          <FlexWrap>
-            <CheckboxRow
-              label="Disable new comments for deployments"
-              checked={newCommentsDisabled}
-              toggle={() => handleToggleCommentStatus(newCommentsDisabled)}
-            />
-            <Div>
-              <DocsHelper
-                disableMargin
-                tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
-                placement="top-end"
-              />
-            </Div>
-          </FlexWrap>
-        </ActionsWrapper>
-      </Flex>
+            </SearchBarWrapper>
+          </SearchRowWrapper>
+          <RadioFilter
+            icon={filterOutline}
+            selected={statusSelectorVal}
+            setSelected={setStatusSelectorVal}
+            options={AvailableStatusFilters.map((filter) => ({
+              value: filter,
+              label: _.startCase(filter),
+            }))}
+            name="Status"
+          />
+        </Flex>
+        <Flex>
+          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+            <i className="material-icons">refresh</i>
+          </RefreshButton>
+          <RadioFilter
+            icon={sort}
+            selected={sortOrder}
+            setSelected={setSortOrder}
+            options={[
+              { label: "Newest", value: "Newest" },
+              { label: "Oldest", value: "Oldest" },
+              { label: "Alphabetical", value: "Alphabetical" },
+            ]}
+            name="Sort"
+          />
+          <CreatePreviewEnvironmentButton
+            disabled={porterYAMLErrors.some(
+              (err) => err === PorterYAMLErrors.FileNotFound
+            )}
+            to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
+          >
+            <i className="material-icons">add</i> New preview deployment
+          </CreatePreviewEnvironmentButton>
+        </Flex>
+      </FlexRow>
       <Container>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
       </Container>
@@ -368,50 +392,89 @@ const mockRequest = () =>
     );
   });
 
-const Flex = styled.div`
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  margin-bottom: -3px;
+  cursor: pointer;
+  width: 30px;
+  border-radius: 40px;
+  height: 30px;
   display: flex;
   align-items: center;
+  justify-content: center;
+  :hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
 `;
 
-const Div = styled.div`
-  margin-bottom: -7px;
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
 `;
 
-const FlexWrap = styled.div`
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  margin-top: 5px;
   display: flex;
-  align-items: center;
+  justify-content: flex-start;
 `;
 
-const BackButton = styled(DynamicLink)`
-  cursor: pointer;
-  font-size: 24px;
-  color: #969fbbaa;
-  padding: 3px;
-  border-radius: 100px;
-  :hover {
-    background: #ffffff11;
-  }
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
 `;
 
-const Icon = styled.img`
-  width: 25px;
-  height: 25px;
-  margin-right: 6px;
-  margin-left: 14px;
+const Wrap = styled.div`
+  z-index: 999;
 `;
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 10px;
-  border-radius: 2px;
-  color: #ffffff;
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
 `;
 
-const ActionsWrapper = styled.div`
+const Flex = styled.div`
   display: flex;
-  margin-left: auto;
+  align-items: center;
 `;
 
 const RefreshButton = styled.button`
@@ -436,26 +499,6 @@ const RefreshButton = styled.button`
   }
 `;
 
-const Placeholder = styled.div`
-  padding: 30px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 40vh;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
 const Container = styled.div`
   margin-top: 33px;
   padding-bottom: 120px;
@@ -467,15 +510,6 @@ const EventsGrid = styled.div`
   grid-template-columns: 1;
 `;
 
-const StyledStatusSelector = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  :not(:first-child) {
-    margin-left: 15px;
-  }
-`;
-
 const SearchInput = styled.input`
   outline: none;
   border: none;
@@ -483,30 +517,88 @@ const SearchInput = styled.input`
   background: none;
   width: 100%;
   color: white;
-  padding: 0;
-  height: 20px;
+  height: 100%;
 `;
 
 const SearchRow = styled.div`
   display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
   align-items: center;
-  padding: 10px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  max-height: 35px;
-  background: #ffffff11;
-  margin-right: 15px;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
 
-  i {
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const CreatePreviewEnvironmentButton = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  min-width: 155px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
     width: 18px;
     height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 20px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
   }
 `;
+
+const PorterYAMLBannerWrapper = styled.div`
+  margin-block: 15px;
+`;

+ 0 - 33
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx

@@ -2,12 +2,10 @@ import React, { useState, useContext } from "react";
 import styled from "styled-components";
 import pr_icon from "assets/pull_request_icon.svg";
 import { PullRequest } from "../types";
-import { integrationList } from "shared/common";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { ActionButton } from "../components/ActionButton";
 import Loading from "components/Loading";
-import DynamicLink from "components/DynamicLink";
 import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 
@@ -193,37 +191,6 @@ const StatusDot = styled.div`
   margin-left: 3px;
 `;
 
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  margin-left: 15px;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 5px;
-`;
-
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const DeploymentTypeIcon = styled(Icon)`
-  width: 20px;
-  margin-right: 10px;
-`;
-
-const RepositoryName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 390px;
-  position: relative;
-  margin-right: 3px;
-`;
-
 const Tooltip = styled.div`
   position: absolute;
   left: 14px;

+ 461 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -0,0 +1,461 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { useParams } from "react-router";
+import { PullRequest } from "../types";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import Helper from "components/form-components/Helper";
+import pr_icon from "assets/pull_request_icon.svg";
+import api from "shared/api";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import { useRouting } from "shared/routing";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import { PlaceHolder } from "brace";
+import Placeholder from "components/Placeholder";
+
+const CreateEnvironment: React.FC = () => {
+  const router = useRouting();
+  const queryClient = useQueryClient();
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const { environment_id, repo_name, repo_owner } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const { isLoading: getPullRequestsLoading, data: pullRequests } = useQuery<
+    PullRequest[]
+  >(
+    ["pullRequests", currentProject.id, currentCluster.id, environment_id],
+    async () => {
+      try {
+        const res = await getPRDeploymentList({
+          projectID: currentProject.id,
+          clusterID: currentCluster.id,
+          environmentID: Number(environment_id),
+        });
+
+        return res.data.pull_requests || [];
+      } catch (err) {
+        setCurrentError(err);
+      }
+    }
+  );
+
+  const [selectedPR, setSelectedPR] = useState<PullRequest>();
+  const [loading, setLoading] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+
+  const selectedRepo = `${repo_owner}/${repo_name}`;
+
+  const handlePRRowItemClick = async (pullRequest: PullRequest) => {
+    setSelectedPR(pullRequest);
+    setLoading(true);
+
+    const res = await validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: Number(environment_id),
+      branch: pullRequest.branch_from,
+    });
+
+    setPorterYAMLErrors(res.data.errors ?? []);
+
+    setLoading(false);
+  };
+
+  const handleCreatePreviewDeployment = async () => {
+    try {
+      await api.createPreviewEnvironmentDeployment("<token>", selectedPR, {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      });
+
+      router.push(
+        `/preview-environments/deployments/${environment_id}/${selectedPR.repo_owner}/${selectedPR.repo_name}?status_filter=all`
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+  };
+
+  const renderPullRequestList = () => {
+    return (
+      <>
+        <Helper>
+          Select an open pull request to preview. Pull requests must contain a{" "}
+          <Code>porter.yaml</Code> file.
+        </Helper>
+        <Br height="10px" />
+        <PullRequestList>
+          {(pullRequests ?? []).map((pullRequest: PullRequest, i: number) => {
+            return (
+              <PullRequestRow
+                onClick={() => {
+                  handlePRRowItemClick(pullRequest);
+                }}
+                isLast={i === pullRequests.length - 1}
+                isSelected={pullRequest === selectedPR}
+              >
+                <PRName>
+                  <PRIcon src={pr_icon} alt="pull request icon" />
+                  <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
+                    {pullRequest.pr_title}
+                  </EllipsisTextWrapper>
+                </PRName>
+
+                <Flex>
+                  <DeploymentImageContainer>
+                    {/* <InfoWrapper>
+                    <LastDeployed>
+                      #{pullRequest.pr_number} last updated xyz
+                    </LastDeployed>
+                  </InfoWrapper>
+                  <SepDot>•</SepDot> */}
+                    <MergeInfoWrapper>
+                      <MergeInfo>
+                        {pullRequest.branch_from}
+                        <i className="material-icons">arrow_forward</i>
+                        {pullRequest.branch_into}
+                      </MergeInfo>
+                    </MergeInfoWrapper>
+                  </DeploymentImageContainer>
+                </Flex>
+              </PullRequestRow>
+            );
+          })}
+        </PullRequestList>
+        {showErrorsModal && selectedPR ? (
+          <PorterYAMLErrorsModal
+            errors={porterYAMLErrors}
+            onClose={() => setShowErrorsModal(false)}
+            repo={selectedPR.repo_owner + "/" + selectedPR.repo_name}
+            branch={selectedPR.branch_from}
+          />
+        ) : null}
+        {selectedPR && porterYAMLErrors.length ? (
+          <ValidationErrorBannerWrapper>
+            <Banner type="warning">
+              We found some errors in the porter.yaml file in the&nbsp;
+              {selectedPR.branch_from}&nbsp;branch. &nbsp;
+              <LearnMoreButton onClick={() => setShowErrorsModal(true)}>
+                Learn more
+              </LearnMoreButton>
+            </Banner>
+          </ValidationErrorBannerWrapper>
+        ) : null}
+        <CreatePreviewDeploymentWrapper>
+          <SubmitButton
+            onClick={handleCreatePreviewDeployment}
+            disabled={loading || !selectedPR || porterYAMLErrors.length > 0}
+          >
+            Create preview deployment
+          </SubmitButton>
+          {selectedPR && porterYAMLErrors.length ? (
+            <RevalidatePorterYAMLSpanWrapper>
+              Please fix your porter.yaml file to continue.{" "}
+              <RevalidateSpan
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+
+                  if (!selectedPR) {
+                    return;
+                  }
+
+                  handlePRRowItemClick(selectedPR);
+                }}
+              >
+                Refresh
+              </RevalidateSpan>
+            </RevalidatePorterYAMLSpanWrapper>
+          ) : null}
+        </CreatePreviewDeploymentWrapper>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environment_id}/${selectedRepo}`}
+        >
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        title="Create a preview deployment"
+        disableLineBreak
+        capitalize={false}
+      />
+      <DarkMatter />
+      {pullRequests?.length ? (
+        renderPullRequestList()
+      ) : (
+        <>
+          <Br height="30px" />
+          <Placeholder height="370px">
+            You do not have any pull requests.
+          </Placeholder>
+        </>
+      )}
+    </>
+  );
+};
+
+export default CreateEnvironment;
+
+const PullRequestList = styled.div`
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  overflow: hidden;
+`;
+
+const PullRequestRow = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "#26292e")};
+  border-bottom: ${(props) => (props.isLast ? "" : "1px solid #494b4f")};
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Code = styled.span`
+  font-family: monospace; ;
+`;
+
+const SepDot = styled.div`
+  color: #aaaabb66;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+  margin-left: 7px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 16px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -15px;
+`;
+
+const Br = styled.div<{ height: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "2px"};
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -10px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const ValidationErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const LearnMoreButton = styled.div`
+  text-decoration: underline;
+  fontweight: bold;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+const CreatePreviewDeploymentWrapper = styled.div`
+  margin-top: 30px;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const RevalidatePorterYAMLSpanWrapper = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const RevalidateSpan = styled.span`
+  color: #aaaabb;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 11 - 12
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -2,11 +2,8 @@ import React, { useContext, useState } from "react";
 import { capitalize } from "shared/string_utils";
 import styled from "styled-components";
 import { Environment } from "../types";
-import Options from "components/OptionsDropdown";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import Modal from "main/home/modals/Modal";
-import InputRow from "components/form-components/InputRow";
 import DynamicLink from "components/DynamicLink";
 import { RepoLink } from "../components/styled";
 
@@ -74,7 +71,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
 
   return (
     <>
-      {showDeleteModal ? (
+      {/* {showDeleteModal ? (
         <Modal
           title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
           width="800px"
@@ -102,7 +99,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             </DeleteButton>
           </ActionWrapper>
         </Modal>
-      ) : null}
+      ) : null} */}
       <EnvironmentCardWrapper
         to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
       >
@@ -116,6 +113,15 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             <RepoLink
               to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
               target="_blank"
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+
+                window.open(
+                  `https://github.com/${git_repo_owner}/${git_repo_name}`,
+                  "_blank"
+                );
+              }}
             >
               <i className="material-icons">open_in_new</i>
               View Repo
@@ -141,13 +147,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             )}
           </Status>
         </DataContainer>
-        <OptionWrapper>
-          <Options.Dropdown expandIcon="more_vert" shrinkIcon="more_vert">
-            <Options.Option onClick={() => setShowDeleteModal(true)}>
-              <i className="material-icons">delete</i> Delete
-            </Options.Option>
-          </Options.Dropdown>
-        </OptionWrapper>
       </EnvironmentCardWrapper>
     </>
   );

+ 484 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -0,0 +1,484 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useParams } from "react-router";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import { Environment, EnvironmentDeploymentMode } from "../types";
+import SaveButton from "components/SaveButton";
+import _ from "lodash";
+import { Context } from "shared/Context";
+import PageNotFound from "components/PageNotFound";
+import Banner from "components/Banner";
+import InputRow from "components/form-components/InputRow";
+import Modal from "main/home/modals/Modal";
+import { useRouting } from "shared/routing";
+import NamespaceAnnotations, {
+  KeyValueType,
+} from "../components/NamespaceAnnotations";
+import BranchFilterSelector from "../components/BranchFilterSelector";
+
+const EnvironmentSettings = () => {
+  const router = useRouting();
+  const [isLoadingBranches, setIsLoadingBranches] = useState<boolean>(false);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [deleteConfirmationPrompt, setDeleteConfirmationPrompt] = useState("");
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [selectedBranches, setSelectedBranches] = useState([]);
+  const [environment, setEnvironment] = useState<Environment>();
+  const [saveStatus, setSaveStatus] = useState("");
+  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [
+    deploymentMode,
+    setDeploymentMode,
+  ] = useState<EnvironmentDeploymentMode>("manual");
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
+  const {
+    environment_id: environmentId,
+    repo_name: repoName,
+    repo_owner: repoOwner,
+  } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const selectedRepo = `${repoOwner}/${repoName}`;
+
+  useEffect(() => {
+    const getPreviewEnvironmentSettings = async () => {
+      const { data: environment } = await api.getEnvironment<Environment>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: parseInt(environmentId),
+        }
+      );
+
+      setEnvironment(environment);
+      setSelectedBranches(environment.git_repo_branches);
+      setNewCommentsDisabled(environment.new_comments_disabled);
+      setDeploymentMode(environment.mode);
+
+      if (environment.namespace_annotations) {
+        const annotations: KeyValueType[] = [];
+
+        Object.keys(environment.namespace_annotations).forEach((k) => {
+          annotations.push({
+            key: k,
+            value: environment.namespace_annotations[k],
+          });
+        });
+
+        setNamespaceAnnotations(annotations);
+      }
+    };
+
+    try {
+      getPreviewEnvironmentSettings();
+    } catch (err) {
+      setCurrentError(err);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!environment) {
+      return;
+    }
+
+    const repoName = environment.git_repo_name;
+    const repoOwner = environment.git_repo_owner;
+    setIsLoadingBranches(true);
+    api
+      .getBranches<string[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          kind: "github",
+          name: repoName,
+          owner: repoOwner,
+          git_repo_id: environment.git_installation_id,
+        }
+      )
+      .then(({ data }) => {
+        setIsLoadingBranches(false);
+        setAvailableBranches(data);
+      })
+      .catch(() => {
+        setIsLoadingBranches(false);
+        setCurrentError(
+          "Couldn't load branches for this repository, using all branches by default."
+        );
+      });
+  }, [environment]);
+
+  const handleSave = async () => {
+    let annotations: Record<string, string> = {};
+
+    setSaveStatus("loading");
+
+    namespaceAnnotations
+      .filter((elem: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are duplicates
+        let numCollisions = self.reduce((n, _elem: KeyValueType) => {
+          return n + (_elem.key === elem.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex((_elem: KeyValueType) => _elem.key === elem.key)
+          );
+        }
+      })
+      .forEach((elem: KeyValueType) => {
+        if (elem.key !== "" && elem.value !== "") {
+          annotations[elem.key] = elem.value;
+        }
+      });
+
+    try {
+      await api.updateEnvironment(
+        "<token>",
+        {
+          mode: deploymentMode,
+          disable_new_comments: newCommentsDisabled,
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: Number(environmentId),
+        }
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+
+    setSaveStatus("");
+  };
+
+  const closeDeleteConfirmationModal = () => {
+    setShowDeleteModal(false);
+    setDeleteConfirmationPrompt("");
+  };
+
+  const canDelete = useMemo(() => {
+    return deleteConfirmationPrompt === `${repoOwner}/${repoName}`;
+  }, [deleteConfirmationPrompt]);
+
+  const handleDelete = async () => {
+    if (!canDelete) {
+      return;
+    }
+
+    try {
+      await api.deleteEnvironment(
+        "<token>",
+        {
+          name: environment?.name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: environment?.git_installation_id,
+          git_repo_owner: repoOwner,
+          git_repo_name: repoName,
+        }
+      );
+
+      closeDeleteConfirmationModal();
+      router.push(`/preview-environments`);
+    } catch (err) {
+      setCurrentError(JSON.stringify(err));
+      closeDeleteConfirmationModal();
+    }
+  };
+
+  return (
+    <>
+      {showDeleteModal ? (
+        <DeletePreviewEnvironmentModal
+          repoOwner={repoOwner}
+          repoName={repoName}
+          onClose={closeDeleteConfirmationModal}
+          prompt={deleteConfirmationPrompt}
+          setPrompt={setDeleteConfirmationPrompt}
+          onDelete={handleDelete}
+          disabled={!canDelete}
+        />
+      ) : null}
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environmentId}/${selectedRepo}`}
+        >
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview environment settings"
+        description={`Preview environment settings for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      <WarningBannerWrapper>
+        <Banner type="warning">
+          Changes made here will not affect existing deployments in this preview
+          environment.
+        </Banner>
+      </WarningBannerWrapper>
+      <StyledPlaceholder>
+        <Heading isAtTop>Pull request comment settings</Heading>
+        <Helper>
+          Update the most recent PR comment on every deploy. If disabled, a new
+          PR comment is made per deploy.
+        </Helper>
+        <CheckboxRow
+          label="Update the most recent PR comment"
+          checked={newCommentsDisabled}
+          toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
+        />
+        <Br />
+        <Heading>Automatic preview deployments</Heading>
+        <Helper>
+          When enabled, preview deployments are automatically created for all
+          new pull requests.
+        </Helper>
+        <CheckboxRow
+          label="Automatically create preview deployments"
+          checked={deploymentMode === "auto"}
+          toggle={() =>
+            setDeploymentMode((deploymentMode) =>
+              deploymentMode === "auto" ? "manual" : "auto"
+            )
+          }
+        />
+        <Br />
+        <Heading>Select allowed branches</Heading>
+        <Helper>
+          If the pull request has a base branch included in this list, it will
+          be allowed to be deployed.
+          <br />
+          (Leave empty to allow all branches)
+        </Helper>
+        <BranchFilterSelector
+          onChange={setSelectedBranches}
+          options={availableBranches}
+          value={selectedBranches}
+          showLoading={isLoadingBranches}
+        />
+        <Br />
+        <Heading>Namespace annotations</Heading>
+        <Helper>
+          Custom annotations to be injected into the Kubernetes namespace
+          created for each deployment.
+        </Helper>
+        <NamespaceAnnotations
+          values={namespaceAnnotations}
+          setValues={(x: KeyValueType[]) => {
+            let annotations: KeyValueType[] = [];
+            x.forEach((entry) => {
+              annotations.push({ key: entry.key, value: entry.value });
+            });
+            setNamespaceAnnotations(annotations);
+          }}
+        />
+        <SavePreviewEnvironmentSettings
+          text={"Save"}
+          status={saveStatus}
+          clearPosition={true}
+          statusPosition={"right"}
+          onClick={handleSave}
+        />
+        <Br />
+        <Heading>Delete preview environment</Heading>
+        <Helper>
+          Delete the Porter preview environment integration for this repo. All
+          preview deployments will also be destroyed.
+        </Helper>
+        <DeleteButton
+          disabled={saveStatus === "loading"}
+          onClick={() => {
+            setShowDeleteModal(true);
+          }}
+        >
+          Delete preview environment
+        </DeleteButton>
+      </StyledPlaceholder>
+    </>
+  );
+};
+
+interface DeletePreviewEnvironmentModalProps {
+  repoName: string;
+  repoOwner: string;
+  prompt: string;
+  setPrompt: (prompt: string) => void;
+  onDelete: () => void;
+  onClose: () => void;
+  disabled: boolean;
+}
+
+const DeletePreviewEnvironmentModal = (
+  props: DeletePreviewEnvironmentModalProps
+) => {
+  return (
+    <Modal
+      height="fit-content"
+      title={`Remove Preview Envs for ${props.repoOwner}/${props.repoName}`}
+      onRequestClose={props.onClose}
+    >
+      <DeletePreviewEnvironmentModalContentsWrapper>
+        <Banner type="warning">
+          All Preview Environment deployments associated with this repo will be
+          deleted.
+        </Banner>
+        <InputRow
+          type="text"
+          label={`Enter ${props.repoOwner}/${props.repoName} to delete Preview Environments:`}
+          value={props.prompt}
+          placeholder={`${props.repoOwner}/${props.repoName}`}
+          setValue={(x: string) => props.setPrompt(x)}
+          width={"500px"}
+        />
+        <Flex justifyContent="center" alignItems="center">
+          <DeleteButton
+            onClick={() => props.onDelete()}
+            disabled={props.disabled}
+          >
+            Delete
+          </DeleteButton>
+        </Flex>
+      </DeletePreviewEnvironmentModalContentsWrapper>
+    </Modal>
+  );
+};
+
+export default EnvironmentSettings;
+
+const DeletePreviewEnvironmentModalContentsWrapper = styled.div`
+  margin-block-start: 25px;
+`;
+
+const SavePreviewEnvironmentSettings = styled(SaveButton)`
+  margin-top: 30px;
+`;
+
+const Flex = styled.div<{
+  justifyContent?: string;
+  alignItems?: string;
+}>`
+  display: flex;
+  align-items: ${({ alignItems }) => alignItems || "flex-start"};
+  justify-content: ${({ justifyContent }) => justifyContent || "flex-start"};
+`;
+
+const DeleteButton = styled.button<{ disabled?: boolean }>`
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 10px 15px;
+  margin-top: 20px;
+  text-align: left;
+  border-radius: 5px;
+  user-select: none;
+  background: #b91133;
+  border: none;
+  cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")};
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
+
+  &:focus {
+    outline: 0;
+  }
+  &:hover {
+    filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
+  }
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledPlaceholder = styled.div`
+  width: 100%;
+  padding: 30px;
+  font-size: 13px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -5px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const WarningBannerWrapper = styled.div`
+  margin-block: 20px;
+`;

+ 31 - 51
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -8,6 +8,7 @@ import ButtonEnablePREnvironments from "../components/ButtonEnablePREnvironments
 import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
+import Placeholder from "components/Placeholder";
 
 const EnvironmentsList = () => {
   const { currentCluster, currentProject } = useContext(Context);
@@ -80,29 +81,34 @@ const EnvironmentsList = () => {
     <>
       <PreviewEnvironmentsHeader />
       <Relative>
-        {isLoading || !buttonIsReady ? (
-          <FloatingPlaceholder>
-            <Loading />
-          </FloatingPlaceholder>
-        ) : null}
-
         <ControlRow>
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
         </ControlRow>
-        {environments.length === 0 ? (
-          <Placeholder>
-            No repositories found with Preview Environments enabled.
-          </Placeholder>
+        {isLoading ? (
+          <LoadingWrapper>
+            <Loading />
+          </LoadingWrapper>
         ) : (
-          <EnvironmentsGrid>
-            {environments.map((env) => (
-              <EnvironmentCard
-                key={env.id}
-                environment={env}
-                onDelete={removeEnvironmentFromList}
-              />
-            ))}
-          </EnvironmentsGrid>
+          <>
+            {environments.length === 0 ? (
+              <Placeholder
+                title="No repositories found"
+                height="calc(100vh - 400px)"
+              >
+                No repositories were found with Preview Environments enabled.
+              </Placeholder>
+            ) : (
+              <EnvironmentsGrid>
+                {environments.map((env) => (
+                  <EnvironmentCard
+                    key={env.id}
+                    environment={env}
+                    onDelete={removeEnvironmentFromList}
+                  />
+                ))}
+              </EnvironmentsGrid>
+            )}
+          </>
         )}
       </Relative>
     </>
@@ -111,44 +117,18 @@ const EnvironmentsList = () => {
 
 export default EnvironmentsList;
 
-const Relative = styled.div`
-  position: relative;
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
 `;
 
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const FloatingPlaceholder = styled(Placeholder)`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  margin-top: 0px;
+const Relative = styled.div`
+  position: relative;
 `;
 
 const EnvironmentsGrid = styled.div`
-  margin-top: 32px;
   padding-bottom: 150px;
   display: grid;
-  grid-row-gap: 25px;
+  grid-row-gap: 15px;
 `;
 
 const ControlRow = styled.div`
@@ -156,7 +136,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   justify-content: space-between;
   align-items: center;
-  margin: 35px 0;
+  margin: 35px 0 30px;
   padding-left: 0px;
 `;
 

+ 3 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/errors.ts

@@ -0,0 +1,3 @@
+export enum PorterYAMLErrors {
+  FileNotFound = "porter.yaml does not exist in the root of this repository",
+}

+ 13 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -5,6 +5,8 @@ import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
 import EnvironmentsList from "./environments/EnvironmentsList";
+import EnvironmentSettings from "./environments/EnvironmentSettings";
+import DeployEnvironment from "./environments/CreateEnvironment";
 
 export const Routes = () => {
   const { path } = useRouteMatch();
@@ -20,9 +22,19 @@ export const Routes = () => {
         <Route path={`${path}/connect-repo`}>
           <ConnectNewRepo />
         </Route>
-        <Route path={`${path}/details/:namespace?`}>
+        <Route path={`${path}/details/:id`}>
           <DeploymentDetail />
         </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/settings`}
+        >
+          <EnvironmentSettings />
+        </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/create`}
+        >
+          <DeployEnvironment />
+        </Route>
         <Route
           path={`${path}/deployments/:environment_id/:repo_owner/:repo_name`}
         >

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts

@@ -29,6 +29,8 @@ export type PRDeployment = {
   gh_pr_branch_into?: string;
 };
 
+export type EnvironmentDeploymentMode = "manual" | "auto";
+
 export type Environment = {
   id: number;
   project_id: number;
@@ -37,9 +39,12 @@ export type Environment = {
   name: string;
   git_repo_owner: string;
   git_repo_name: string;
+  git_repo_branches: string[];
+  new_comments_disabled: boolean;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
-  mode: "manual" | "auto";
+  mode: EnvironmentDeploymentMode;
+  namespace_annotations: Record<string, string>;
 };
 
 export type PullRequest = {

+ 50 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts

@@ -0,0 +1,50 @@
+import api from "shared/api";
+
+interface ValidatePorterYAMLProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+  branch?: string;
+}
+
+export const validatePorterYAML = ({
+  projectID,
+  clusterID,
+  environmentID,
+  branch,
+}: ValidatePorterYAMLProps) => {
+  return api.validatePorterYAML(
+    "<token>",
+    {
+      ...(branch ? { branch } : {}),
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+      environment_id: environmentID,
+    }
+  );
+};
+
+interface GetPRDeploymentListProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+}
+
+export const getPRDeploymentList = ({
+  clusterID,
+  projectID,
+  environmentID,
+}: GetPRDeploymentListProps) => {
+  return api.getPRDeploymentList(
+    "<token>",
+    {
+      environment_id: environmentID,
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+    }
+  );
+};

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

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
 import React, { useContext, useState } from "react";

+ 6 - 3
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -1,15 +1,18 @@
-import React, { useEffect, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { BackButton, Card } from "../../launch/components/styles";
 import DynamicLink from "components/DynamicLink";
 import { VersionSelector } from "../../launch/components/VersionSelector";
 import TitleSection from "components/TitleSection";
+import { Context } from "shared/Context";
 
 const TemplateSelector = () => {
+  const { capabilities } = useContext(Context);
+
   const [templates, setTemplates] = useState<PorterTemplate[]>([]);
   const [selectedVersion, setSelectedVersion] = useState<{
     [template_name: string]: string;
@@ -23,7 +26,7 @@ const TemplateSelector = () => {
       const res = await api.getTemplates<PorterTemplate[]>(
         "<token>",
         {
-          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+          repo_url: capabilities?.default_app_helm_repo_url,
         },
         {}
       );

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import React, { createContext, useContext, useEffect, useState } from "react";
 import { useParams } from "react-router";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -5,7 +5,7 @@ import { Card } from "../../launch/components/styles";
 import { Stack } from "../../types";
 import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Loading from "components/Loading";
 import { useRouteMatch } from "react-router";
 

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

@@ -3,7 +3,7 @@ import Loading from "components/Loading";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import styled from "styled-components";
 import { Stack } from "./types";
 import { readableDate } from "shared/string_utils";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -156,7 +156,7 @@ const Overview = () => {
         Env Groups
         {/* <InlineDocsHelper
           disableMargin={true}
-          tooltipText="Environment Groups"
+          tooltipText="Environment groups"
           link="https://docs.porter.run/deploying-applications/environment-groups"
         /> */}
       </Heading>

+ 4 - 2
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
@@ -8,8 +8,10 @@ import { VersionSelector } from "./VersionSelector";
 import DynamicLink from "components/DynamicLink";
 
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 export const AddResourceButton = () => {
+  const { capabilities } = useContext(Context);
   const [templates, setTemplates] = useState<PorterTemplate[]>([]);
   const [currentTemplate, setCurrentTemplate] = useState<PorterTemplate>();
   const [currentVersion, setCurrentVersion] = useState("");
@@ -19,7 +21,7 @@ export const AddResourceButton = () => {
       const res = await api.getTemplates<PorterTemplate[]>(
         "<token>",
         {
-          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+          repo_url: capabilities?.default_app_helm_repo_url,
         },
         {}
       );

+ 17 - 10
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -13,11 +13,11 @@ import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
-import Banner from "components/Banner";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
+import Banner from "components/Banner";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -225,19 +225,26 @@ const Br = styled.div`
   height: 1px;
 `;
 
-const Code = styled.div`
-  font-family: monospace;
-  margin: 0 7px;
-`;
-
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
 
-const A = styled.a`
-  margin-left: 10px;
-  color: #8590ff;
-`;
+// const Banner = styled.div<{ color: string }>`
+//   height: 40px;
+//   width: 100%;
+//   margin: 5px 0 30px;
+//   font-size: 13px;
+//   display: flex;
+//   border-radius: 5px;
+//   padding-left: 15px;
+//   align-items: center;
+//   background: #ffffff11;
+//   color: ${(props) => props.color};
+//   > i {
+//     margin-right: 10px;
+//     font-size: 18px;
+//   }
+// `;
 
 const TopRow = styled.div`
   display: flex;

+ 1 - 1
dashboard/src/main/home/infrastructure/ExpandedInfra.tsx

@@ -10,7 +10,7 @@ import DeployList from "./components/DeployList";
 import InfraResourceList from "./components/InfraResourceList";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Header from "components/expanded-object/Header";
 import { Infrastructure, KindMap, Operation } from "shared/types";
 import InfraSettings from "./components/InfraSettings";

+ 2 - 2
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -7,7 +7,7 @@ import { pushFiltered } from "shared/routing";
 
 import { Column } from "react-table";
 import styled from "styled-components";
-import Table from "components/Table";
+import Table from "components/OldTable";
 
 import Loading from "components/Loading";
 
@@ -15,7 +15,7 @@ import _ from "lodash";
 import { integrationList } from "shared/common";
 import { Infrastructure, KindMap } from "shared/types";
 import { capitalize, readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import SaveButton from "components/SaveButton";
 import { useRouting } from "shared/routing";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/DeployList.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import ExpandedOperation from "./ExpandedOperation";
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx

@@ -4,7 +4,7 @@ import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import { TFState } from "shared/types";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   infra_id: number;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx

@@ -8,7 +8,7 @@ import Loading from "components/Loading";
 import TitleSection from "components/TitleSection";
 
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialsList from "./credentials/AWSCredentialList";
 import Heading from "components/form-components/Heading";
 import GCPCredentialsList from "./credentials/GCPCredentialList";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx

@@ -9,7 +9,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import { Operation, OperationStatus, OperationType } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialForm from "./AWSCredentialForm";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;

Some files were not shown because too many files changed in this diff