Browse Source

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

Mohammed Nafees 3 năm trước cách đây
mục cha
commit
4a52083d72
40 tập tin đã thay đổi với 1265 bổ sung1135 xóa
  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
 		return
 	}
 	}
 
 
-	namespace, err := agent.CreateNamespace(request.Name, nil)
+	namespace, err := agent.CreateNamespace(request.Name, request.Annotations)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		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/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
 	"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/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
 )
 )
 
 
 var (
 var (
@@ -65,3 +69,58 @@ func isGithubPRClosed(
 
 
 	return ghPR.GetState() == "closed", nil
 	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 err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		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
 			return
 		}
 		}
 
 
@@ -82,7 +82,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 
 	if err != nil {
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		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
 			return
 		}
 		}
 
 
@@ -90,10 +90,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		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 err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 		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
 		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)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 
 	if err != nil {
 	if err != nil {
@@ -118,7 +139,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	// create the deployment
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
 		EnvironmentID: env.ID,
-		Namespace:     "namespace-creating",
+		Namespace:     "",
 		Status:        types.DeploymentStatusCreating,
 		Status:        types.DeploymentStatusCreating,
 		PullRequestID: request.Number,
 		PullRequestID: request.Number,
 		RepoOwner:     request.RepoOwner,
 		RepoOwner:     request.RepoOwner,

+ 5 - 40
api/server/handlers/environment/get_deployment.go

@@ -2,7 +2,6 @@ package environment
 
 
 import (
 import (
 	"errors"
 	"errors"
-	"fmt"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -47,15 +46,6 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		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
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 	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
 		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
 		return
 	}
 	}
 
 

+ 5 - 41
api/server/handlers/environment/get_deployment_by_env.go

@@ -2,7 +2,6 @@ package environment
 
 
 import (
 import (
 	"errors"
 	"errors"
-	"fmt"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,15 +45,6 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 		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)
 	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 
 
 	if err != nil {
 	if err != nil {
@@ -67,38 +57,12 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 		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
 		return
 	}
 	}
 
 

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

@@ -276,7 +276,7 @@ func fetchOpenPullRequests(
 
 
 	for _, pr := range openPRs {
 	for _, pr := range openPRs {
 		if len(branchesMap) > 0 {
 		if len(branchesMap) > 0 {
-			if _, ok := branchesMap[pr.GetHead().GetRef()]; !ok {
+			if _, ok := branchesMap[pr.GetBase().GetRef()]; !ok {
 				continue
 				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
 		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 {
 	if changed {
 		env, err = c.Repo().Environment().UpdateEnvironment(env)
 		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" {
 	if env.Mode == "auto" && event.GetAction() == "opened" {
 		depl := &models.Deployment{
 		depl := &models.Deployment{
 			EnvironmentID: env.ID,
 			EnvironmentID: env.ID,
-			Namespace:     "namespace-creating",
+			Namespace:     "",
 			Status:        types.DeploymentStatusCreating,
 			Status:        types.DeploymentStatusCreating,
 			PullRequestID: uint(event.GetPullRequest().GetNumber()),
 			PullRequestID: uint(event.GetPullRequest().GetNumber()),
 			PRName:        event.GetPullRequest().GetTitle(),
 			PRName:        event.GetPullRequest().GetTitle(),
@@ -310,10 +310,7 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		&deploymentStatusRequest,
 		&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 {
 	if err != nil {
 		return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error updating deployment: %w",
 		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 {
 type FinalizeDeploymentRequest struct {
-	Namespace           string                          `json:"namespace"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Subdomain           string                          `json:"subdomain"`
 	Subdomain           string                          `json:"subdomain"`
 	PRNumber            uint                            `json:"pr_number"`
 	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 }
 
 
 type FinalizeDeploymentWithErrorsRequest struct {
 type FinalizeDeploymentWithErrorsRequest struct {
-	Namespace           string                          `json:"namespace"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Errors              map[string]string               `json:"errors" form:"required"`
 	Errors              map[string]string               `json:"errors" form:"required"`
 	PRNumber            uint                            `json:"pr_number"`
 	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 }
 
 
 type UpdateDeploymentRequest struct {
 type UpdateDeploymentRequest struct {
@@ -99,8 +103,10 @@ type UpdateDeploymentRequest struct {
 
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	CommitSHA    string `json:"commit_sha" form:"required"`
 	CommitSHA    string `json:"commit_sha" form:"required"`
-	Namespace    string `json:"namespace"`
 	PRNumber     uint   `json:"pr_number"`
 	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 }
 
 
 type ListDeploymentRequest struct {
 type ListDeploymentRequest struct {
@@ -112,8 +118,10 @@ type UpdateDeploymentStatusRequest struct {
 
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
 	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace    string `json:"namespace"`
 	PRNumber     uint   `json:"pr_number"`
 	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 }
 
 
 type DeleteDeploymentRequest struct {
 type DeleteDeploymentRequest struct {
@@ -121,8 +129,11 @@ type DeleteDeploymentRequest struct {
 }
 }
 
 
 type GetDeploymentRequest struct {
 type GetDeploymentRequest struct {
+	DeploymentID uint `schema:"id"`
+	PRNumber     uint `schema:"pr_number"`
+
+	// legacy usage for backwards compatibility
 	Namespace string `schema:"namespace"`
 	Namespace string `schema:"namespace"`
-	PRNumber  uint   `schema:"pr_number"`
 }
 }
 
 
 type PullRequest struct {
 type PullRequest struct {
@@ -149,7 +160,8 @@ type ValidatePorterYAMLResponse struct {
 }
 }
 
 
 type UpdateEnvironmentSettingsRequest 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"
 	"log"
 
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"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/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
 	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
 	"github.com/porter-dev/porter/cmd/migrate/startup_migrations"
 	"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 {
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 		logger.Fatal().Err(err).Msg("vault migration failed")
 		logger.Fatal().Err(err).Msg("vault migration failed")
 	}
 	}
@@ -157,22 +148,3 @@ func shouldPopulateSourceConfigDisplayName() bool {
 
 
 	return c.PopulateSourceConfigDisplayName
 	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
 package startup_migrations
 
 
 import (
 import (
+	"github.com/porter-dev/porter/cmd/migrate/enable_cluster_preview_envs"
 	"github.com/porter-dev/porter/cmd/migrate/migrate_legacy_rbac"
 	"github.com/porter-dev/porter/cmd/migrate/migrate_legacy_rbac"
 	lr "github.com/porter-dev/porter/pkg/logger"
 	lr "github.com/porter-dev/porter/pkg/logger"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
+// this should be incremented with every new startup migration script
 const LatestMigrationVersion uint = 1
 const LatestMigrationVersion uint = 1
 
 
 type migrationFunc func(db *gorm.DB, logger *lr.Logger) error
 type migrationFunc func(db *gorm.DB, logger *lr.Logger) error
@@ -14,4 +16,5 @@ var StartupMigrations = make(map[uint]migrationFunc)
 
 
 func init() {
 func init() {
 	StartupMigrations[1] = migrate_legacy_rbac.MigrateFromLegacyRBAC
 	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": {
     "@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": {
       "requires": {
         "@tanstack/match-sorter-utils": "^8.1.1",
         "@tanstack/match-sorter-utils": "^8.1.1",
         "superjson": "^1.10.0",
         "superjson": "^1.10.0",

+ 1 - 1
dashboard/package.json

@@ -10,7 +10,7 @@
     "@sentry/react": "^6.13.2",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",
     "@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/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/event": "^1.3.0",

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

@@ -16,20 +16,29 @@ const Placeholder: React.FC<Props> = ({
 }) => {
 }) => {
   return (
   return (
     <StyledPlaceholder height={height} minHeight={minHeight}>
     <StyledPlaceholder height={height} minHeight={minHeight}>
-      <div>
+      <Wrapper>
         <Title>{title}</Title>
         <Title>{title}</Title>
-        {children}
-      </div>
+        <Flex>{children}</Flex>
+      </Wrapper>
     </StyledPlaceholder>
     </StyledPlaceholder>
   );
   );
 };
 };
 
 
 export default Placeholder;
 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`
 const Title = styled.div`
   font-size: 16px;
   font-size: 16px;
   color: white;
   color: white;
-  margin-bottom: 10px;
   font-weight: 500;
   font-weight: 500;
 `;
 `;
 
 
@@ -50,4 +59,11 @@ const StyledPlaceholder = styled.div<{
   background: #26292e;
   background: #26292e;
   border: 1px solid #494b4f;
   border: 1px solid #494b4f;
   padding-bottom: 60px;
   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;
   className?: string;
   materialIconClass?: string;
   materialIconClass?: string;
   handleNavBack?: () => void;
   handleNavBack?: () => void;
+  onClick?: any;
 }
 }
 
 
 const TitleSection: React.FC<Props> = ({
 const TitleSection: React.FC<Props> = ({
@@ -19,6 +20,7 @@ const TitleSection: React.FC<Props> = ({
   handleNavBack,
   handleNavBack,
   className,
   className,
   materialIconClass,
   materialIconClass,
+  onClick,
 }) => {
 }) => {
   return (
   return (
     <StyledTitleSection className={className}>
     <StyledTitleSection className={className}>
@@ -39,7 +41,12 @@ const TitleSection: React.FC<Props> = ({
           <Icon width={iconWidth} src={icon} />
           <Icon width={iconWidth} src={icon} />
         ))}
         ))}
 
 
-      <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
+      <StyledTitle
+        capitalize={capitalize}
+        onClick={onClick}
+      >
+        {children}
+      </StyledTitle>
     </StyledTitleSection>
     </StyledTitleSection>
   );
   );
 };
 };
@@ -78,13 +85,20 @@ const MaterialIcon = styled.span<{ width: string }>`
   margin-right: 16px;
   margin-right: 16px;
 `;
 `;
 
 
-const StyledTitle = styled.div<{ capitalize: boolean }>`
+const StyledTitle = styled.div<{ 
+  capitalize: boolean;
+  onClick?: any;
+}>`
   font-size: 21px;
   font-size: 21px;
   font-weight: 600;
   font-weight: 600;
   user-select: text;
   user-select: text;
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  cursor: ${props => props.onClick ? "pointer" : ""};
+  :hover {
+    text-decoration: ${props => props.onClick ? "underline" : ""};
+  }
 
 
   > i {
   > i {
     margin-left: 10px;
     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 Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import CronParser from "cron-parser";
 import CronParser from "cron-parser";
+import Placeholder from "components/Placeholder";
 
 
 type Props = {
 type Props = {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -445,13 +446,13 @@ const ChartList: React.FunctionComponent<Props> = ({
       );
       );
     } else if (isError) {
     } else if (isError) {
       return (
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">error</i> Error connecting to cluster.
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
         </Placeholder>
       );
       );
     } else if (filteredCharts?.length === 0) {
     } else if (filteredCharts?.length === 0) {
       return (
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">category</i> No
           <i className="material-icons">category</i> No
           {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
           {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
           filters.
           filters.
@@ -486,27 +487,6 @@ const ChartList: React.FunctionComponent<Props> = ({
 
 
 export default ChartList;
 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`
 const LoadingWrapper = styled.div`
   padding-top: 100px;
   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 JobMetricsSection from "../metrics/JobMetricsSection";
 import Logs from "../status/Logs";
 import Logs from "../status/Logs";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
-import LogsSection from "../logs-section/LogsSection";
+import LogsSection, { InitLogData } from "../logs-section/LogsSection";
 import EventsTab from "../events/EventsTab";
 import EventsTab from "../events/EventsTab";
 import { getPodStatus } from "../deploy-status-section/util";
 import { getPodStatus } from "../deploy-status-section/util";
 import { capitalize } from "shared/string_utils";
 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 (
     return (
       <JobLogsWrapper>
       <JobLogsWrapper>
         <DeprecatedWarning>
         <DeprecatedWarning>
@@ -247,6 +253,7 @@ const ExpandedJobRun = ({
           setIsFullscreen={() => {}}
           setIsFullscreen={() => {}}
           overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
           overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
           currentChart={currentChart}
           currentChart={currentChart}
+          initData={initData}
         />
         />
       </JobLogsWrapper>
       </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 CheckboxRow from "components/form-components/CheckboxRow";
 import BranchFilterSelector from "./components/BranchFilterSelector";
 import BranchFilterSelector from "./components/BranchFilterSelector";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
+import NamespaceAnnotations, {
+  KeyValueType,
+} from "./components/NamespaceAnnotations";
 
 
 const ConnectNewRepo: React.FC = () => {
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
   const { currentProject, currentCluster, setCurrentError } = useContext(
@@ -46,6 +49,11 @@ const ConnectNewRepo: React.FC = () => {
   // Disable new comments data
   // Disable new comments data
   const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
   const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
 
 
+  // Namespace annotations
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
+
   useEffect(() => {
   useEffect(() => {
     api
     api
       .listEnvironments<Environment[]>(
       .listEnvironments<Environment[]>(
@@ -109,7 +117,32 @@ const ConnectNewRepo: React.FC = () => {
 
 
   const addRepo = () => {
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
     let [owner, repoName] = repo.split("/");
+    let annotations: Record<string, string> = {};
+
     setStatus("loading");
     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
     api
       .createEnvironment(
       .createEnvironment(
         "<token>",
         "<token>",
@@ -118,6 +151,7 @@ const ConnectNewRepo: React.FC = () => {
           mode: enableAutomaticDeployments ? "auto" : "manual",
           mode: enableAutomaticDeployments ? "auto" : "manual",
           disable_new_comments: isNewCommentsDisabled,
           disable_new_comments: isNewCommentsDisabled,
           git_repo_branches: selectedBranches,
           git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         },
         {
         {
           project_id: currentProject.id,
           project_id: currentProject.id,
@@ -152,7 +186,21 @@ const ConnectNewRepo: React.FC = () => {
           <i className="material-icons">keyboard_backspace</i>
           <i className="material-icons">keyboard_backspace</i>
           Back
           Back
         </Button>
         </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>
       </HeaderSection>
 
 
       <Heading>Select a Repository</Heading>
       <Heading>Select a Repository</Heading>
@@ -192,10 +240,6 @@ const ConnectNewRepo: React.FC = () => {
             disableMargin: true,
             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>
       </CheckboxWrapper>
 
 
       <Heading>Disable new comments for new deployments</Heading>
       <Heading>Disable new comments for new deployments</Heading>
@@ -212,11 +256,6 @@ const ConnectNewRepo: React.FC = () => {
             disableMargin: true,
             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>
       </CheckboxWrapper>
 
 
       <Heading>Select allowed branches</Heading>
       <Heading>Select allowed branches</Heading>
@@ -233,6 +272,22 @@ const ConnectNewRepo: React.FC = () => {
         showLoading={isLoadingBranches}
         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>
       <ActionContainer>
         <SaveButton
         <SaveButton
           text="Add repository"
           text="Add repository"

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

@@ -53,16 +53,38 @@ const BranchFilterSelector = ({
         showLoading={showLoading}
         showLoading={showLoading}
       />
       />
       {/* List selected branches  */}
       {/* 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;
 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 = [
   const DeploymentCardActions = [
     {
     {
-      active: deployment.last_workflow_run_url,
+      active: !!deployment.last_workflow_run_url,
       label: "View last workflow",
       label: "View last workflow",
       action: (e: React.MouseEvent) => {
       action: (e: React.MouseEvent) => {
         e.preventDefault();
         e.preventDefault();
@@ -195,7 +195,7 @@ const DeploymentCard: React.FC<{
 
 
   return (
   return (
     <DeploymentCardWrapper
     <DeploymentCardWrapper
-      to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+      to={`/preview-environments/details/${deployment.id}?environment_id=${deployment.environment_id}`}
     >
     >
       <DataContainer>
       <DataContainer>
         <PRName>
         <PRName>
@@ -234,12 +234,6 @@ const DeploymentCard: React.FC<{
               )}
               )}
             </MergeInfoWrapper>
             </MergeInfoWrapper>
           ) : null}
           ) : 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>
         </PRName>
 
 
         <Flex>
         <Flex>
@@ -279,18 +273,21 @@ const DeploymentCard: React.FC<{
 
 
             {deployment.status !== DeploymentStatus.Creating && (
             {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
                 <DeploymentCardActionsDropdown
                   options={DeploymentCardActions}
                   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 { useRouteMatch, useLocation } from "react-router";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 import { PRDeployment } from "../types";
 import { PRDeployment } from "../types";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
@@ -12,13 +13,14 @@ import ChartList from "../../chart/ChartList";
 import github from "assets/github-white.png";
 import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
 import { capitalize } from "shared/string_utils";
-import leftArrow from "assets/left-arrow.svg";
 import Banner from "components/Banner";
 import Banner from "components/Banner";
 import Modal from "main/home/modals/Modal";
 import Modal from "main/home/modals/Modal";
 import { validatePorterYAML } from "../utils";
 import { validatePorterYAML } from "../utils";
+import Placeholder from "components/Placeholder";
+import GithubIcon from "assets/GithubIcon";
 
 
 const DeploymentDetail = () => {
 const DeploymentDetail = () => {
-  const { params } = useRouteMatch<{ namespace: string }>();
+  const { params } = useRouteMatch<{ id: string }>();
   const context = useContext(Context);
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [environmentId, setEnvironmentId] = useState("");
   const [environmentId, setEnvironmentId] = useState("");
@@ -38,10 +40,10 @@ const DeploymentDetail = () => {
     let environment_id = parseInt(searchParams.get("environment_id"));
     let environment_id = parseInt(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     api
     api
-      .getPRDeploymentByEnvironment(
+      .getPRDeploymentByID(
         "<token>",
         "<token>",
         {
         {
-          namespace: params.namespace,
+          id: parseInt(params.id),
         },
         },
         {
         {
           project_id: currentProject.id,
           project_id: currentProject.id,
@@ -53,7 +55,6 @@ const DeploymentDetail = () => {
         if (!isSubscribed) {
         if (!isSubscribed) {
           return;
           return;
         }
         }
-
         setPRDeployment(data);
         setPRDeployment(data);
       })
       })
       .catch((err) => {
       .catch((err) => {
@@ -64,11 +65,11 @@ const DeploymentDetail = () => {
       });
       });
   }, [params]);
   }, [params]);
 
 
-  if (!prDeployment) {
-    return <Loading />;
-  }
-
   useEffect(() => {
   useEffect(() => {
+    if (!prDeployment) {
+      return;
+    }
+
     const isSubscribed = true;
     const isSubscribed = true;
     const environment_id = parseInt(searchParams.get("environment_id"));
     const environment_id = parseInt(searchParams.get("environment_id"));
 
 
@@ -91,13 +92,103 @@ const DeploymentDetail = () => {
           setPorterYAMLErrors([]);
           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 (
   return (
     <>
     <>
-      {expandedPorterYAMLErrors.length && (
+      {expandedPorterYAMLErrors.length > 0 && (
         <Modal
         <Modal
           onRequestClose={() => setExpandedPorterYAMLErrors([])}
           onRequestClose={() => setExpandedPorterYAMLErrors([])}
           height="auto"
           height="auto"
@@ -114,17 +205,31 @@ const DeploymentDetail = () => {
           </Message>
           </Message>
         </Modal>
         </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>
       <StyledExpandedChart>
-        <BreadcrumbRow>
-          <Breadcrumb
-            to={`/preview-environments/deployments/${environmentId}/${repository}`}
-          >
-            <ArrowIcon src={leftArrow} />
-            <Wrap>Back</Wrap>
-          </Breadcrumb>
-        </BreadcrumbRow>
         <HeaderWrapper>
         <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}
             {prDeployment.gh_pr_name}
           </Title>
           </Title>
           <InfoWrapper>
           <InfoWrapper>
@@ -135,7 +240,7 @@ const DeploymentDetail = () => {
               </PRLink>
               </PRLink>
             )}
             )}
             <TagWrapper>
             <TagWrapper>
-              Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+              Namespace <NamespaceTag>{prDeployment.namespace}</NamespaceTag>
             </TagWrapper>
             </TagWrapper>
           </InfoWrapper>
           </InfoWrapper>
           <Flex>
           <Flex>
@@ -159,19 +264,9 @@ const DeploymentDetail = () => {
               {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
               {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
             </DeploymentImageContainer>
             </DeploymentImageContainer>
             <Dot>•</Dot>
             <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 ? (
             {prDeployment.last_workflow_run_url ? (
               <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
               <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>
                 <i className="material-icons">open_in_new</i>
               </GHALink>
               </GHALink>
             ) : null}
             ) : null}
@@ -179,23 +274,26 @@ const DeploymentDetail = () => {
           <LinkToActionsWrapper></LinkToActionsWrapper>
           <LinkToActionsWrapper></LinkToActionsWrapper>
         </HeaderWrapper>
         </HeaderWrapper>
         {porterYAMLErrors.length > 0 ? (
         {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}
         ) : null}
         <ChartListWrapper>
         <ChartListWrapper>
           <ChartList
           <ChartList
             currentCluster={context.currentCluster}
             currentCluster={context.currentCluster}
             currentView="cluster-dashboard"
             currentView="cluster-dashboard"
             sortType="Newest"
             sortType="Newest"
-            namespace={params.namespace}
+            namespace={prDeployment.namespace}
             disableBottomPadding
             disableBottomPadding
             closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
             closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
           />
           />
@@ -207,6 +305,15 @@ const DeploymentDetail = () => {
 
 
 export default DeploymentDetail;
 export default DeploymentDetail;
 
 
+const ErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
 const ArrowIcon = styled.img`
 const ArrowIcon = styled.img`
   width: 15px;
   width: 15px;
   margin-right: 8px;
   margin-right: 8px;
@@ -232,16 +339,17 @@ const Message = styled.div`
 const BreadcrumbRow = styled.div`
 const BreadcrumbRow = styled.div`
   width: 100%;
   width: 100%;
   display: flex;
   display: flex;
+  margin-top: -5px;
   justify-content: flex-start;
   justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 15px;
 `;
 `;
 
 
 const Breadcrumb = styled(DynamicLink)`
 const Breadcrumb = styled(DynamicLink)`
   color: #aaaabb88;
   color: #aaaabb88;
   font-size: 13px;
   font-size: 13px;
-  margin-bottom: 15px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  margin-top: -10px;
   z-index: 999;
   z-index: 999;
   padding: 5px;
   padding: 5px;
   padding-right: 7px;
   padding-right: 7px;
@@ -271,7 +379,6 @@ const GHALink = styled(DynamicLink)`
   align-items: center;
   align-items: center;
 
 
   :hover {
   :hover {
-    text-decoration: underline;
     color: white;
     color: white;
   }
   }
 
 
@@ -280,10 +387,7 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-right: 9px;
     margin-left: 5px;
     margin-left: 5px;
 
 
-    text-decoration: none;
-
     :hover {
     :hover {
-      text-decoration: underline;
       color: white;
       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`
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   width: calc(100% - 0px);
   height: 1px;
   height: 1px;
@@ -388,6 +504,11 @@ const Icon = styled.img`
   width: 100%;
   width: 100%;
 `;
 `;
 
 
+const GitIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
 const StyledExpandedChart = styled.div`
 const StyledExpandedChart = styled.div`
   width: 100%;
   width: 100%;
   z-index: 0;
   z-index: 0;
@@ -452,7 +573,6 @@ const PRLink = styled(DynamicLink)`
 const ChartListWrapper = styled.div`
 const ChartListWrapper = styled.div`
   width: 100%;
   width: 100%;
   margin: auto;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
   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 Loading from "components/Loading";
 import _ from "lodash";
 import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
 import DeploymentCard from "./DeploymentCard";
-import { PRDeployment, PullRequest } from "../types";
+import { Environment, PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
 import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
 import { deployments, pull_requests } from "../mocks";
@@ -14,66 +14,31 @@ import DashboardHeader from "../../DashboardHeader";
 import RadioFilter from "components/RadioFilter";
 import RadioFilter from "components/RadioFilter";
 import Placeholder from "components/Placeholder";
 import Placeholder from "components/Placeholder";
 import Banner from "components/Banner";
 import Banner from "components/Banner";
-import Modal from "main/home/modals/Modal";
 
 
 import pullRequestIcon from "assets/pull_request_icon.svg";
 import pullRequestIcon from "assets/pull_request_icon.svg";
 import filterOutline from "assets/filter-outline.svg";
 import filterOutline from "assets/filter-outline.svg";
 import sort from "assets/sort.svg";
 import sort from "assets/sort.svg";
 import { search } from "shared/search";
 import { search } from "shared/search";
 import { getPRDeploymentList, validatePorterYAML } from "../utils";
 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];
 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 DeploymentList = () => {
   const [sortOrder, setSortOrder] = useState("Newest");
   const [sortOrder, setSortOrder] = useState("Newest");
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
   const [hasError, setHasError] = useState(false);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>(
-    HARD_CODED_DEPLOYMENTS
-  );
+  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [searchValue, setSearchValue] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
@@ -87,7 +52,9 @@ const DeploymentList = () => {
     setStatusSelectorVal,
     setStatusSelectorVal,
   ] = useState<AvailableStatusFiltersType>("all");
   ] = useState<AvailableStatusFiltersType>("all");
 
 
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
   const { getQueryParam, pushQueryParams } = useRouting();
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const location = useLocation();
   const history = useHistory();
   const history = useHistory();
@@ -169,9 +136,7 @@ const DeploymentList = () => {
           }
           }
 
 
           setPorterYAMLErrors(porterYAMLErrors);
           setPorterYAMLErrors(porterYAMLErrors);
-          setDeploymentList(
-            deploymentList.deployments || HARD_CODED_DEPLOYMENTS
-          );
+          setDeploymentList(deploymentList.deployments ?? []);
           setPullRequests(deploymentList.pull_requests || []);
           setPullRequests(deploymentList.pull_requests || []);
 
 
           setNewCommentsDisabled(
           setNewCommentsDisabled(
@@ -181,8 +146,9 @@ const DeploymentList = () => {
           setIsLoading(false);
           setIsLoading(false);
         }
         }
       )
       )
-      .catch(() => {
-        setDeploymentList(HARD_CODED_DEPLOYMENTS);
+      .catch((err) => {
+        setDeploymentList([]);
+        setCurrentError(err);
       });
       });
 
 
     return () => {
     return () => {
@@ -198,7 +164,7 @@ const DeploymentList = () => {
         clusterID: currentCluster.id,
         clusterID: currentCluster.id,
         environmentID: Number(environment_id),
         environmentID: Number(environment_id),
       });
       });
-      setDeploymentList(data.deployments || []);
+      setDeploymentList(data.deployments ?? []);
       setPullRequests(data.pull_requests || []);
       setPullRequests(data.pull_requests || []);
     } catch (error) {
     } catch (error) {
       setHasError(true);
       setHasError(true);
@@ -207,19 +173,6 @@ const DeploymentList = () => {
     setIsLoading(false);
     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 searchFilter = (value: string | number) => {
     const val = String(value);
     const val = String(value);
 
 
@@ -227,9 +180,21 @@ const DeploymentList = () => {
   };
   };
 
 
   const filteredDeployments = useMemo(() => {
   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>(
     const filteredBySearch = search<PRDeployment>(
       filteredByStatus,
       filteredByStatus,
@@ -273,8 +238,8 @@ const DeploymentList = () => {
     if (!deploymentList.length) {
     if (!deploymentList.length) {
       return (
       return (
         <Placeholder height="calc(100vh - 400px)">
         <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>
         </Placeholder>
       );
       );
     }
     }
@@ -282,23 +247,13 @@ const DeploymentList = () => {
     if (!filteredDeployments.length) {
     if (!filteredDeployments.length) {
       return (
       return (
         <Placeholder height="calc(100vh - 400px)">
         <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>
         </Placeholder>
       );
       );
     }
     }
 
 
     return (
     return (
       <>
       <>
-        {/* Deprecated -> New Preview Env button */}
-        {/* {filteredPullRequests.map((pr) => {
-          return (
-            <PullRequestCard
-              key={pr.pr_title}
-              pullRequest={pr}
-              onCreation={handlePreviewEnvironmentManualCreation}
-            />
-          );
-        })} */}
         {filteredDeployments.map((d: any) => {
         {filteredDeployments.map((d: any) => {
           return (
           return (
             <DeploymentCard
             <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(() => {
   useEffect(() => {
     pushQueryParams({ status_filter: statusSelectorVal });
     pushQueryParams({ status_filter: statusSelectorVal });
   }, [statusSelectorVal]);
   }, [statusSelectorVal]);
 
 
   return (
   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>
       <BreadcrumbRow>
         <Breadcrumb to="/preview-environments">
         <Breadcrumb to="/preview-environments">
           <ArrowIcon src={pullRequestIcon} />
           <ArrowIcon src={pullRequestIcon} />
@@ -383,55 +309,19 @@ const DeploymentList = () => {
         capitalize={false}
         capitalize={false}
       />
       />
       {porterYAMLErrors.length > 0 ? (
       {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}
       ) : 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>
       <FlexRow>
         <Flex>
         <Flex>
           <SearchRowWrapper>
           <SearchRowWrapper>
@@ -473,6 +363,9 @@ const DeploymentList = () => {
             name="Sort"
             name="Sort"
           />
           />
           <CreatePreviewEnvironmentButton
           <CreatePreviewEnvironmentButton
+            disabled={porterYAMLErrors.some(
+              (err) => err === PorterYAMLErrors.FileNotFound
+            )}
             to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
             to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
           >
           >
             <i className="material-icons">add</i> New preview deployment
             <i className="material-icons">add</i> New preview deployment
@@ -547,6 +440,7 @@ const Message = styled.div`
 
 
 const BreadcrumbRow = styled.div`
 const BreadcrumbRow = styled.div`
   width: 100%;
   width: 100%;
+  margin-top: 5px;
   display: flex;
   display: flex;
   justify-content: flex-start;
   justify-content: flex-start;
 `;
 `;
@@ -583,41 +477,6 @@ const Flex = styled.div`
   align-items: center;
   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`
 const RefreshButton = styled.button`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -651,15 +510,6 @@ const EventsGrid = styled.div`
   grid-template-columns: 1;
   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`
 const SearchInput = styled.input`
   outline: none;
   outline: none;
   border: none;
   border: none;
@@ -748,3 +598,7 @@ const CreatePreviewEnvironmentButton = styled(DynamicLink)`
     justify-content: center;
     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 styled from "styled-components";
 import pr_icon from "assets/pull_request_icon.svg";
 import pr_icon from "assets/pull_request_icon.svg";
 import { PullRequest } from "../types";
 import { PullRequest } from "../types";
-import { integrationList } from "shared/common";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ActionButton } from "../components/ActionButton";
 import { ActionButton } from "../components/ActionButton";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import DynamicLink from "components/DynamicLink";
 import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
 import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 
 
@@ -193,37 +191,6 @@ const StatusDot = styled.div`
   margin-left: 3px;
   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`
 const Tooltip = styled.div`
   position: absolute;
   position: absolute;
   left: 14px;
   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 DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import React, { useContext, useEffect, useState } from "react";
 import React, { useContext, useEffect, useState } from "react";
-import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
-import { CellProps } from "react-table";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { useParams } from "react-router";
 import { useParams } from "react-router";
-import { PRDeployment, PullRequest } from "../types";
+import { PullRequest } from "../types";
 import DashboardHeader from "../../DashboardHeader";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
-import Table from "components/Table";
 import pr_icon from "assets/pull_request_icon.svg";
 import pr_icon from "assets/pull_request_icon.svg";
+import api from "shared/api";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import { useQuery, useQueryClient } from "@tanstack/react-query";
 import { useQuery, useQueryClient } from "@tanstack/react-query";
 import { getPRDeploymentList, validatePorterYAML } from "../utils";
 import { getPRDeploymentList, validatePorterYAML } from "../utils";
 import Banner from "components/Banner";
 import Banner from "components/Banner";
 import Modal from "main/home/modals/Modal";
 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 = () => {
 const CreateEnvironment: React.FC = () => {
-  // TODO Soham: Replace any
+  const router = useRouting();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
-  const [modalContent, setModalContent] = useState<React.ReactNode>();
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
   const { currentProject, currentCluster, setCurrentError } = useContext(
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
     Context
   );
   );
@@ -74,12 +47,9 @@ const CreateEnvironment: React.FC = () => {
       } catch (err) {
       } catch (err) {
         setCurrentError(err);
         setCurrentError(err);
       }
       }
-
-      // TODO Soham: Replace with actual data
-      return dummyData; // [];
     }
     }
   );
   );
-  
+
   const [selectedPR, setSelectedPR] = useState<PullRequest>();
   const [selectedPR, setSelectedPR] = useState<PullRequest>();
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
   const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
   const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
@@ -94,6 +64,7 @@ const CreateEnvironment: React.FC = () => {
       projectID: currentProject.id,
       projectID: currentProject.id,
       clusterID: currentCluster.id,
       clusterID: currentCluster.id,
       environmentID: Number(environment_id),
       environmentID: Number(environment_id),
+      branch: pullRequest.branch_from,
     });
     });
 
 
     setPorterYAMLErrors(res.data.errors ?? []);
     setPorterYAMLErrors(res.data.errors ?? []);
@@ -101,63 +72,20 @@ const CreateEnvironment: React.FC = () => {
     setLoading(false);
     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 (
   return (
     <>
     <>
@@ -185,59 +113,115 @@ const CreateEnvironment: React.FC = () => {
         <Code>porter.yaml</Code> file.
         <Code>porter.yaml</Code> file.
       </Helper>
       </Helper>
       <Br height="10px" />
       <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}
       ) : null}
       {selectedPR && porterYAMLErrors.length ? (
       {selectedPR && porterYAMLErrors.length ? (
         <ValidationErrorBannerWrapper>
         <ValidationErrorBannerWrapper>
           <Banner type="warning">
           <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
               Learn more
             </LearnMoreButton>
             </LearnMoreButton>
           </Banner>
           </Banner>
         </ValidationErrorBannerWrapper>
         </ValidationErrorBannerWrapper>
       ) : null}
       ) : 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;
 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`
 const SepDot = styled.div`
@@ -307,7 +291,7 @@ const MergeInfo = styled.div`
 
 
 const PRIcon = styled.img`
 const PRIcon = styled.img`
   font-size: 20px;
   font-size: 20px;
-  height: 17px;
+  height: 16px;
   margin-right: 10px;
   margin-right: 10px;
   color: #aaaabb;
   color: #aaaabb;
   opacity: 50%;
   opacity: 50%;
@@ -337,7 +321,6 @@ const SubmitButton = styled.div`
   height: 30px;
   height: 30px;
   padding: 0 8px;
   padding: 0 8px;
   width: 200px;
   width: 200px;
-  margin-top: 30px;
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
@@ -370,48 +353,11 @@ const DarkMatter = styled.div`
   margin-top: -15px;
   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 }>`
 const Br = styled.div<{ height: string }>`
   width: 100%;
   width: 100%;
   height: ${(props) => props.height || "2px"};
   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`
 const Slash = styled.div`
   margin: 0 4px;
   margin: 0 4px;
   color: #aaaabb88;
   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`
 const ValidationErrorBannerWrapper = styled.div`
   margin-block: 20px;
   margin-block: 20px;
 `;
 `;
 
 
 const LearnMoreButton = styled.div`
 const LearnMoreButton = styled.div`
+  text-decoration: underline;
   fontweight: bold;
   fontweight: bold;
   cursor: pointer;
   cursor: pointer;
-  &:hover {
-    text-decoration: underline;
-  }
 `;
 `;
 
 
 const Message = styled.div`
 const Message = styled.div`
@@ -539,3 +421,22 @@ const Message = styled.div`
   font-size: 13px;
   font-size: 13px;
   margin-top: 40px;
   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
             <RepoLink
               to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
               to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
               target="_blank"
               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>
               <i className="material-icons">open_in_new</i>
               View Repo
               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 DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 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 api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import { useParams } from "react-router";
 import { useParams } from "react-router";
@@ -14,20 +14,25 @@ import SaveButton from "components/SaveButton";
 import _ from "lodash";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import PageNotFound from "components/PageNotFound";
 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(
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
     Context
   );
   );
+  const [selectedBranches, setSelectedBranches] = useState([]);
   const [environment, setEnvironment] = useState<Environment>();
   const [environment, setEnvironment] = useState<Environment>();
   const [saveStatus, setSaveStatus] = useState("");
   const [saveStatus, setSaveStatus] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
@@ -35,6 +40,9 @@ const EnvironmentSettings: React.FC = () => {
     deploymentMode,
     deploymentMode,
     setDeploymentMode,
     setDeploymentMode,
   ] = useState<EnvironmentDeploymentMode>("manual");
   ] = useState<EnvironmentDeploymentMode>("manual");
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
   const {
   const {
     environment_id: environmentId,
     environment_id: environmentId,
     repo_name: repoName,
     repo_name: repoName,
@@ -60,8 +68,22 @@ const EnvironmentSettings: React.FC = () => {
       );
       );
 
 
       setEnvironment(environment);
       setEnvironment(environment);
-      setNewCommentsDisabled(environment.disable_new_comments);
+      setSelectedBranches(environment.git_repo_branches);
+      setNewCommentsDisabled(environment.new_comments_disabled);
       setDeploymentMode(environment.mode);
       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 {
     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 () => {
   const handleSave = async () => {
+    let annotations: Record<string, string> = {};
+
     setSaveStatus("loading");
     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 {
     try {
       await api.updateEnvironment(
       await api.updateEnvironment(
         "<token>",
         "<token>",
         {
         {
           mode: deploymentMode,
           mode: deploymentMode,
           disable_new_comments: newCommentsDisabled,
           disable_new_comments: newCommentsDisabled,
-          git_repo_branches: [],
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         },
         {
         {
           project_id: currentProject.id,
           project_id: currentProject.id,
@@ -95,8 +174,56 @@ const EnvironmentSettings: React.FC = () => {
     setSaveStatus("");
     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 (
   return (
     <>
     <>
+      {showDeleteModal ? (
+        <DeletePreviewEnvironmentModal
+          repoOwner={repoOwner}
+          repoName={repoName}
+          onClose={closeDeleteConfirmationModal}
+          prompt={deleteConfirmationPrompt}
+          setPrompt={setDeleteConfirmationPrompt}
+          onDelete={handleDelete}
+          disabled={!canDelete}
+        />
+      ) : null}
       <BreadcrumbRow>
       <BreadcrumbRow>
         <Breadcrumb to={`/preview-environments/deployments/settings`}>
         <Breadcrumb to={`/preview-environments/deployments/settings`}>
           <ArrowIcon src={PullRequestIcon} />
           <ArrowIcon src={PullRequestIcon} />
@@ -117,6 +244,12 @@ const EnvironmentSettings: React.FC = () => {
         disableLineBreak
         disableLineBreak
         capitalize={false}
         capitalize={false}
       />
       />
+      <WarningBannerWrapper>
+        <Banner type="warning">
+          Changes made here will not affect existing deployments in this preview
+          environment.
+        </Banner>
+      </WarningBannerWrapper>
       <StyledPlaceholder>
       <StyledPlaceholder>
         <Heading isAtTop>Pull request comment settings</Heading>
         <Heading isAtTop>Pull request comment settings</Heading>
         <Helper>
         <Helper>
@@ -125,7 +258,7 @@ const EnvironmentSettings: React.FC = () => {
         </Helper>
         </Helper>
         <CheckboxRow
         <CheckboxRow
           label="Update the most recent PR comment"
           label="Update the most recent PR comment"
-          checked={!newCommentsDisabled}
+          checked={newCommentsDisabled}
           toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
           toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
         />
         />
         <Br />
         <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
         <SavePreviewEnvironmentSettings
           text={"Save"}
           text={"Save"}
           status={saveStatus}
           status={saveStatus}
@@ -156,7 +319,12 @@ const EnvironmentSettings: React.FC = () => {
           Delete the Porter preview environment integration for this repo. All
           Delete the Porter preview environment integration for this repo. All
           preview deployments will also be destroyed.
           preview deployments will also be destroyed.
         </Helper>
         </Helper>
-        <DeleteButton disabled={saveStatus === "loading"} onClick={_.noop}>
+        <DeleteButton
+          disabled={saveStatus === "loading"}
+          onClick={() => {
+            setShowDeleteModal(true);
+          }}
+        >
           Delete preview environment
           Delete preview environment
         </DeleteButton>
         </DeleteButton>
       </StyledPlaceholder>
       </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;
 export default EnvironmentSettings;
 
 
+const DeletePreviewEnvironmentModalContentsWrapper = styled.div`
+  margin-block-start: 25px;
+`;
+
 const SavePreviewEnvironmentSettings = styled(SaveButton)`
 const SavePreviewEnvironmentSettings = styled(SaveButton)`
   margin-top: 30px;
   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-size: 13px;
   font-weight: 500;
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   color: white;
   color: white;
   display: flex;
   display: flex;
-  width: 210px;
   align-items: center;
   align-items: center;
-  padding: 0 15px;
+  padding: 10px 15px;
   margin-top: 20px;
   margin-top: 20px;
   text-align: left;
   text-align: left;
   border-radius: 5px;
   border-radius: 5px;
-  cursor: pointer;
   user-select: none;
   user-select: none;
-  :focus {
-    outline: 0;
-  }
-  :hover {
-    filter: brightness(120%);
-  }
   background: #b91133;
   background: #b91133;
   border: none;
   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;
   display: flex;
   justify-content: flex-start;
   justify-content: flex-start;
   margin-bottom: 15px;
   margin-bottom: 15px;
-  margin-top: -10px;
+  margin-top: -5px;
   align-items: center;
   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 EnvironmentCard from "./EnvironmentCard";
 import Placeholder from "components/Placeholder";
 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 EnvironmentsList = () => {
   const { currentCluster, currentProject } = useContext(Context);
   const { currentCluster, currentProject } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [buttonIsReady, setButtonIsReady] = useState(false);
   const [buttonIsReady, setButtonIsReady] = useState(false);
 
 
-  const [environments, setEnvironments] = useState<Environment[]>(
-    HARD_CODED_ENVS
-  );
+  const [environments, setEnvironments] = useState<Environment[]>([]);
 
 
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
     setEnvironments((prev) => {
     setEnvironments((prev) => {
@@ -89,12 +56,8 @@ const EnvironmentsList = () => {
       }
       }
 
 
       setEnvironments(envs);
       setEnvironments(envs);
-
-      //
-      setEnvironments(HARD_CODED_ENVS);
     } catch (error) {
     } 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`}>
         <Route path={`${path}/connect-repo`}>
           <ConnectNewRepo />
           <ConnectNewRepo />
         </Route>
         </Route>
-        <Route path={`${path}/details/:namespace?`}>
+        <Route path={`${path}/details/:id`}>
           <DeploymentDetail />
           <DeploymentDetail />
         </Route>
         </Route>
         <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_owner: string;
   git_repo_name: string;
   git_repo_name: string;
   git_repo_branches: string[];
   git_repo_branches: string[];
-  disable_new_comments: boolean;
+  new_comments_disabled: boolean;
   last_deployment_status: DeploymentStatusUnion;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   deployment_count: number;
   mode: EnvironmentDeploymentMode;
   mode: EnvironmentDeploymentMode;
+  namespace_annotations: Record<string, string>;
 };
 };
 
 
 export type PullRequest = {
 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 { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
 import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
+import Banner from "components/Banner";
 
 
 type PropsType = RouteComponentProps &
 type PropsType = RouteComponentProps &
   WithAuthProps & {
   WithAuthProps & {
@@ -115,24 +116,18 @@ class Dashboard extends Component<PropsType, StateType> {
       );
       );
     } else if (this.currentTab() === "create-cluster") {
     } else if (this.currentTab() === "create-cluster") {
       let helperText = "Create a cluster to link to this project";
       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 =
         helperText =
           "You need to update your billing to provision or connect a new cluster";
           "You need to update your billing to provision or connect a new cluster";
-        helperIcon = "warning";
-        helperColor = "#f5cb42";
+        helperType = "warning";
       }
       }
       return (
       return (
         <>
         <>
-          <Banner color={helperColor}>
-            <i className="material-icons">{helperIcon}</i>
+          <Banner type={helperType} noMargin>
             {helperText}
             {helperText}
           </Banner>
           </Banner>
+          <Br />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
         </>
       );
       );
@@ -230,22 +225,22 @@ const DashboardWrapper = styled.div`
   padding-bottom: 100px;
   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`
 const TopRow = styled.div`
   display: flex;
   display: flex;

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

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

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

@@ -198,7 +198,7 @@ const FeedbackButton = styled(NavButton)`
   color: ${(props: { selected?: boolean }) =>
   color: ${(props: { selected?: boolean }) =>
     props.selected ? "#ffffff" : "#ffffff88"};
     props.selected ? "#ffffff" : "#ffffff88"};
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
-  font-size: 14px;
+  font-size: 13px;
   margin-right: 20px;
   margin-right: 20px;
   :hover {
   :hover {
     color: #ffffff;
     color: #ffffff;
@@ -213,8 +213,9 @@ const FeedbackButton = styled(NavButton)`
     > i {
     > i {
       color: ${(props: { selected?: boolean }) =>
       color: ${(props: { selected?: boolean }) =>
         props.selected ? "#ffffff" : "#ffffff88"};
         props.selected ? "#ffffff" : "#ffffff88"};
-      font-size: 22px;
+      font-size: 18px;
       margin-right: 6px;
       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`
 const StyledNavbar = styled.div`
-  height: 60px;
+  height: 50px;
   position: absolute;
   position: absolute;
   top: 0;
   top: 0;
   right: 0;
   right: 0;
@@ -229,7 +229,7 @@ const NavButton = styled.a`
   color: #ffffff88;
   color: #ffffff88;
   cursor: pointer;
   cursor: pointer;
   justify-content: center;
   justify-content: center;
-  margin-right: 15px;
+  margin-right: 10px;
   :hover {
   :hover {
     > i {
     > i {
       color: #ffffff;
       color: #ffffff;
@@ -241,6 +241,6 @@ const NavButton = styled.a`
     cursor: pointer;
     cursor: pointer;
     color: ${(props: { selected?: boolean }) =>
     color: ${(props: { selected?: boolean }) =>
       props.selected ? "#ffffff" : "#ffffff88"};
       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";
     mode: "auto" | "manual";
     disable_new_comments: boolean;
     disable_new_comments: boolean;
     git_repo_branches: string[];
     git_repo_branches: string[];
+    namespace_annotations: Record<string, string>;
   },
   },
   {
   {
     project_id: number;
     project_id: number;
@@ -175,6 +176,7 @@ const updateEnvironment = baseApi<
     mode: "auto" | "manual";
     mode: "auto" | "manual";
     disable_new_comments: boolean;
     disable_new_comments: boolean;
     git_repo_branches: string[]; // Array with branch names
     git_repo_branches: string[]; // Array with branch names
+    namespace_annotations: Record<string, string>;
   },
   },
   {
   {
     project_id: number;
     project_id: number;
@@ -445,9 +447,9 @@ const getPRDeploymentList = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
 });
 });
 
 
-const getPRDeploymentByEnvironment = baseApi<
+const getPRDeploymentByID = baseApi<
   {
   {
-    namespace: string;
+    id: number;
   },
   },
   {
   {
     cluster_id: number;
     cluster_id: number;
@@ -460,27 +462,28 @@ const getPRDeploymentByEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/deployment`;
   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<
 const deletePRDeployment = baseApi<
   {},
   {},
@@ -2436,8 +2439,8 @@ export default {
   getClusterNode,
   getClusterNode,
   getConfigMap,
   getConfigMap,
   getPRDeploymentList,
   getPRDeploymentList,
-  getPRDeploymentByEnvironment,
-  getPRDeployment,
+  getPRDeploymentByID,
+  // getPRDeployment,
   getGHAWorkflowTemplate,
   getGHAWorkflowTemplate,
   getGitRepoList,
   getGitRepoList,
   getGitRepoPermission,
   getGitRepoPermission,

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

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