Răsfoiți Sursa

Merge branch 'preview-env-v2-fe' into dev

Mohammed Nafees 3 ani în urmă
părinte
comite
4a52083d72
40 a modificat fișierele cu 1265 adăugiri și 1135 ștergeri
  1. 1 1
      api/server/handlers/cluster/create_namespace.go
  2. 59 0
      api/server/handlers/environment/common.go
  3. 3 6
      api/server/handlers/environment/delete_deployment.go
  4. 22 1
      api/server/handlers/environment/enable_pull_request.go
  5. 5 40
      api/server/handlers/environment/get_deployment.go
  6. 5 41
      api/server/handlers/environment/get_deployment_by_env.go
  7. 1 1
      api/server/handlers/environment/list_deployments_by_cluster.go
  8. 16 0
      api/server/handlers/environment/update_environment_settings.go
  9. 2 5
      api/server/handlers/webhook/github_incoming.go
  10. 20 8
      api/types/environment.go
  11. 0 28
      cmd/migrate/main.go
  12. 3 0
      cmd/migrate/startup_migrations/global_map.go
  13. 3 3
      dashboard/package-lock.json
  14. 1 1
      dashboard/package.json
  15. 20 4
      dashboard/src/components/Placeholder.tsx
  16. 16 2
      dashboard/src/components/TitleSection.tsx
  17. 3 23
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  18. 0 214
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  19. 8 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  20. 65 10
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  21. 30 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx
  22. 164 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/NamespaceAnnotations.tsx
  23. 98 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PorterYAMLErrorsModal.tsx
  24. 17 20
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  25. 171 51
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  26. 65 211
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  27. 0 33
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  28. 135 234
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx
  29. 9 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  30. 254 89
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx
  31. 2 39
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  32. 3 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/errors.ts
  33. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  34. 2 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  35. 22 27
      dashboard/src/main/home/dashboard/Dashboard.tsx
  36. 2 2
      dashboard/src/main/home/navbar/Feedback.tsx
  37. 3 2
      dashboard/src/main/home/navbar/Help.tsx
  38. 3 3
      dashboard/src/main/home/navbar/Navbar.tsx
  39. 28 25
      dashboard/src/shared/api.tsx
  40. 3 0
      dashboard/src/shared/routing.tsx

+ 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, nil)
+	namespace, err := agent.CreateNamespace(request.Name, request.Annotations)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

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

@@ -9,8 +9,12 @@ import (
 
 	"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 (
@@ -65,3 +69,58 @@ func isGithubPRClosed(
 
 	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
+}

+ 3 - 6
api/server/handlers/environment/delete_deployment.go

@@ -50,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
 		}
 
@@ -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,10 +90,7 @@ 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) {

+ 22 - 1
api/server/handlers/environment/enable_pull_request.go

@@ -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 {
@@ -118,7 +139,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
-		Namespace:     "namespace-creating",
+		Namespace:     "",
 		Status:        types.DeploymentStatusCreating,
 		PullRequestID: request.Number,
 		RepoOwner:     request.RepoOwner,

+ 5 - 40
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"
@@ -47,15 +46,6 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		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)
 
@@ -67,37 +57,12 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	var depl *models.Deployment
-
-	// read the deployment
-	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
-		}
-	}
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	if depl == nil {
-		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

+ 5 - 41
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,15 +45,6 @@ func (c *GetDeploymentByEnvironmentHandler) 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
-	}
-
-	var err error
-
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 
 	if err != nil {
@@ -67,38 +57,12 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	var depl *models.Deployment
-
-	// read the deployment
-	if request.PRNumber != 0 {
-		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, env.GitRepoOwner, env.GitRepoName,
-			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
-		}
-	}
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	if depl == nil {
-		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

+ 1 - 1
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -276,7 +276,7 @@ func fetchOpenPullRequests(
 
 	for _, pr := range openPRs {
 		if len(branchesMap) > 0 {
-			if _, ok := branchesMap[pr.GetHead().GetRef()]; !ok {
+			if _, ok := branchesMap[pr.GetBase().GetRef()]; !ok {
 				continue
 			}
 		}

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

@@ -89,6 +89,22 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 		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)
 

+ 2 - 5
api/server/handlers/webhook/github_incoming.go

@@ -116,7 +116,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	if env.Mode == "auto" && event.GetAction() == "opened" {
 		depl := &models.Deployment{
 			EnvironmentID: env.ID,
-			Namespace:     "namespace-creating",
+			Namespace:     "",
 			Status:        types.DeploymentStatusCreating,
 			PullRequestID: uint(event.GetPullRequest().GetNumber()),
 			PRName:        event.GetPullRequest().GetTitle(),
@@ -310,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",

+ 20 - 8
api/types/environment.go

@@ -81,17 +81,21 @@ type SuccessfullyDeployedResource struct {
 }
 
 type FinalizeDeploymentRequest struct {
-	Namespace           string                          `json:"namespace"`
 	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"`
 	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 {
@@ -99,8 +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"`
 	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type ListDeploymentRequest struct {
@@ -112,8 +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"`
 	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type DeleteDeploymentRequest struct {
@@ -121,8 +129,11 @@ type DeleteDeploymentRequest struct {
 }
 
 type GetDeploymentRequest struct {
+	DeploymentID uint `schema:"id"`
+	PRNumber     uint `schema:"pr_number"`
+
+	// legacy usage for backwards compatibility
 	Namespace string `schema:"namespace"`
-	PRNumber  uint   `schema:"pr_number"`
 }
 
 type PullRequest struct {
@@ -149,7 +160,8 @@ type ValidatePorterYAMLResponse struct {
 }
 
 type UpdateEnvironmentSettingsRequest struct {
-	Mode               string   `json:"mode" form:"oneof=auto manual"`
-	DisableNewComments bool     `json:"disable_new_comments"`
-	GitRepoBranches    []string `json:"git_repo_branches"`
+	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"`
 }

+ 0 - 28
cmd/migrate/main.go

@@ -5,7 +5,6 @@ import (
 	"log"
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
-	"github.com/porter-dev/porter/cmd/migrate/enable_cluster_preview_envs"
 	"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"
@@ -108,14 +107,6 @@ func main() {
 		}
 	}
 
-	if shouldEnableClusterPreviewEnvs() {
-		err := enable_cluster_preview_envs.EnableClusterPreviewEnvs(db, logger)
-
-		if err != nil {
-			logger.Fatal().Err(err).Msg("failed to enable cluster preview envs")
-		}
-	}
-
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 		logger.Fatal().Err(err).Msg("vault migration failed")
 	}
@@ -157,22 +148,3 @@ func shouldPopulateSourceConfigDisplayName() bool {
 
 	return c.PopulateSourceConfigDisplayName
 }
-
-type EnableClusterPreviewEnvsConf struct {
-	// we add a dummy field to avoid empty struct issue with envdecode
-	DummyField string `env:"ASDF,default=asdf"`
-
-	// if true, will mark all clusters to have preview envs enabled whose parent project has it enabled
-	EnableClusterPreviewEnvs bool `env:"ENABLE_CLUSTER_PREVIEW_ENVS"`
-}
-
-func shouldEnableClusterPreviewEnvs() bool {
-	var c EnableClusterPreviewEnvsConf
-
-	if err := envdecode.StrictDecode(&c); err != nil {
-		log.Fatalf("Failed to decode migration conf: %s", err)
-		return false
-	}
-
-	return c.EnableClusterPreviewEnvs
-}

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

@@ -1,11 +1,13 @@
 package startup_migrations
 
 import (
+	"github.com/porter-dev/porter/cmd/migrate/enable_cluster_preview_envs"
 	"github.com/porter-dev/porter/cmd/migrate/migrate_legacy_rbac"
 	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
@@ -14,4 +16,5 @@ var StartupMigrations = make(map[uint]migrationFunc)
 
 func init() {
 	StartupMigrations[1] = migrate_legacy_rbac.MigrateFromLegacyRBAC
+	StartupMigrations[1] = enable_cluster_preview_envs.EnableClusterPreviewEnvs
 }

+ 3 - 3
dashboard/package-lock.json

@@ -1549,9 +1549,9 @@
       }
     },
     "@tanstack/react-query-devtools": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.13.0.tgz",
-      "integrity": "sha512-Gv33auVlcUMrnj5qXJuhhTJtBs8bKp7v3TFRysnS+VlLOmdj9gwl5bc0e0/URL7m+PQoR1Rr55yzQ5bmZcWm1g==",
+      "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",

+ 1 - 1
dashboard/package.json

@@ -10,7 +10,7 @@
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",
-    "@tanstack/react-query-devtools": "^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",

+ 20 - 4
dashboard/src/components/Placeholder.tsx

@@ -16,20 +16,29 @@ const Placeholder: React.FC<Props> = ({
 }) => {
   return (
     <StyledPlaceholder height={height} minHeight={minHeight}>
-      <div>
+      <Wrapper>
         <Title>{title}</Title>
-        {children}
-      </div>
+        <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;
-  margin-bottom: 10px;
   font-weight: 500;
 `;
 
@@ -50,4 +59,11 @@ const StyledPlaceholder = styled.div<{
   background: #26292e;
   border: 1px solid #494b4f;
   padding-bottom: 60px;
+
+  > div {
+    > i {
+      font-size: 16px;
+      margin-right: 12px;
+    }
+  }
 `;

+ 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;

+ 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;
 `;

+ 0 - 214
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -1,214 +0,0 @@
-import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import styled from "styled-components";
-import IncidentsTable from "./IncidentsTable";
-
-export type DetectAgentResponse = {
-  version: string;
-};
-
-const IncidentsTab = () => {
-  const { currentProject, currentCluster } = useContext(Context);
-  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
-  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    api
-      .detectPorterAgent<DetectAgentResponse>(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => res.data)
-      .then((data) => {
-        if (data.version === "v1") {
-          setIsAgentInstalled(true);
-          setIsAgentOutdated(true);
-        } else {
-          setIsAgentInstalled(true);
-          setIsAgentOutdated(false);
-        }
-      })
-      .catch(() => {
-        setIsAgentInstalled(false);
-      })
-      .finally(() => {
-        setIsLoading(false);
-      });
-  }, []);
-
-  const upgradeAgent = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-    try {
-      await api.upgradePorterAgent(
-        "<token>",
-        {},
-        {
-          project_id,
-          cluster_id,
-        }
-      );
-      setIsAgentOutdated(false);
-    } catch (err) {
-      setIsAgentOutdated(true);
-    }
-  };
-
-  const installAgent = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-
-    api
-      .installPorterAgent("<token>", {}, { project_id, cluster_id })
-      .then(() => {
-        setIsAgentInstalled(true);
-      })
-      .catch(() => {
-        setIsAgentInstalled(false);
-      });
-  };
-
-  const triggerInstall = () => {
-    if (isAgentOutdated) {
-      upgradeAgent();
-      return;
-    }
-
-    installAgent();
-  };
-
-  if (isLoading) {
-    return (
-      <StyledCard>
-        <Loading height="200px" />
-      </StyledCard>
-    );
-  }
-
-  if (!isAgentInstalled || isAgentOutdated) {
-    return (
-      <Placeholder>
-        <AgentButtonContainer>
-          <Header>Incident detection is not enabled on this cluster.</Header>
-          <Subheader>
-            In order to view incidents, you must enable incident detection on
-            this cluster.
-          </Subheader>
-          <InstallPorterAgentButton onClick={() => triggerInstall()}>
-            <i className="material-icons">add</i> Enable Incident Detection
-          </InstallPorterAgentButton>
-        </AgentButtonContainer>
-      </Placeholder>
-    );
-  }
-
-  return (
-    <StyledCard>
-      <IncidentsTable />
-    </StyledCard>
-  );
-};
-
-export default IncidentsTab;
-
-const StyledCard = styled.div`
-  margin-top: 35px;
-  background: #26282f;
-  padding: 14px;
-  border-radius: 8px;
-  box-shadow: 0 4px 15px 0px #00000055;
-  position: relative;
-  border: 2px solid #9eb4ff00;
-  width: 100%;
-  :not(:last-child) {
-    margin-bottom: 25px;
-  }
-`;
-
-const InstallPorterAgentButton = styled.button`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  width: 200px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border: none;
-  border-radius: 5px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-top: 20px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#5561C0"};
-  :hover {
-    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
-  }
-  > 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 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;
-  display: flex;
-  align-items: left;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const AgentButtonContainer = styled.div`
-  display: flex;
-  align-items: left;
-  justify-content: center;
-  flex-direction: column;
-  width: 500px;
-  margin: 0 auto;
-`;
-
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-`;
-
-const Subheader = styled.div``;

+ 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>
     );

+ 65 - 10
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -15,6 +15,9 @@ 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(
@@ -46,6 +49,11 @@ const ConnectNewRepo: React.FC = () => {
   // Disable new comments data
   const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
 
+  // Namespace annotations
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
+
   useEffect(() => {
     api
       .listEnvironments<Environment[]>(
@@ -109,7 +117,32 @@ const ConnectNewRepo: React.FC = () => {
 
   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>",
@@ -118,6 +151,7 @@ const ConnectNewRepo: React.FC = () => {
           mode: enableAutomaticDeployments ? "auto" : "manual",
           disable_new_comments: isNewCommentsDisabled,
           git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         {
           project_id: currentProject.id,
@@ -152,7 +186,21 @@ 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>
@@ -192,10 +240,6 @@ const ConnectNewRepo: React.FC = () => {
             disableMargin: true,
           }}
         />
-        <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."
-        />
       </CheckboxWrapper>
 
       <Heading>Disable new comments for new deployments</Heading>
@@ -212,11 +256,6 @@ const ConnectNewRepo: React.FC = () => {
             disableMargin: true,
           }}
         />
-        <DocsHelper
-          disableMargin
-          tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
-          placement="top-end"
-        />
       </CheckboxWrapper>
 
       <Heading>Select allowed branches</Heading>
@@ -233,6 +272,22 @@ const ConnectNewRepo: React.FC = () => {
         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
           text="Add repository"

+ 30 - 8
dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx

@@ -53,16 +53,38 @@ const BranchFilterSelector = ({
         showLoading={showLoading}
       />
       {/* List selected branches  */}
-      <ul>
-        {value.map((branch) => (
-          <li key={branch}>
-            {branch}
-            <button onClick={() => handleDeleteBranch(branch)}>Remove</button>
-          </li>
-        ))}
-      </ul>
+
+      <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;
+`;

+ 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;

+ 17 - 20
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -174,7 +174,7 @@ const DeploymentCard: React.FC<{
 
   const DeploymentCardActions = [
     {
-      active: deployment.last_workflow_run_url,
+      active: !!deployment.last_workflow_run_url,
       label: "View last workflow",
       action: (e: React.MouseEvent) => {
         e.preventDefault();
@@ -195,7 +195,7 @@ const DeploymentCard: React.FC<{
 
   return (
     <DeploymentCardWrapper
-      to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+      to={`/preview-environments/details/${deployment.id}?environment_id=${deployment.environment_id}`}
     >
       <DataContainer>
         <PRName>
@@ -234,12 +234,6 @@ const DeploymentCard: React.FC<{
               )}
             </MergeInfoWrapper>
           ) : null}
-          {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>
@@ -279,18 +273,21 @@ const DeploymentCard: React.FC<{
 
             {deployment.status !== DeploymentStatus.Creating && (
               <>
-                <RowButton
-                  onClick={(e) => {
-                    e.preventDefault();
-                    e.stopPropagation();
-
-                    window.open(deployment.subdomain, "_blank");
-                  }}
-                  key={deployment.subdomain}
-                >
-                  <i className="material-icons">open_in_new</i>
-                  View Live
-                </RowButton>
+                {deployment.subdomain &&
+                deployment.status === DeploymentStatus.Created ? (
+                  <RowButton
+                    onClick={(e) => {
+                      e.preventDefault();
+                      e.stopPropagation();
+
+                      window.open(deployment.subdomain, "_blank");
+                    }}
+                    key={deployment.subdomain}
+                  >
+                    <i className="material-icons">open_in_new</i>
+                    View Live
+                  </RowButton>
+                ) : null}
                 <DeploymentCardActionsDropdown
                   options={DeploymentCardActions}
                 />

+ 171 - 51
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -5,6 +5,7 @@ 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 PullRequestIcon from "assets/pull_request_icon.svg";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -12,13 +13,14 @@ 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("");
@@ -38,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,
@@ -53,7 +55,6 @@ const DeploymentDetail = () => {
         if (!isSubscribed) {
           return;
         }
-
         setPRDeployment(data);
       })
       .catch((err) => {
@@ -64,11 +65,11 @@ const DeploymentDetail = () => {
       });
   }, [params]);
 
-  if (!prDeployment) {
-    return <Loading />;
-  }
-
   useEffect(() => {
+    if (!prDeployment) {
+      return;
+    }
+
     const isSubscribed = true;
     const environment_id = parseInt(searchParams.get("environment_id"));
 
@@ -91,13 +92,103 @@ const DeploymentDetail = () => {
           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 && prDeployment.status === "creating") {
+    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 (
     <>
-      {expandedPorterYAMLErrors.length && (
+      {expandedPorterYAMLErrors.length > 0 && (
         <Modal
           onRequestClose={() => setExpandedPorterYAMLErrors([])}
           height="auto"
@@ -114,17 +205,31 @@ const DeploymentDetail = () => {
           </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}`}
+        >
+          <GitIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{repository}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
       <StyledExpandedChart>
-        <BreadcrumbRow>
-          <Breadcrumb
-            to={`/preview-environments/deployments/${environmentId}/${repository}`}
-          >
-            <ArrowIcon src={leftArrow} />
-            <Wrap>Back</Wrap>
-          </Breadcrumb>
-        </BreadcrumbRow>
         <HeaderWrapper>
-          <Title icon={pr_icon} iconWidth="25px">
+          <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>
@@ -135,7 +240,7 @@ const DeploymentDetail = () => {
               </PRLink>
             )}
             <TagWrapper>
-              Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+              Namespace <NamespaceTag>{prDeployment.namespace}</NamespaceTag>
             </TagWrapper>
           </InfoWrapper>
           <Flex>
@@ -159,19 +264,9 @@ const DeploymentDetail = () => {
               {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
             </DeploymentImageContainer>
             <Dot>•</Dot>
-            <GHALink
-              to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
-              target="_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
+                <img src={github} /> View last workflow run
                 <i className="material-icons">open_in_new</i>
               </GHALink>
             ) : null}
@@ -179,23 +274,26 @@ const DeploymentDetail = () => {
           <LinkToActionsWrapper></LinkToActionsWrapper>
         </HeaderWrapper>
         {porterYAMLErrors.length > 0 ? (
-          <Banner type="error">
-            Your porter.yaml file has errors. Please fix them before deploying.
-            <LinkButton
-              onClick={() => {
-                setExpandedPorterYAMLErrors(porterYAMLErrors);
-              }}
-            >
-              View details
-            </LinkButton>
-          </Banner>
+          <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={params.namespace}
+            namespace={prDeployment.namespace}
             disableBottomPadding
             closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
           />
@@ -207,6 +305,15 @@ const DeploymentDetail = () => {
 
 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;
@@ -232,16 +339,17 @@ const Message = styled.div`
 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;
@@ -271,7 +379,6 @@ const GHALink = styled(DynamicLink)`
   align-items: center;
 
   :hover {
-    text-decoration: underline;
     color: white;
   }
 
@@ -280,10 +387,7 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-left: 5px;
 
-    text-decoration: none;
-
     :hover {
-      text-decoration: underline;
       color: white;
     }
   }
@@ -301,6 +405,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;
@@ -388,6 +504,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;
@@ -452,7 +573,6 @@ const PRLink = styled(DynamicLink)`
 const ChartListWrapper = styled.div`
   width: 100%;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
 `;
 

+ 65 - 211
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
-import { PRDeployment, PullRequest } from "../types";
+import { Environment, PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
@@ -14,66 +14,31 @@ import DashboardHeader from "../../DashboardHeader";
 import RadioFilter from "components/RadioFilter";
 import Placeholder from "components/Placeholder";
 import Banner from "components/Banner";
-import Modal from "main/home/modals/Modal";
 
 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";
-
-const AvailableStatusFilters = ["all", "created", "failed", "not_deployed"];
+import { PorterYAMLErrors } from "../errors";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+
+const AvailableStatusFilters = [
+  "all",
+  "creating",
+  "created",
+  "failed",
+  "timed_out",
+  "updating",
+];
 
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 
-const HARD_CODED_DEPLOYMENTS: PRDeployment[] = [
-  {
-    id: 1,
-    created_at: "2021-03-01T00:00:00.000Z",
-    updated_at: "2021-03-01T00:00:00.000Z",
-    subdomain: "subdomain",
-    status: "created",
-    environment_id: 1,
-    pull_request_id: 1,
-    namespace: "namespace",
-    last_workflow_run_url: "",
-    gh_installation_id: 1,
-    gh_deployment_id: 1,
-    gh_pr_name: "gh_pr_name",
-    gh_repo_owner: "meehawk",
-    gh_repo_name: "meehawk",
-    gh_commit_sha: "3659ef050a687da4d04bb870b27058bd9d1957be",
-    gh_pr_branch_from: "gh_pr_branch_from",
-    gh_pr_branch_into: "gh_pr_branch_into",
-  },
-  {
-    id: 2,
-    created_at: "2021-03-01T00:00:00.000Z",
-    updated_at: "2021-03-01T00:00:00.000Z",
-    subdomain: "subdomain",
-    status: "created",
-    environment_id: 1,
-    pull_request_id: 1,
-    namespace: "namespace",
-    last_workflow_run_url: "",
-    gh_installation_id: 1,
-    gh_deployment_id: 1,
-    gh_pr_name: "some_awesome_pr",
-    gh_repo_owner: "godzilla",
-    gh_repo_name: "kong",
-    gh_commit_sha: "3659ef050a687da4d04bb870b27058bd9d1957be",
-    gh_pr_branch_from: "gh_pr_branch_from",
-    gh_pr_branch_into: "gh_pr_branch_into",
-  },
-];
-
 const DeploymentList = () => {
   const [sortOrder, setSortOrder] = useState("Newest");
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>(
-    HARD_CODED_DEPLOYMENTS
-  );
+  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
@@ -87,7 +52,9 @@ const DeploymentList = () => {
     setStatusSelectorVal,
   ] = useState<AvailableStatusFiltersType>("all");
 
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
@@ -169,9 +136,7 @@ const DeploymentList = () => {
           }
 
           setPorterYAMLErrors(porterYAMLErrors);
-          setDeploymentList(
-            deploymentList.deployments || HARD_CODED_DEPLOYMENTS
-          );
+          setDeploymentList(deploymentList.deployments ?? []);
           setPullRequests(deploymentList.pull_requests || []);
 
           setNewCommentsDisabled(
@@ -181,8 +146,9 @@ const DeploymentList = () => {
           setIsLoading(false);
         }
       )
-      .catch(() => {
-        setDeploymentList(HARD_CODED_DEPLOYMENTS);
+      .catch((err) => {
+        setDeploymentList([]);
+        setCurrentError(err);
       });
 
     return () => {
@@ -198,7 +164,7 @@ const DeploymentList = () => {
         clusterID: currentCluster.id,
         environmentID: Number(environment_id),
       });
-      setDeploymentList(data.deployments || []);
+      setDeploymentList(data.deployments ?? []);
       setPullRequests(data.pull_requests || []);
     } catch (error) {
       setHasError(true);
@@ -207,19 +173,6 @@ const DeploymentList = () => {
     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);
 
@@ -227,9 +180,21 @@ const DeploymentList = () => {
   };
 
   const filteredDeployments = useMemo(() => {
-    const filteredByStatus = deploymentList.filter(
-      (d) => !["deleted", "inactive"].includes(d.status)
-    );
+    const filteredByStatus = deploymentList.filter((d) => {
+      if (["deleted", "inactive"].includes(d.status)) {
+        return false;
+      }
+
+      if (statusSelectorVal === "all") {
+        return true;
+      }
+
+      if (d.status === statusSelectorVal) {
+        return true;
+      }
+
+      return false;
+    });
 
     const filteredBySearch = search<PRDeployment>(
       filteredByStatus,
@@ -273,8 +238,8 @@ const DeploymentList = () => {
     if (!deploymentList.length) {
       return (
         <Placeholder height="calc(100vh - 400px)">
-          No preview apps have been found. Open a PR to create a new preview
-          app.
+          No preview developments have been found. Open a PR to create a new
+          preview app.
         </Placeholder>
       );
     }
@@ -282,23 +247,13 @@ const DeploymentList = () => {
     if (!filteredDeployments.length) {
       return (
         <Placeholder height="calc(100vh - 400px)">
-          No preview apps have been found with the given filter.
+          No preview developments have been found with the given filter.
         </Placeholder>
       );
     }
 
     return (
       <>
-        {/* Deprecated -> New Preview Env button */}
-        {/* {filteredPullRequests.map((pr) => {
-          return (
-            <PullRequestCard
-              key={pr.pr_title}
-              pullRequest={pr}
-              onCreation={handlePreviewEnvironmentManualCreation}
-            />
-          );
-        })} */}
         {filteredDeployments.map((d: any) => {
           return (
             <DeploymentCard
@@ -314,47 +269,18 @@ const DeploymentList = () => {
     );
   };
 
-  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 (
     <>
-      {expandedPorterYAMLErrors.length && (
-        <Modal
-          onRequestClose={() => setExpandedPorterYAMLErrors([])}
-          height="auto"
-        >
-          <Message>
-            {expandedPorterYAMLErrors.map((el) => {
-              return (
-                <div>
-                  {"- "}
-                  {el}
-                </div>
-              );
-            })}
-          </Message>
-        </Modal>
-      )}
+      <PorterYAMLErrorsModal
+        errors={expandedPorterYAMLErrors}
+        onClose={() => setExpandedPorterYAMLErrors([])}
+        repo={selectedRepo}
+      />
+
       <BreadcrumbRow>
         <Breadcrumb to="/preview-environments">
           <ArrowIcon src={pullRequestIcon} />
@@ -383,55 +309,19 @@ const DeploymentList = () => {
         capitalize={false}
       />
       {porterYAMLErrors.length > 0 ? (
-        <Banner type="error">
-          Your porter.yaml file has errors. Please fix them before deploying.
-          <LinkButton
-            onClick={() => {
-              setExpandedPorterYAMLErrors(porterYAMLErrors);
-            }}
-          >
-            View details
-          </LinkButton>
-        </Banner>
+        <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}
-      {/* <Flex>
-        <ActionsWrapper>
-          <StyledStatusSelector>
-            <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-              <i className="material-icons">refresh</i>
-            </RefreshButton>
-            <SearchRow>
-              <i className="material-icons">search</i>
-              <SearchInput
-                value={searchValue}
-                onChange={(e: any) => {
-                  setSearchValue(e.target.value);
-                }}
-                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}
-            />
-            <EnvironmentSettings environmentId={environment_id} />
-          </StyledStatusSelector>
-        </ActionsWrapper>
-      </Flex> */}
       <FlexRow>
         <Flex>
           <SearchRowWrapper>
@@ -473,6 +363,9 @@ const DeploymentList = () => {
             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
@@ -547,6 +440,7 @@ const Message = styled.div`
 
 const BreadcrumbRow = styled.div`
   width: 100%;
+  margin-top: 5px;
   display: flex;
   justify-content: flex-start;
 `;
@@ -583,41 +477,6 @@ const Flex = styled.div`
   align-items: center;
 `;
 
-const Div = styled.div`
-  margin-bottom: -7px;
-`;
-
-const FlexWrap = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const BackButton = styled(DynamicLink)`
-  cursor: pointer;
-  font-size: 24px;
-  color: #969fbbaa;
-  padding: 3px;
-  border-radius: 100px;
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
-const Icon = styled.img`
-  width: 25px;
-  height: 25px;
-  margin-right: 6px;
-`;
-
-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 RefreshButton = styled.button`
   display: flex;
   align-items: center;
@@ -651,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;
@@ -748,3 +598,7 @@ const CreatePreviewEnvironmentButton = styled(DynamicLink)`
     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;

+ 135 - 234
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -1,54 +1,27 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 import React, { useContext, useEffect, useState } from "react";
-import api from "shared/api";
 import styled from "styled-components";
-import { CellProps } from "react-table";
 import { Context } from "shared/Context";
 import { useParams } from "react-router";
-import { PRDeployment, PullRequest } from "../types";
+import { PullRequest } from "../types";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import Helper from "components/form-components/Helper";
-import Table from "components/Table";
 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";
-
-const dummyData: any = [
-  {
-    pr_title: "pr_title1",
-    pr_number: 1,
-    repo_owner: "repo_owner",
-    repo_name: "repo_name",
-    branch_from: "test1",
-    branch_into: "test",
-  },
-  {
-    pr_title: "pr_title2",
-    pr_number: 2,
-    repo_owner: "repo_owner",
-    repo_name: "repo_name",
-    branch_from: "test2",
-    branch_into: "test",
-  },
-  {
-    pr_title: "pr_title3",
-    pr_number: 3,
-    repo_owner: "repo_owner",
-    repo_name: "repo_name",
-    branch_from: "test3",
-    branch_into: "test",
-  },
-];
+import { useRouting } from "shared/routing";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
 
 const CreateEnvironment: React.FC = () => {
-  // TODO Soham: Replace any
+  const router = useRouting();
   const queryClient = useQueryClient();
-  const [modalContent, setModalContent] = useState<React.ReactNode>();
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
@@ -74,12 +47,9 @@ const CreateEnvironment: React.FC = () => {
       } catch (err) {
         setCurrentError(err);
       }
-
-      // TODO Soham: Replace with actual data
-      return dummyData; // [];
     }
   );
-  
+
   const [selectedPR, setSelectedPR] = useState<PullRequest>();
   const [loading, setLoading] = useState(false);
   const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
@@ -94,6 +64,7 @@ const CreateEnvironment: React.FC = () => {
       projectID: currentProject.id,
       clusterID: currentCluster.id,
       environmentID: Number(environment_id),
+      branch: pullRequest.branch_from,
     });
 
     setPorterYAMLErrors(res.data.errors ?? []);
@@ -101,63 +72,20 @@ const CreateEnvironment: React.FC = () => {
     setLoading(false);
   };
 
-  const columns = React.useMemo(
-    () => [
-      {
-        Header: "Monitors",
-        columns: [
-          {
-            Header: "Open pull requests",
-            accessor: "name",
-            width: 140,
-            Cell: ({
-              row: { original: pullRequest },
-            }: CellProps<PullRequest>) => {
-              return (
-                <div
-                  style={{
-                    cursor: "pointer",
-                  }}
-                  onClick={() => {
-                    handlePRRowItemClick(pullRequest);
-                  }}
-                >
-                  <PRName>
-                    <PRIcon src={pr_icon} alt="pull request icon" />
-                    <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
-                      {pullRequest.pr_title}
-                    </EllipsisTextWrapper>
-                    <Spacer />
-                    <RepoLink to="" target="_blank">
-                      <i className="material-icons">open_in_new</i>
-                      View last workflow
-                    </RepoLink>
-                  </PRName>
-
-                  <Flex>
-                    <DeploymentImageContainer>
-                      <InfoWrapper>
-                        <LastDeployed>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>
-                </div>
-              );
-            },
-          },
-        ],
-      },
-    ],
-    [pullRequests]
-  );
+  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);
+    }
+  };
 
   return (
     <>
@@ -185,59 +113,115 @@ const CreateEnvironment: React.FC = () => {
         <Code>porter.yaml</Code> file.
       </Helper>
       <Br height="10px" />
-      <Table
-        columns={columns}
-        data={pullRequests}
-        placeholder="No open pull requests found."
-      />
-      {modalContent ? (
-        <Modal onRequestClose={() => setModalContent(null)} height="auto">
-          {modalContent}
-        </Modal>
+      <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 on your default branch.
-            &nbsp;
-            <LearnMoreButton
-              onClick={() =>
-                setModalContent(
-                  <Message>
-                    {porterYAMLErrors.map((el) => {
-                      return (
-                        <div>
-                          {"- "}
-                          {el}
-                        </div>
-                      );
-                    })}
-                  </Message>
-                )
-              }
-            >
+            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}
-      <SubmitButton
-        disabled={loading || !selectedPR || porterYAMLErrors.length > 0}
-      >
-        Create preview deployment
-      </SubmitButton>
+      <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>
     </>
   );
 };
 
 export default CreateEnvironment;
 
-const Code = styled.span`
-  font-family: monospace; ;
+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 Spacer = styled.div`
-  width: 5px;
+const Code = styled.span`
+  font-family: monospace; ;
 `;
 
 const SepDot = styled.div`
@@ -307,7 +291,7 @@ const MergeInfo = styled.div`
 
 const PRIcon = styled.img`
   font-size: 20px;
-  height: 17px;
+  height: 16px;
   margin-right: 10px;
   color: #aaaabb;
   opacity: 50%;
@@ -337,7 +321,6 @@ const SubmitButton = styled.div`
   height: 30px;
   padding: 0 8px;
   width: 200px;
-  margin-top: 30px;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
@@ -370,48 +353,11 @@ const DarkMatter = styled.div`
   margin-top: -15px;
 `;
 
-const DeleteButton = styled.div`
-  height: 30px;
-  font-size: 13px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-  display: flex;
-  width: 210px;
-  align-items: center;
-  padding: 0 15px;
-  margin-top: 20px;
-  text-align: left;
-  border-radius: 5px;
-  cursor: pointer;
-  user-select: none;
-  :focus {
-    outline: 0;
-  }
-  :hover {
-    filter: brightness(120%);
-  }
-  background: #b91133;
-  border: none;
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
 const Br = styled.div<{ height: string }>`
   width: 100%;
   height: ${(props) => props.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;
@@ -456,78 +402,14 @@ const Breadcrumb = styled(DynamicLink)`
   }
 `;
 
-const Relative = styled.div`
-  position: relative;
-`;
-
-const EnvironmentsGrid = styled.div`
-  padding-bottom: 150px;
-  display: grid;
-  grid-row-gap: 15px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin: 35px 0 30px;
-  padding-left: 0px;
-`;
-
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  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 ValidationErrorBannerWrapper = styled.div`
   margin-block: 20px;
 `;
 
 const LearnMoreButton = styled.div`
+  text-decoration: underline;
   fontweight: bold;
   cursor: pointer;
-  &:hover {
-    text-decoration: underline;
-  }
 `;
 
 const Message = styled.div`
@@ -539,3 +421,22 @@ const Message = styled.div`
   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;
+`;

+ 9 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -113,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

+ 254 - 89
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -1,6 +1,6 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { useParams } from "react-router";
@@ -14,20 +14,25 @@ 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";
 
-/**
- * 
- * TODO Soham:
- * 
- * - Handle errors when fetching environments
- * - Handle errors when the environment is not found
- * - Handle errors on saving and deleting the environment
- */
-const EnvironmentSettings: React.FC = () => {
-  const [error, setError] = useState("");
+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);
@@ -35,6 +40,9 @@ const EnvironmentSettings: React.FC = () => {
     deploymentMode,
     setDeploymentMode,
   ] = useState<EnvironmentDeploymentMode>("manual");
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
   const {
     environment_id: environmentId,
     repo_name: repoName,
@@ -60,8 +68,22 @@ const EnvironmentSettings: React.FC = () => {
       );
 
       setEnvironment(environment);
-      setNewCommentsDisabled(environment.disable_new_comments);
+      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 {
@@ -71,16 +93,73 @@ const EnvironmentSettings: React.FC = () => {
     }
   }, []);
 
+  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: [],
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         {
           project_id: currentProject.id,
@@ -95,8 +174,56 @@ const EnvironmentSettings: React.FC = () => {
     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} />
@@ -117,6 +244,12 @@ const EnvironmentSettings: React.FC = () => {
         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>
@@ -125,7 +258,7 @@ const EnvironmentSettings: React.FC = () => {
         </Helper>
         <CheckboxRow
           label="Update the most recent PR comment"
-          checked={!newCommentsDisabled}
+          checked={newCommentsDisabled}
           toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
         />
         <Br />
@@ -143,6 +276,36 @@ const EnvironmentSettings: React.FC = () => {
             )
           }
         />
+        <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}
@@ -156,7 +319,12 @@ const EnvironmentSettings: React.FC = () => {
           Delete the Porter preview environment integration for this repo. All
           preview deployments will also be destroyed.
         </Helper>
-        <DeleteButton disabled={saveStatus === "loading"} onClick={_.noop}>
+        <DeleteButton
+          disabled={saveStatus === "loading"}
+          onClick={() => {
+            setShowDeleteModal(true);
+          }}
+        >
           Delete preview environment
         </DeleteButton>
       </StyledPlaceholder>
@@ -164,37 +332,92 @@ const EnvironmentSettings: React.FC = () => {
   );
 };
 
+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 DeleteButton = styled.button`
-  height: 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;
-  width: 210px;
   align-items: center;
-  padding: 0 15px;
+  padding: 10px 15px;
   margin-top: 20px;
   text-align: left;
   border-radius: 5px;
-  cursor: pointer;
   user-select: none;
-  :focus {
-    outline: 0;
-  }
-  :hover {
-    filter: brightness(120%);
-  }
   background: #b91133;
   border: none;
-  :hover {
-    filter: brightness(120%);
+  cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")};
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
+
+  &:focus {
+    outline: 0;
+  }
+  &:hover {
+    filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
   }
 `;
 
@@ -237,7 +460,7 @@ const BreadcrumbRow = styled.div`
   display: flex;
   justify-content: flex-start;
   margin-bottom: 15px;
-  margin-top: -10px;
+  margin-top: -5px;
   align-items: center;
 `;
 
@@ -256,64 +479,6 @@ const Breadcrumb = styled(DynamicLink)`
   }
 `;
 
-const Relative = styled.div`
-  position: relative;
-`;
-
-const EnvironmentsGrid = styled.div`
-  padding-bottom: 150px;
-  display: grid;
-  grid-row-gap: 15px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin: 35px 0 30px;
-  padding-left: 0px;
-`;
-
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  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 WarningBannerWrapper = styled.div`
+  margin-block: 20px;
 `;

+ 2 - 39
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -10,45 +10,12 @@ import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
 import Placeholder from "components/Placeholder";
 
-const HARD_CODED_ENVS: Environment[] = [
-  {
-    id: 12,
-    project_id: 1234,
-    cluster_id: 4321,
-    git_installation_id: 55,
-    name: "asdf",
-    git_repo_owner: "owned",
-    git_repo_name: "this-is-a-repo",
-    last_deployment_status: "failed",
-    deployment_count: 12,
-    mode: "manual",
-    git_repo_branches: [],
-    disable_new_comments: true,
-  },
-  {
-    id: 13,
-    project_id: 1234,
-    cluster_id: 4321,
-    git_installation_id: 55,
-    name: "asdf",
-    git_repo_owner: "owned",
-    git_repo_name: "this-is-a-repo",
-    last_deployment_status: "failed",
-    deployment_count: 12,
-    mode: "manual",
-    git_repo_branches: [],
-    disable_new_comments: true,
-  },
-];
-
 const EnvironmentsList = () => {
   const { currentCluster, currentProject } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [buttonIsReady, setButtonIsReady] = useState(false);
 
-  const [environments, setEnvironments] = useState<Environment[]>(
-    HARD_CODED_ENVS
-  );
+  const [environments, setEnvironments] = useState<Environment[]>([]);
 
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
     setEnvironments((prev) => {
@@ -89,12 +56,8 @@ const EnvironmentsList = () => {
       }
 
       setEnvironments(envs);
-
-      //
-      setEnvironments(HARD_CODED_ENVS);
     } catch (error) {
-      // ret2: remove placeholder (set to empty array)
-      setEnvironments(HARD_CODED_ENVS);
+      setEnvironments([]);
     }
   };
 

+ 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",
+}

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

@@ -22,7 +22,7 @@ export const Routes = () => {
         <Route path={`${path}/connect-repo`}>
           <ConnectNewRepo />
         </Route>
-        <Route path={`${path}/details/:namespace?`}>
+        <Route path={`${path}/details/:id`}>
           <DeploymentDetail />
         </Route>
         <Route

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

@@ -40,10 +40,11 @@ export type Environment = {
   git_repo_owner: string;
   git_repo_name: string;
   git_repo_branches: string[];
-  disable_new_comments: boolean;
+  new_comments_disabled: boolean;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   mode: EnvironmentDeploymentMode;
+  namespace_annotations: Record<string, string>;
 };
 
 export type PullRequest = {

+ 22 - 27
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -17,6 +17,7 @@ import TitleSection from "components/TitleSection";
 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 & {
@@ -115,24 +116,18 @@ class Dashboard extends Component<PropsType, StateType> {
       );
     } else if (this.currentTab() === "create-cluster") {
       let helperText = "Create a cluster to link to this project";
-      let helperIcon = "info";
-      let helperColor = "white";
-      if (
-        this.context.hasBillingEnabled &&
-        this.context.usage.current.clusters !== 0 &&
-        this.context.usage.current.clusters >= this.context.usage.limit.clusters
-      ) {
+      let helperType = "info";
+      if (true) {
         helperText =
           "You need to update your billing to provision or connect a new cluster";
-        helperIcon = "warning";
-        helperColor = "#f5cb42";
+        helperType = "warning";
       }
       return (
         <>
-          <Banner color={helperColor}>
-            <i className="material-icons">{helperIcon}</i>
+          <Banner type={helperType} noMargin>
             {helperText}
           </Banner>
+          <Br />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
       );
@@ -230,22 +225,22 @@ const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
 
-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 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;

+ 2 - 2
dashboard/src/main/home/navbar/Feedback.tsx

@@ -261,7 +261,7 @@ const FeedbackButton = styled(NavButton)`
   color: ${(props: { selected?: boolean }) =>
     props.selected ? "#ffffff" : "#ffffff88"};
   font-family: "Work Sans", sans-serif;
-  font-size: 14px;
+  font-size: 13px;
   margin-right: 20px;
   :hover {
     color: #ffffff;
@@ -276,7 +276,7 @@ const FeedbackButton = styled(NavButton)`
     > i {
       color: ${(props: { selected?: boolean }) =>
         props.selected ? "#ffffff" : "#ffffff88"};
-      font-size: 26px;
+      font-size: 23px;
       margin-right: 6px;
     }
   }

+ 3 - 2
dashboard/src/main/home/navbar/Help.tsx

@@ -198,7 +198,7 @@ const FeedbackButton = styled(NavButton)`
   color: ${(props: { selected?: boolean }) =>
     props.selected ? "#ffffff" : "#ffffff88"};
   font-family: "Work Sans", sans-serif;
-  font-size: 14px;
+  font-size: 13px;
   margin-right: 20px;
   :hover {
     color: #ffffff;
@@ -213,8 +213,9 @@ const FeedbackButton = styled(NavButton)`
     > i {
       color: ${(props: { selected?: boolean }) =>
         props.selected ? "#ffffff" : "#ffffff88"};
-      font-size: 22px;
+      font-size: 18px;
       margin-right: 6px;
+      margin-bottom: -1px;
     }
   }
 `;

+ 3 - 3
dashboard/src/main/home/navbar/Navbar.tsx

@@ -211,7 +211,7 @@ const Dropdown = styled.div`
 `;
 
 const StyledNavbar = styled.div`
-  height: 60px;
+  height: 50px;
   position: absolute;
   top: 0;
   right: 0;
@@ -229,7 +229,7 @@ const NavButton = styled.a`
   color: #ffffff88;
   cursor: pointer;
   justify-content: center;
-  margin-right: 15px;
+  margin-right: 10px;
   :hover {
     > i {
       color: #ffffff;
@@ -241,6 +241,6 @@ const NavButton = styled.a`
     cursor: pointer;
     color: ${(props: { selected?: boolean }) =>
       props.selected ? "#ffffff" : "#ffffff88"};
-    font-size: 24px;
+    font-size: 20px;
   }
 `;

+ 28 - 25
dashboard/src/shared/api.tsx

@@ -151,6 +151,7 @@ const createEnvironment = baseApi<
     mode: "auto" | "manual";
     disable_new_comments: boolean;
     git_repo_branches: string[];
+    namespace_annotations: Record<string, string>;
   },
   {
     project_id: number;
@@ -175,6 +176,7 @@ const updateEnvironment = baseApi<
     mode: "auto" | "manual";
     disable_new_comments: boolean;
     git_repo_branches: string[]; // Array with branch names
+    namespace_annotations: Record<string, string>;
   },
   {
     project_id: number;
@@ -445,9 +447,9 @@ const getPRDeploymentList = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
 });
 
-const getPRDeploymentByEnvironment = baseApi<
+const getPRDeploymentByID = baseApi<
   {
-    namespace: string;
+    id: number;
   },
   {
     cluster_id: number;
@@ -460,27 +462,28 @@ const getPRDeploymentByEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/deployment`;
 });
 
-const getPRDeployment = baseApi<
-  {
-    namespace: string;
-  },
-  {
-    cluster_id: number;
-    project_id: number;
-    git_installation_id: number;
-    git_repo_owner: string;
-    git_repo_name: string;
-  }
->("GET", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    git_installation_id,
-    git_repo_owner,
-    git_repo_name,
-  } = pathParams;
-  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
-});
+// TODO (soham): Check if we are really using this?
+// const getPRDeployment = baseApi<
+//   {
+//     namespace: string;
+//   },
+//   {
+//     cluster_id: number;
+//     project_id: number;
+//     git_installation_id: number;
+//     git_repo_owner: string;
+//     git_repo_name: string;
+//   }
+// >("GET", (pathParams) => {
+//   const {
+//     cluster_id,
+//     project_id,
+//     git_installation_id,
+//     git_repo_owner,
+//     git_repo_name,
+//   } = pathParams;
+//   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+// });
 
 const deletePRDeployment = baseApi<
   {},
@@ -2436,8 +2439,8 @@ export default {
   getClusterNode,
   getConfigMap,
   getPRDeploymentList,
-  getPRDeploymentByEnvironment,
-  getPRDeployment,
+  getPRDeploymentByID,
+  // getPRDeployment,
   getGHAWorkflowTemplate,
   getGitRepoList,
   getGitRepoPermission,

+ 3 - 0
dashboard/src/shared/routing.tsx

@@ -91,6 +91,9 @@ export const useRouting = () => {
   const history = useHistory();
 
   return {
+    push(path: string, state?: any) {
+      history.push(path, state);
+    },
     pushQueryParams: (
       params: { [key: string]: unknown },
       removedParams?: string[]