Răsfoiți Sursa

Merge branch 'master' of github.com:porter-dev/porter into nico/por-409-build-settings-tab

jnfrati 4 ani în urmă
părinte
comite
3788dc402d
41 a modificat fișierele cu 1183 adăugiri și 710 ștergeri
  1. 0 1
      .github/workflows/dev.yaml
  2. 5 0
      .github/workflows/production.yaml
  3. 5 0
      .github/workflows/staging.yaml
  4. 80 8
      api/server/handlers/environment/list_deployments_by_cluster.go
  5. 131 0
      api/server/handlers/environment/trigger_deployment_workflow.go
  6. 105 0
      api/server/handlers/gitinstallation/rerun_workflow.go
  7. 15 0
      api/server/handlers/infra/forms.go
  8. 29 0
      api/server/router/cluster.go
  9. 36 0
      api/server/router/git_installation.go
  10. 12 9
      api/types/environment.go
  11. 10 1
      cmd/docker-credential-porter/helper/helper.go
  12. 1 0
      dashboard/src/components/OptionsDropdown.tsx
  13. 66 79
      dashboard/src/components/repo-selector/RepoList.tsx
  14. 186 0
      dashboard/src/hosted.index.html
  15. 0 144
      dashboard/src/index.html
  16. 12 5
      dashboard/src/main/CurrentError.tsx
  17. 10 5
      dashboard/src/main/Main.tsx
  18. 3 3
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  19. 5 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  20. 2 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  21. 3 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  22. 29 63
      dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx
  23. 7 66
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  24. 84 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/styled.tsx
  25. 83 55
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  26. 21 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  27. 111 112
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  28. 8 35
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  29. 13 46
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  30. 19 7
      dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts
  31. 10 4
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  32. 16 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  33. 2 2
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  34. 1 1
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  35. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  36. 28 1
      dashboard/src/shared/api.tsx
  37. 2 2
      dashboard/src/shared/hooks/useChart.ts
  38. 1 0
      dashboard/tsconfig.json
  39. 17 4
      dashboard/webpack.config.js
  40. 0 39
      internal/models/environment.go
  41. 14 1
      internal/registry/registry.go

+ 0 - 1
.github/workflows/dev.yaml

@@ -33,7 +33,6 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ENABLE_SENTRY=true

+ 5 - 0
.github/workflows/production.yaml

@@ -34,6 +34,11 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          IS_HOSTED=true
+          COHERE_KEY=${{secrets.COHERE_KEY}}
+          INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
+          INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
+          SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev

+ 5 - 0
.github/workflows/staging.yaml

@@ -33,6 +33,11 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          IS_HOSTED=true
+          COHERE_KEY=${{secrets.COHERE_KEY}}
+          INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
+          INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
+          SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.staging.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev

+ 80 - 8
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -56,6 +56,18 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 			deplInfoMap[fmt.Sprintf(
 				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
 			)] = true
+
+			env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, deployment.EnvironmentID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			updateDeploymentWithGithubWorkflowRunStatus(r.Context(), c.Config(), env, deployment)
+
+			deployment.InstallationID = env.GitInstallationID
+
 			deployments = append(deployments, deployment)
 		}
 
@@ -77,7 +89,14 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 			pullRequests = append(pullRequests, prs...)
 		}
 	} else {
-		depls, err := c.Repo().Environment().ListDeployments(req.EnvironmentID)
+		env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		depls, err := c.Repo().Environment().ListDeployments(env.ID)
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -91,14 +110,12 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 			deplInfoMap[fmt.Sprintf(
 				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
 			)] = true
-			deployments = append(deployments, deployment)
-		}
 
-		env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, req.EnvironmentID)
+			updateDeploymentWithGithubWorkflowRunStatus(r.Context(), c.Config(), env, deployment)
 
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			deployment.InstallationID = env.GitInstallationID
+
+			deployments = append(deployments, deployment)
 		}
 
 		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
@@ -117,6 +134,46 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 	})
 }
 
+func updateDeploymentWithGithubWorkflowRunStatus(
+	ctx context.Context,
+	config *config.Config,
+	env *models.Environment,
+	deployment *types.Deployment,
+) {
+	client, err := getGithubClientFromEnvironment(config, env)
+
+	if err == nil {
+		workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+			ctx, deployment.RepoOwner, deployment.RepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), &github.ListWorkflowRunsOptions{
+				Branch: deployment.PRBranchFrom,
+				ListOptions: github.ListOptions{
+					Page:    1,
+					PerPage: 1,
+				},
+			},
+		)
+
+		if err == nil && workflowRuns.GetTotalCount() > 0 {
+			latestWorkflowRun := workflowRuns.WorkflowRuns[0]
+
+			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
+
+			if (latestWorkflowRun.GetStatus() == "in_progress" ||
+				latestWorkflowRun.GetStatus() == "queued") &&
+				deployment.Status != types.DeploymentStatusCreating {
+				deployment.Status = types.DeploymentStatusUpdating
+			} else if latestWorkflowRun.GetStatus() == "completed" {
+				if latestWorkflowRun.GetConclusion() == "failure" {
+					deployment.Status = types.DeploymentStatusFailed
+				} else if latestWorkflowRun.GetConclusion() == "timed_out" {
+					deployment.Status = types.DeploymentStatusTimedOut
+				}
+			}
+		}
+	}
+}
+
 func fetchOpenPullRequests(
 	ctx context.Context,
 	config *config.Config,
@@ -132,7 +189,7 @@ func fetchOpenPullRequests(
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{
-				PerPage: 50,
+				PerPage: 100,
 			},
 		},
 	)
@@ -147,6 +204,21 @@ func fetchOpenPullRequests(
 		return nil, err
 	}
 
+	var ghPRs []*github.PullRequest
+
+	for resp.NextPage != 0 && err == nil {
+		ghPRs, resp, err = client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
+			&github.PullRequestListOptions{
+				ListOptions: github.ListOptions{
+					PerPage: 100,
+					Page:    resp.NextPage,
+				},
+			},
+		)
+
+		openPRs = append(openPRs, ghPRs...)
+	}
+
 	for _, pr := range openPRs {
 		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
 			prs = append(prs, &types.PullRequest{

+ 131 - 0
api/server/handlers/environment/trigger_deployment_workflow.go

@@ -0,0 +1,131 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+
+type TriggerDeploymentWorkflowHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewTriggerDeploymentWorkflowHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *TriggerDeploymentWorkflowHandler {
+	return &TriggerDeploymentWorkflowHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Status == types.DeploymentStatusInactive {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := getLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name))
+
+	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if latestWorkflowRun.GetStatus() == "in_progress" || latestWorkflowRun.GetStatus() == "queued" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow already in progress"), 409))
+		return
+	}
+
+	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: depl.PRBranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(depl.PullRequestID), 10),
+				"pr_title":       depl.PRName,
+				"pr_branch_from": depl.PRBranchFrom,
+				"pr_branch_into": depl.PRBranchInto,
+			},
+		},
+	)
+
+	if ghResp != nil && ghResp.StatusCode == 404 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow file not found"), 404))
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
+	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

+ 105 - 0
api/server/handlers/gitinstallation/rerun_workflow.go

@@ -0,0 +1,105 @@
+package gitinstallation
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+
+type RerunWorkflowHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRerunWorkflowHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RerunWorkflowHandler {
+	return &RerunWorkflowHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	filename := r.URL.Query().Get("filename")
+
+	if filename == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename query param not set")))
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := getLatestWorkflowRun(client, owner, name, filename)
+
+	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if latestWorkflowRun.GetStatus() == "in_progress" || latestWorkflowRun.GetStatus() == "queued" {
+		w.WriteHeader(409)
+		c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
+		return
+	}
+
+	_, err = client.Actions.RerunWorkflowByID(r.Context(), owner, name, latestWorkflowRun.GetID())
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err = getLatestWorkflowRun(client, owner, name, filename)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
+}
+
+func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
+	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

+ 15 - 0
api/server/handlers/infra/forms.go

@@ -273,6 +273,8 @@ tabs:
           value: "13.3"
         - label: "v13.4"
           value: "13.4"
+        - label: "v13.6"
+          value: "13.6"
   - name: additional-settings
     contents:
     - type: heading
@@ -379,11 +381,24 @@ tabs:
       required: true
       placeholder: my-cluster
       variable: cluster_name
+    - type: number-input
+      label: Maximum number of EC2 instances to create in the application autoscaling group.
+      variable: max_instances
+      placeholder: "ex: 10"
+      settings:
+        default: 10
     - type: checkbox
       variable: spot_instances_enabled
       label: Enable spot instances for this cluster.
       settings:
         default: false
+  - name: spot_instance_price
+    show_if: spot_instances_enabled
+    contents:
+    - type: string-input
+      label: Assign a bid price for the spot instance (optional).
+      variable: spot_price
+      placeholder: "ex: 0.05"
 `
 
 const gcrForm = `name: GCR

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

@@ -405,6 +405,35 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/trigger_workflow -> environment.NewTriggerDeploymentWorkflowHandler
+		triggerDeploymentWorkflowEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}/trigger_workflow",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		triggerDeploymentWorkflowHandler := environment.NewTriggerDeploymentWorkflowHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: triggerDeploymentWorkflowEndpoint,
+			Handler:  triggerDeploymentWorkflowHandler,
+			Router:   r,
+		})
+
 		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/pull_request -> environment.NewEnablePullRequestHandler
 		enablePullRequestEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{

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

@@ -616,5 +616,41 @@ func getGitInstallationRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/rerun_workflow ->
+	// gitinstallation.NewRerunWorkflowHandler
+	rerunWorkflowEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{%s}/{%s}/clusters/{cluster_id}/rerun_workflow",
+					relPath,
+					types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	rerunWorkflowHandler := gitinstallation.NewRerunWorkflowHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: rerunWorkflowEndpoint,
+		Handler:  rerunWorkflowHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 12 - 9
api/types/environment.go

@@ -36,22 +36,25 @@ type DeploymentStatus string
 const (
 	DeploymentStatusCreated  DeploymentStatus = "created"
 	DeploymentStatusCreating DeploymentStatus = "creating"
+	DeploymentStatusUpdating DeploymentStatus = "updating"
 	DeploymentStatusInactive DeploymentStatus = "inactive"
+	DeploymentStatusTimedOut DeploymentStatus = "timed_out"
 	DeploymentStatusFailed   DeploymentStatus = "failed"
 )
 
 type Deployment struct {
 	*GitHubMetadata
 
-	ID                uint             `json:"id"`
-	CreatedAt         time.Time        `json:"created_at"`
-	UpdatedAt         time.Time        `json:"updated_at"`
-	GitInstallationID uint             `json:"git_installation_id"`
-	EnvironmentID     uint             `json:"environment_id"`
-	Namespace         string           `json:"namespace"`
-	Status            DeploymentStatus `json:"status"`
-	Subdomain         string           `json:"subdomain"`
-	PullRequestID     uint             `json:"pull_request_id"`
+	ID                 uint             `json:"id"`
+	CreatedAt          time.Time        `json:"created_at"`
+	UpdatedAt          time.Time        `json:"updated_at"`
+	EnvironmentID      uint             `json:"environment_id"`
+	Namespace          string           `json:"namespace"`
+	Status             DeploymentStatus `json:"status"`
+	Subdomain          string           `json:"subdomain"`
+	PullRequestID      uint             `json:"pull_request_id"`
+	InstallationID     uint             `json:"gh_installation_id"`
+	LastWorkflowRunURL string           `json:"last_workflow_run_url"`
 }
 
 type CreateGHDeploymentRequest struct {

+ 10 - 1
cmd/docker-credential-porter/helper/helper.go

@@ -2,6 +2,7 @@ package helper
 
 import (
 	"github.com/docker/docker-credential-helpers/credentials"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
@@ -21,11 +22,19 @@ func NewPorterHelper(debug bool) *PorterHelper {
 	cliConfig := config.InitAndLoadNewConfig()
 	cache := docker.NewFileCredentialsCache()
 
+	var client *api.Client
+
+	if token := cliConfig.Token; token != "" {
+		client = api.NewClientWithToken(cliConfig.Host+"/api", token)
+	} else {
+		client = api.NewClient(cliConfig.Host+"/api", "cookie.json")
+	}
+
 	return &PorterHelper{
 		Debug:     debug,
 		ProjectID: cliConfig.Project,
 		AuthGetter: &docker.AuthGetter{
-			Client:    config.GetAPIClient(),
+			Client:    client,
 			Cache:     cache,
 			ProjectID: cliConfig.Project,
 		},

+ 1 - 0
dashboard/src/components/OptionsDropdown.tsx

@@ -9,6 +9,7 @@ export const OptionsDropdown: React.FC<{
 
   const handleClick = (e: any) => {
     e.stopPropagation();
+    e.preventDefault();
     setIsOpen(!isOpen);
   };
 

+ 66 - 79
dashboard/src/components/repo-selector/RepoList.tsx

@@ -42,87 +42,74 @@ const RepoList: React.FC<Props> = ({
   const [searchFilter, setSearchFilter] = useState(null);
   const { currentProject } = useContext(Context);
 
+  const loadData = async () => {
+    try {
+      const { data } = await api.getGithubAccounts("<token>", {}, {});
+
+      setAccessData(data);
+      setAccessLoading(false);
+    } catch (error) {
+      setAccessError(true);
+      setAccessLoading(false);
+    }
+
+    let ids: number[] = [];
+
+    if (!userId && userId !== 0) {
+      ids = await api
+        .getGitRepos("token", {}, { project_id: currentProject.id })
+        .then((res) => res.data);
+    } else {
+      setRepoLoading(false);
+      setRepoError(true);
+      return;
+    }
+
+    const repoListPromises = ids.map((id) =>
+      api.getGitRepoList(
+        "<token>",
+        {},
+        { project_id: currentProject.id, git_repo_id: id }
+      )
+    );
+
+    try {
+      const resolvedRepoList = await Promise.allSettled(repoListPromises);
+
+      const repos: RepoType[][] = resolvedRepoList.map((repo) =>
+        repo.status === "fulfilled" ? repo.value.data : []
+      );
+
+      const names = new Set();
+      // note: would be better to use .flat() here but you need es2019 for
+      setRepos(
+        repos
+          .map((arr, idx) =>
+            arr.map((el) => {
+              el.GHRepoID = ids[idx];
+              return el;
+            })
+          )
+          .reduce((acc, val) => acc.concat(val), [])
+          .reduce((acc, val) => {
+            if (!names.has(val.FullName)) {
+              names.add(val.FullName);
+              return acc.concat(val);
+            } else {
+              return acc;
+            }
+          }, [])
+      );
+      setRepoLoading(false);
+    } catch (err) {
+      setRepoLoading(false);
+      setRepoError(true);
+    }
+  };
+
   // TODO: Try to unhook before unmount
   useEffect(() => {
-    api
-      .getGithubAccounts("<token>", {}, {})
-      .then(({ data }) => {
-        setAccessData(data);
-        setAccessLoading(false);
-      })
-      .catch(() => {
-        setAccessError(true);
-        setAccessLoading(false);
-      })
-      .finally(() => {
-        // load git repo ids, and then repo names from that
-        // this only happens once during the lifecycle
-        new Promise((resolve, reject) => {
-          if (!userId && userId !== 0) {
-            api
-              .getGitRepos("<token>", {}, { project_id: currentProject.id })
-              .then(async (res) => {
-                resolve(res.data);
-              })
-              .catch(() => {
-                resolve([]);
-              });
-          } else {
-            reject(null);
-          }
-        })
-          .then((ids: number[]) => {
-            Promise.all(
-              ids.map((id) => {
-                return new Promise((resolve, reject) => {
-                  api
-                    .getGitRepoList(
-                      "<token>",
-                      {},
-                      { project_id: currentProject.id, git_repo_id: id }
-                    )
-                    .then((res) => {
-                      resolve(res.data);
-                    })
-                    .catch((err) => {
-                      reject(err);
-                    });
-                });
-              })
-            )
-              .then((repos: RepoType[][]) => {
-                const names = new Set();
-                // note: would be better to use .flat() here but you need es2019 for
-                setRepos(
-                  repos
-                    .map((arr, idx) =>
-                      arr.map((el) => {
-                        el.GHRepoID = ids[idx];
-                        return el;
-                      })
-                    )
-                    .reduce((acc, val) => acc.concat(val), [])
-                    .reduce((acc, val) => {
-                      if (!names.has(val.FullName)) {
-                        names.add(val.FullName);
-                        return acc.concat(val);
-                      } else {
-                        return acc;
-                      }
-                    }, [])
-                );
-                setRepoLoading(false);
-              })
-              .catch((_) => {
-                setRepoLoading(false);
-                setRepoError(true);
-              });
-          })
-          .catch((_) => {
-            setRepoLoading(false);
-            setRepoError(true);
-          });
-      });
+    loadData();
   }, []);
 
   // clear out actionConfig and SelectedRepository if new search is performed

+ 186 - 0
dashboard/src/hosted.index.html

@@ -0,0 +1,186 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>Porter | Dashboard</title>
+
+    <script>
+      !(function () {
+        var e = (window.Cohere = window.Cohere || []);
+        if (e.invoked) console.error("Tried to load Cohere twice");
+        else {
+          (e.invoked = !0),
+            (e.snippet = "0.2"),
+            (e.methods = [
+              "init",
+              "identify",
+              "stop",
+              "showCode",
+              "getSessionUrl",
+              "makeCall",
+              "addCallStatusListener",
+              "removeCallStatusListener",
+              "widget",
+            ]),
+            e.methods.forEach(function (o) {
+              e[o] = function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(o), e.push(t);
+              };
+            });
+          var o = document.createElement("script");
+          (o.type = "text/javascript"),
+            (o.async = !0),
+            (o.src = "https://static.cohere.so/main.js"),
+            (o.crossOrigin = "anonymous");
+          var t = document.getElementsByTagName("script")[0];
+          t.parentNode.insertBefore(o, t);
+        }
+      })();
+      window.Cohere.init("<%= htmlWebpackPlugin.options.cohereKey %>");
+    </script>
+
+    <script>
+      window.intercomSettings = {
+        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+        custom_launcher_selector: "#intercom_help",
+      };
+    </script>
+
+    <script>
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
+        } else {
+          var d = document;
+          var i = function () {
+            i.c(arguments);
+          };
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
+          };
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (document.readyState === "complete") {
+            l();
+          } else if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
+        }
+      })();
+    </script>
+
+    <script>
+      !(function () {
+        var analytics = (window.analytics = window.analytics || []);
+        if (!analytics.initialize)
+          if (analytics.invoked)
+            window.console &&
+              console.error &&
+              console.error("Segment snippet included twice.");
+          else {
+            analytics.invoked = !0;
+            analytics.methods = [
+              "trackSubmit",
+              "trackClick",
+              "trackLink",
+              "trackForm",
+              "pageview",
+              "identify",
+              "reset",
+              "group",
+              "track",
+              "ready",
+              "alias",
+              "debug",
+              "page",
+              "once",
+              "off",
+              "on",
+              "addSourceMiddleware",
+              "addIntegrationMiddleware",
+              "setAnonymousId",
+              "addDestinationMiddleware",
+            ];
+            analytics.factory = function (e) {
+              return function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(e);
+                analytics.push(t);
+                return analytics;
+              };
+            };
+            for (var e = 0; e < analytics.methods.length; e++) {
+              var key = analytics.methods[e];
+              analytics[key] = analytics.factory(key);
+            }
+            analytics.load = function (key, e) {
+              var t = document.createElement("script");
+              t.type = "text/javascript";
+              t.async = !0;
+              t.src =
+                "https://cdn.segment.com/analytics.js/v1/" +
+                key +
+                "/analytics.min.js";
+              var n = document.getElementsByTagName("script")[0];
+              n.parentNode.insertBefore(t, n);
+              analytics._loadOptions = e;
+            };
+            analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
+            analytics.SNIPPET_VERSION = "4.13.2";
+            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+            analytics.page();
+          }
+      })();
+    </script>
+    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
+    <meta
+      name="description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:title" content="Porter" />
+    <meta
+      property="og:image"
+      content="https://i.ibb.co/52g2g7C/porter-wide.png"
+    />
+    <meta
+      property="og:description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:url" content="https://porter.run" />
+    <link
+      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
+      rel="stylesheet"
+    />
+    <link
+      href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
+      rel="stylesheet"
+    />
+    <!-- Coding languages icons -->
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
+    />
+  </head>
+  <body>
+    <div id="output"></div>
+    <div id="modal-root"></div>
+  </body>
+</html>

+ 0 - 144
dashboard/src/index.html

@@ -2,150 +2,6 @@
 <html lang="en">
   <head>
     <title>Porter | Dashboard</title>
-
-    <script>
-      !(function () {
-        var e = (window.Cohere = window.Cohere || []);
-        if (e.invoked) console.error("Tried to load Cohere twice");
-        else {
-          (e.invoked = !0),
-            (e.snippet = "0.2"),
-            (e.methods = [
-              "init",
-              "identify",
-              "stop",
-              "showCode",
-              "getSessionUrl",
-              "makeCall",
-              "addCallStatusListener",
-              "removeCallStatusListener",
-              "widget",
-            ]),
-            e.methods.forEach(function (o) {
-              e[o] = function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(o), e.push(t);
-              };
-            });
-          var o = document.createElement("script");
-          (o.type = "text/javascript"),
-            (o.async = !0),
-            (o.src = "https://static.cohere.so/main.js"),
-            (o.crossOrigin = "anonymous");
-          var t = document.getElementsByTagName("script")[0];
-          t.parentNode.insertBefore(o, t);
-        }
-      })();
-      window.Cohere.init("_A-2HNgriISqaQq4yzTxM8V-");
-    </script>
-
-    <script>
-      window.intercomSettings = {
-        app_id: "gq56g49i",
-        custom_launcher_selector: "#intercom_help",
-      };
-    </script>
-
-    <script>
-      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-      (function () {
-        var w = window;
-        var ic = w.Intercom;
-        if (typeof ic === "function") {
-          ic("reattach_activator");
-          ic("update", w.intercomSettings);
-        } else {
-          var d = document;
-          var i = function () {
-            i.c(arguments);
-          };
-          i.q = [];
-          i.c = function (args) {
-            i.q.push(args);
-          };
-          w.Intercom = i;
-          var l = function () {
-            var s = d.createElement("script");
-            s.type = "text/javascript";
-            s.async = true;
-            s.src = "https://widget.intercom.io/widget/gq56g49i";
-            var x = d.getElementsByTagName("script")[0];
-            x.parentNode.insertBefore(s, x);
-          };
-          if (document.readyState === "complete") {
-            l();
-          } else if (w.attachEvent) {
-            w.attachEvent("onload", l);
-          } else {
-            w.addEventListener("load", l, false);
-          }
-        }
-      })();
-    </script>
-
-    <script>
-      !(function () {
-        var analytics = (window.analytics = window.analytics || []);
-        if (!analytics.initialize)
-          if (analytics.invoked)
-            window.console &&
-              console.error &&
-              console.error("Segment snippet included twice.");
-          else {
-            analytics.invoked = !0;
-            analytics.methods = [
-              "trackSubmit",
-              "trackClick",
-              "trackLink",
-              "trackForm",
-              "pageview",
-              "identify",
-              "reset",
-              "group",
-              "track",
-              "ready",
-              "alias",
-              "debug",
-              "page",
-              "once",
-              "off",
-              "on",
-              "addSourceMiddleware",
-              "addIntegrationMiddleware",
-              "setAnonymousId",
-              "addDestinationMiddleware",
-            ];
-            analytics.factory = function (e) {
-              return function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(e);
-                analytics.push(t);
-                return analytics;
-              };
-            };
-            for (var e = 0; e < analytics.methods.length; e++) {
-              var key = analytics.methods[e];
-              analytics[key] = analytics.factory(key);
-            }
-            analytics.load = function (key, e) {
-              var t = document.createElement("script");
-              t.type = "text/javascript";
-              t.async = !0;
-              t.src =
-                "https://cdn.segment.com/analytics.js/v1/" +
-                key +
-                "/analytics.min.js";
-              var n = document.getElementsByTagName("script")[0];
-              n.parentNode.insertBefore(t, n);
-              analytics._loadOptions = e;
-            };
-            analytics._writeKey = "J6sN7XaMPOGIkA1ZGYMBU4UX37aPZ1Yb";
-            analytics.SNIPPET_VERSION = "4.13.2";
-            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-            analytics.page();
-          }
-      })();
-    </script>
     <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
     <meta
       name="description"

+ 12 - 5
dashboard/src/main/CurrentError.tsx

@@ -5,7 +5,7 @@ import close from "assets/close.png";
 import { Context } from "shared/Context";
 
 type PropsType = {
-  currentError: string;
+  currentError: any;
 };
 
 type StateType = {};
@@ -26,11 +26,18 @@ export default class CurrentError extends Component<PropsType, StateType> {
   }
 
   render() {
-    let currentError = this.props.currentError;
-    if (!React.isValidElement(this.props.currentError)) {
-      currentError = String(this.props.currentError);
+    if (!this.props.currentError) {
+      return null;
     }
-    if (this.props.currentError) {
+
+    // Check if it's an error from the API then retrieve the error message that we get from the API
+    let currentError =
+      this.props.currentError?.response?.data?.error || this.props.currentError;
+    if (!React.isValidElement(currentError)) {
+      currentError = String(currentError);
+    }
+
+    if (currentError) {
       if (!this.state.expanded) {
         return (
           <StyledCurrentError>

+ 10 - 5
dashboard/src/main/Main.tsx

@@ -5,7 +5,9 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import Cohere from "cohere-js";
 
-Cohere.init(process.env.COHERE_API_KEY);
+if (window.location.href.includes("dashboard.getporter.dev")) {
+  Cohere.init(process.env.COHERE_API_KEY);
+}
 
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
@@ -45,10 +47,13 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         if (res && res?.data) {
-          Cohere.identify(res?.data?.id, {
-            displayName: res?.data?.email,
-            email: res?.data?.email,
-          });
+          if (window.location.href.includes("dashboard.getporter.dev")) {
+            Cohere.identify(res?.data?.id, {
+              displayName: res?.data?.email,
+              email: res?.data?.email,
+            });
+          }
+
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -285,6 +285,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     let { setSidebar } = this.props;
     return (
       <Switch>
+        <Route path={"/preview-environments"}>
+          <LazyPreviewEnvironmentsRoutes />
+        </Route>
         <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
           <ExpandedChartWrapper
             setSidebar={setSidebar}
@@ -329,9 +332,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         >
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
-        <Route path={"/preview-environments"}>
-          <LazyPreviewEnvironmentsRoutes />
-        </Route>
         <Route path={"/databases"}>
           <LazyDatabasesRoutes />
         </Route>

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

@@ -339,7 +339,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       setSaveValueStatus("successful");
       setForceRefreshRevisions(true);
 
-      window.analytics.track("Chart Upgraded", {
+      window.analytics?.track("Chart Upgraded", {
         chart: currentChart.name,
         values: valuesYaml,
       });
@@ -354,7 +354,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
       setCurrentError(parsedErr);
 
-      window.analytics.track("Failed to Upgrade Chart", {
+      window.analytics?.track("Failed to Upgrade Chart", {
         chart: currentChart.name,
         values: valuesYaml,
         error: err,
@@ -393,7 +393,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus("successful");
         setForceRefreshRevisions(true);
 
-        window.analytics.track("Chart Upgraded", {
+        window.analytics?.track("Chart Upgraded", {
           chart: currentChart.name,
           values: valuesYaml,
         });
@@ -409,7 +409,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus(err);
         setCurrentError(parsedErr);
 
-        window.analytics.track("Failed to Upgrade Chart", {
+        window.analytics?.track("Failed to Upgrade Chart", {
           chart: currentChart.name,
           values: valuesYaml,
           error: err,
@@ -714,7 +714,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   useEffect(() => {
-    window.analytics.track("Opened Chart", {
+    window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
     });
 

+ 2 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -113,11 +113,8 @@ export const ExpandedJobChartFC: React.FC<{
   };
 
   const handleDeleteChart = async () => {
-    try {
-      await deleteChart();
-    } finally {
-      setCurrentOverlay(null);
-    }
+    deleteChart();
+    setCurrentOverlay(null);
   };
 
   const renderTabContents = (currentTab: string) => {

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

@@ -257,8 +257,8 @@ const HeaderSection = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 20px;
-    color: #969Fbbaa;
+    font-size: 20px;
+    color: #969fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;
     border-radius: 100px;
@@ -272,4 +272,4 @@ const HeaderSection = styled.div`
     margin-left: 17px;
     margin-right: 7px;
   }
-`;
+`;

+ 29 - 63
dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useCallback, useContext, useEffect, useState } from "react";
 import { useHistory, useLocation } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -11,10 +11,7 @@ import PullRequestIcon from "assets/pull_request_icon.svg";
 import DeploymentList from "./deployments/DeploymentList";
 import EnvironmentsList from "./environments/EnvironmentsList";
 import { environments } from "./mocks";
-
-const AvailableTabs = ["repositories", "pull_requests"];
-
-type TabEnum = typeof AvailableTabs[number];
+import { PreviewEnvironmentsHeader } from "./components/PreviewEnvironmentsHeader";
 
 const PreviewEnvironmentsHome = () => {
   const { currentCluster, currentProject } = useContext(Context);
@@ -22,11 +19,10 @@ const PreviewEnvironmentsHome = () => {
   const [hasGHAccountsLinked, setHasGHAccountsLinked] = useState(false);
   const [hasEnvironments, setHasEnvironments] = useState(false);
   const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
   const [environments, setEnvironments] = useState([]);
   const [selectedRepo, setSelectedRepo] = useState("");
 
-  const { getQueryParam, pushQueryParams } = useRouting();
+  const { getQueryParam } = useRouting();
   const location = useLocation();
   const history = useHistory();
 
@@ -107,21 +103,21 @@ const PreviewEnvironmentsHome = () => {
     setSelectedRepo(current_repo);
   }, [location.search, history]);
 
-  const renderMain = () => {
-    if (isLoading) {
-      return (
+  if (isLoading) {
+    return (
+      <>
+        <PreviewEnvironmentsHeader />
         <Placeholder>
           <Loading />
         </Placeholder>
-      );
-    }
-  
-    if (hasError) {
-      return <Placeholder>Something went wrong, please try again</Placeholder>;
-    }
-  
-    if (!hasGHAccountsLinked) {
-      return (
+      </>
+    );
+  }
+
+  if (!hasGHAccountsLinked) {
+    return (
+      <>
+        <PreviewEnvironmentsHeader />
         <Placeholder>
           <Title>There are no repositories linked</Title>
           <Subtitle>
@@ -130,11 +126,15 @@ const PreviewEnvironmentsHome = () => {
           </Subtitle>
           <ButtonEnablePREnvironments />
         </Placeholder>
-      );
-    }
-  
-    if (!hasEnvironments) {
-      return (
+      </>
+    );
+  }
+
+  if (!hasEnvironments) {
+    return (
+      <>
+        <PreviewEnvironmentsHeader />
+
         <Placeholder>
           <Title>Preview environments are not enabled on this cluster</Title>
           <Subtitle>
@@ -143,57 +143,23 @@ const PreviewEnvironmentsHome = () => {
           </Subtitle>
           <ButtonEnablePREnvironments />
         </Placeholder>
-      );
-    }
-
-    if (!selectedRepo) {
-      return (
-        <EnvironmentsList
-          environments={environments}
-          setEnvironments={setEnvironments}
-        />
-      );
-    }
-
-    return (
-      <DeploymentList
-        // selectedRepo={selectedRepo}
-        environments={environments}
-      />
+      </>
     );
   }
 
   return (
     <>
-      <DashboardHeader
-        image={PullRequestIcon}
-        title="Preview Environments"
-        description="Create full-stack preview environments for your pull requests."
+      <PreviewEnvironmentsHeader />
+      <EnvironmentsList
+        environments={environments}
+        setEnvironments={setEnvironments}
       />
-      {renderMain()}
     </>
   );
 };
 
-/*
-<DeploymentList environments={environments} />
-*/
 export default PreviewEnvironmentsHome;
 
-const mockRequest = () =>
-  new Promise((res) => {
-    setTimeout(() => {
-      res({ data: environments });
-    }, 1000);
-  });
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px 35px;
-`;
-
 const Placeholder = styled.div`
   padding: 30px;
   margin-top: 35px;

+ 7 - 66
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -1,73 +1,14 @@
 import React from "react";
-import TitleSection from "components/TitleSection";
 import styled from "styled-components";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 
 export const PreviewEnvironmentsHeader = () => (
   <>
-    <TitleSection>
-      <DashboardIcon>
-        <i className="material-icons">device_hub</i>
-      </DashboardIcon>
-      Preview environments
-    </TitleSection>
-    <InfoSection>
-      <TopRow>
-        <InfoLabel>
-          <i className="material-icons">info</i> Info
-        </InfoLabel>
-      </TopRow>
-      <Description>
-        Create preview environments for your pull requests
-      </Description>
-    </InfoSection>
+    <DashboardHeader
+      image={PullRequestIcon}
+      title="Preview Environments"
+      description="Create full-stack preview environments for your pull requests."
+    />
   </>
 );
-
-const DashboardIcon = styled.div`
-  height: 45px;
-  min-width: 45px;
-  width: 45px;
-  border-radius: 5px;
-  margin-right: 17px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #676c7c;
-  border: 2px solid #8e94aa;
-  > i {
-    font-size: 22px;
-  }
-`;
-
-const TopRow = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Description = styled.div`
-  color: #aaaabb;
-  margin-top: 13px;
-  margin-left: 2px;
-  font-size: 13px;
-`;
-
-const InfoLabel = styled.div`
-  width: 72px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  color: #7a838f;
-  font-size: 13px;
-  > i {
-    color: #8b949f;
-    font-size: 18px;
-    margin-right: 5px;
-  }
-`;
-
-const InfoSection = styled.div`
-  margin-top: 36px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 35px;
-`;

+ 84 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/styled.tsx

@@ -0,0 +1,84 @@
+import styled from "styled-components";
+import DynamicLink from "components/DynamicLink";
+import React, { useState } from "react";
+
+export const EllipsisTextWrapper: React.FC<
+  { tooltipText?: string } & React.HTMLAttributes<HTMLDivElement>
+> = ({ children, tooltipText, className, ...divProps }) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+  return (
+    <StyledTooltipWrapper
+      {...divProps}
+      className={className}
+      onMouseOver={() => setShowTooltip(true)}
+      onMouseOut={() => setShowTooltip(false)}
+    >
+      <StyledEllipsisTextWrapper>{children}</StyledEllipsisTextWrapper>
+      {tooltipText && showTooltip ? <Tooltip>{tooltipText}</Tooltip> : null}
+    </StyledTooltipWrapper>
+  );
+};
+
+export const Tooltip = styled.div`
+  position: absolute;
+  left: -20px;
+  top: 10px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledTooltipWrapper = styled.div`
+  position: relative;
+  overflow: visible;
+`;
+
+export const StyledEllipsisTextWrapper = styled.span`
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  position: relative;
+  max-width: 350px;
+`;
+
+export const RepoLink = styled(DynamicLink)`
+  height: 22px;
+  border-radius: 50px;
+  margin-left: 6px;
+  display: flex;
+  font-size: 12px;
+  cursor: pointer;
+  color: #a7a6bb;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    color: #ffffff;
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    margin-right: 5px;
+    color: #a7a6bb;
+    font-size: 16px;
+  }
+`;

+ 83 - 55
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -1,9 +1,7 @@
 import React, { useState } from "react";
-import styled, { css, keyframes } from "styled-components";
-import { Environment, PRDeployment } from "../types";
+import styled, { keyframes } from "styled-components";
+import { DeploymentStatus, PRDeployment } from "../types";
 import pr_icon from "assets/pull_request_icon.svg";
-import { integrationList } from "shared/common";
-import { useRouteMatch } from "react-router";
 import DynamicLink from "components/DynamicLink";
 import { capitalize, readableDate } from "shared/string_utils";
 import api from "shared/api";
@@ -11,26 +9,27 @@ import { useContext } from "react";
 import { Context } from "shared/Context";
 import Loading from "components/Loading";
 import { ActionButton } from "../components/ActionButton";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
+import MaterialTooltip from "@material-ui/core/Tooltip";
 
 const DeploymentCard: React.FC<{
   deployment: PRDeployment;
   onDelete: () => void;
   onReEnable: () => void;
-}> = ({ deployment, onDelete, onReEnable }) => {
+  onReRun: () => void;
+}> = ({ deployment, onDelete, onReEnable, onReRun }) => {
   const {
     setCurrentOverlay,
     currentProject,
     currentCluster,
     setCurrentError,
   } = useContext(Context);
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isDeleting, setIsDeleting] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
   const [hasErrorOnReEnabling, setHasErrorOnReEnabling] = useState(false);
   const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
-  const { url: currentUrl } = useRouteMatch();
-
-  let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
+  const [isReRunningWorkflow, setIsReRunningWorkflow] = useState(false);
+  const [hasErrorOnReRun, setHasErrorOnReRun] = useState(false);
 
   const deleteDeployment = () => {
     setIsDeleting(true);
@@ -55,11 +54,10 @@ const DeploymentCard: React.FC<{
       });
   };
 
-  const reEnablePreviewEnvironment = () => {
+  const reEnablePreviewEnvironment = async () => {
     setIsLoading(true);
-
-    api
-      .reenablePreviewEnvironmentDeployment(
+    try {
+      await api.reenablePreviewEnvironmentDeployment(
         "<token>",
         {},
         {
@@ -67,19 +65,42 @@ const DeploymentCard: React.FC<{
           project_id: currentProject.id,
           deployment_id: deployment.id,
         }
-      )
-      .then(() => {
-        setIsLoading(false);
-        onReEnable();
-      })
-      .catch((err) => {
-        setHasErrorOnReEnabling(true);
-        setIsLoading(false);
-        setCurrentError(err);
-        setTimeout(() => {
-          setHasErrorOnReEnabling(false);
-        }, 500);
-      });
+      );
+
+      setIsLoading(false);
+      onReEnable();
+    } catch (err) {
+      setHasErrorOnReEnabling(true);
+      setIsLoading(false);
+      setCurrentError(err?.response?.data?.error || err);
+      setTimeout(() => {
+        setHasErrorOnReEnabling(false);
+      }, 500);
+    }
+  };
+
+  const reRunWorkflow = async () => {
+    setIsReRunningWorkflow(true);
+    try {
+      await api.triggerPreviewEnvWorkflow(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_id: deployment.id,
+        }
+      );
+      setIsReRunningWorkflow(false);
+      onReEnable();
+    } catch (error) {
+      setHasErrorOnReRun(true);
+      setIsReRunningWorkflow(false);
+      setCurrentError(error);
+      setTimeout(() => {
+        setHasErrorOnReRun(false);
+      }, 500);
+    }
   };
 
   return (
@@ -87,7 +108,9 @@ const DeploymentCard: React.FC<{
       <DataContainer>
         <PRName>
           <PRIcon src={pr_icon} alt="pull request icon" />
-          {deployment.gh_pr_name}
+          <EllipsisTextWrapper tooltipText={deployment.gh_pr_name}>
+            {deployment.gh_pr_name}
+          </EllipsisTextWrapper>
           {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
             <MergeInfoWrapper>
               <MergeInfo
@@ -100,20 +123,25 @@ const DeploymentCard: React.FC<{
               </MergeInfo>
               {showMergeInfoTooltip && (
                 <Tooltip>
-                  {deployment.gh_pr_branch_from} {"->"} {deployment.gh_pr_branch_into}
+                  {deployment.gh_pr_branch_from} {"->"}{" "}
+                  {deployment.gh_pr_branch_into}
                 </Tooltip>
               )}
             </MergeInfoWrapper>
           ) : null}
           <RepoLink
-            onClick={e => {
-              e.stopPropagation();
-              window.open(`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`, "_blank")
-            }}
+            to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
+            target="_blank"
           >
             <i className="material-icons">open_in_new</i>
             View PR
           </RepoLink>
+          {deployment.last_workflow_run_url ? (
+            <RepoLink to={deployment.last_workflow_run_url} target="_blank">
+              <i className="material-icons">open_in_new</i>
+              View last workflow
+            </RepoLink>
+          ) : null}
         </PRName>
 
         <Flex>
@@ -136,8 +164,23 @@ const DeploymentCard: React.FC<{
       <Flex>
         {!isDeleting ? (
           <>
-            {deployment.status !== "creating" &&
-              deployment.status !== "inactive" && (
+            {deployment.status === DeploymentStatus.Failed ||
+            deployment.status === DeploymentStatus.TimedOut ? (
+              <>
+                <MaterialTooltip title="Re run last github workflow">
+                  <ReRunButton
+                    onClick={() => reRunWorkflow()}
+                    disabled={isReRunningWorkflow}
+                    hasError={hasErrorOnReRun}
+                  >
+                    <i className="material-icons-outlined">loop</i>
+                  </ReRunButton>
+                </MaterialTooltip>
+              </>
+            ) : null}
+
+            {deployment.status !== DeploymentStatus.Creating &&
+              deployment.status !== DeploymentStatus.Inactive && (
                 <>
                   <RowButton
                     to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
@@ -156,7 +199,7 @@ const DeploymentCard: React.FC<{
                   </RowButton>
                 </>
               )}
-            {deployment.status === "inactive" ? (
+            {deployment.status === DeploymentStatus.Inactive ? (
               <ActionButton
                 onClick={reEnablePreviewEnvironment}
                 disabled={isLoading}
@@ -178,7 +221,7 @@ const DeploymentCard: React.FC<{
                     message: `Are you sure you want to delete this deployment?`,
                     onYes: deleteDeployment,
                     onNo: () => setCurrentOverlay(null),
-                  })
+                  });
                 }}
               >
                 <i className="material-icons">delete</i>
@@ -201,27 +244,11 @@ const DeploymentCard: React.FC<{
 
 export default DeploymentCard;
 
-const RepoLink = styled.div`
-  height: 22px;
-  border-radius: 50px;
-  margin-left: 6px;
-  display: flex;
-  font-size: 12px;
-  cursor: pointer;
-  color: #a7a6bb;
-  align-items: center;
-  justify-content: center;
-  :hover {
-    color: #ffffff;
-    > i {
-      color: #ffffff;
-    }
-  }
+const ReRunButton = styled(ActionButton)`
+  min-width: unset;
 
   > i {
-    margin-right: 5px;
-    color: #a7a6bb;
-    font-size: 16px;
+    margin-right: unset;
   }
 `;
 
@@ -328,6 +355,7 @@ const PRIcon = styled.img`
 `;
 
 const RowButton = styled(DynamicLink)`
+  white-space: nowrap;
   font-size: 12px;
   padding: 8px 10px;
   margin-left: 10px;

+ 21 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -18,6 +18,7 @@ const DeploymentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
+  const [environmentId, setEnvironmentId] = useState("");
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
 
   const { currentProject, currentCluster } = useContext(Context);
@@ -28,6 +29,7 @@ const DeploymentDetail = () => {
   useEffect(() => {
     let isSubscribed = true;
     let environment_id = parseInt(searchParams.get("environment_id"));
+    setEnvironmentId(searchParams.get("environment_id"));
     api
       .getPRDeploymentByCluster(
         "<token>",
@@ -64,7 +66,9 @@ const DeploymentDetail = () => {
   return (
     <StyledExpandedChart>
       <HeaderWrapper>
-        <BackButton to={`/preview-environments?repository=${repository}`}>
+        <BackButton
+          to={`/preview-environments/deployments/${environmentId}/${repository}`}
+        >
           <BackButtonImg src={backArrow} />
         </BackButton>
         <Title icon={pr_icon} iconWidth="25px">
@@ -109,6 +113,15 @@ const DeploymentDetail = () => {
             <img src={github} /> GitHub PR
             <i className="material-icons">open_in_new</i>
           </GHALink>
+          {prDeployment.last_workflow_run_url ? (
+            <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
+              <span className="material-icons-outlined">
+                play_circle_outline
+              </span>
+              Last workflow run
+              <i className="material-icons">open_in_new</i>
+            </GHALink>
+          ) : null}
         </Flex>
         <LinkToActionsWrapper></LinkToActionsWrapper>
       </HeaderWrapper>
@@ -161,6 +174,13 @@ const GHALink = styled(DynamicLink)`
     }
   }
 
+  > span {
+    font-size: 17px;
+    margin-right: 9px;
+    margin-left: 5px;
+    text-decoration: none;
+  }
+
   > i {
     margin-left: 7px;
     font-size: 17px;

+ 111 - 112
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -10,9 +10,12 @@ import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
 import { Environment, PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
-import { useHistory, useLocation } from "react-router";
+import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
 import PullRequestCard from "./PullRequestCard";
+import DynamicLink from "components/DynamicLink";
+import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
+import SearchBar from "components/SearchBar";
 
 const AvailableStatusFilters = [
   "all",
@@ -25,27 +28,36 @@ const AvailableStatusFilters = [
 
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 
-const DeploymentList = ({ environments }: { environments: Environment[] }) => {
+const DeploymentList = () => {
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
+  const [searchValue, setSearchValue] = useState("");
 
   const [
     statusSelectorVal,
     setStatusSelectorVal,
   ] = useState<AvailableStatusFiltersType>("active");
-  const [selectedRepo, setSelectedRepo] = useState("");
 
   const { currentProject, currentCluster } = useContext(Context);
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
+  const { environment_id, repo_name, repo_owner } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const selectedRepo = `${repo_owner}/${repo_name}`;
 
   const getPRDeploymentList = () => {
     return api.getPRDeploymentList(
       "<token>",
-      {},
+      {
+        environment_id: Number(environment_id),
+      },
       {
         project_id: currentProject.id,
         cluster_id: currentCluster.id,
@@ -54,18 +66,6 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
     // return mockRequest();
   };
 
-  useEffect(() => {
-    const selected_repo = getQueryParam("repository");
-
-    const repo = environments.find(
-      env => `${env.git_repo_owner}/${env.git_repo_name}` === selected_repo
-    );
-
-    if (repo && true) {
-      setSelectedRepo(`${repo.git_repo_owner}/${repo.git_repo_name}`);
-    }
-  }, [location.search, history]);
-
   useEffect(() => {
     const status_filter = getQueryParam("status_filter");
 
@@ -105,20 +105,20 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
     return () => {
       isSubscribed = false;
     };
-  }, [currentCluster, currentProject, statusSelectorVal]);
+  }, [currentCluster, currentProject]);
 
-  const handleRefresh = () => {
+  const handleRefresh = async () => {
     setIsLoading(true);
-    getPRDeploymentList()
-      .then(({ data }) => {
-        setDeploymentList(data.deployments || []);
-        setPullRequests(data.pull_requests || []);
-      })
-      .catch((err) => {
-        setHasError(true);
-        console.error(err);
-      })
-      .finally(() => setIsLoading(false));
+    try {
+      const { data } = await getPRDeploymentList();
+      setDeploymentList(data.deployments || []);
+      setPullRequests(data.pull_requests || []);
+    } catch (error) {
+      setHasError(true);
+      console.error(error);
+    } finally {
+      setIsLoading(false);
+    }
   };
 
   const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
@@ -134,53 +134,46 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
     handleRefresh();
   };
 
-  const filteredDeployments = useMemo(() => {
-    if (statusSelectorVal === "not_deployed") {
-      return [];
-    }
-
-    if (statusSelectorVal === "all" && selectedRepo === "all") {
-      return deploymentList;
-    }
+  const searchFilter = (value: string | number) => {
+    const val = String(value);
 
-    let tmpDeploymentList = [...deploymentList];
-
-    if (selectedRepo !== "all") {
-      tmpDeploymentList = tmpDeploymentList.filter((deployment) => {
-        return (
-          `${deployment.gh_repo_owner}/${deployment.gh_repo_name}` ===
-          selectedRepo
-        );
-      });
-    }
+    return val.toLowerCase().includes(searchValue.toLowerCase());
+  };
 
+  const filteredDeployments = useMemo(() => {
     // Only filter out inactive when status filter is "active"
     if (statusSelectorVal === "active") {
-      tmpDeploymentList = tmpDeploymentList.filter((d) => {
-        return d.status !== "inactive";
-      });
-    } else if (statusSelectorVal === "inactive") {
-      tmpDeploymentList = tmpDeploymentList.filter((d) => {
-        return d.status === "inactive";
-      });      
+      return deploymentList
+        .filter((d) => {
+          return d.status !== "inactive";
+        })
+        .filter((d) => {
+          return Object.values(d).find(searchFilter) !== undefined;
+        });
     }
 
-    return tmpDeploymentList;
-  }, [selectedRepo, statusSelectorVal, deploymentList]);
+    if (statusSelectorVal === "inactive") {
+      return deploymentList
+        .filter((d) => {
+          return d.status === "inactive";
+        })
+        .filter((d) => {
+          return Object.values(d).find(searchFilter) !== undefined;
+        });
+    }
+
+    return deploymentList;
+  }, [statusSelectorVal, deploymentList, searchValue]);
 
   const filteredPullRequests = useMemo(() => {
-    if (statusSelectorVal !== "not_deployed" && statusSelectorVal !== "inactive") {
+    if (statusSelectorVal !== "inactive") {
       return [];
     }
 
-    if (selectedRepo === "inactive") {
-      return pullRequests;
-    }
-
     return pullRequests.filter((pr) => {
-      return `${pr.repo_owner}/${pr.repo_name}` === selectedRepo;
+      return Object.values(pr).find(searchFilter) !== undefined;
     });
-  }, [selectedRepo, pullRequests]);
+  }, [pullRequests, statusSelectorVal, searchValue]);
 
   const renderDeploymentList = () => {
     if (isLoading) {
@@ -226,6 +219,7 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
               deployment={d}
               onDelete={handleRefresh}
               onReEnable={handleRefresh}
+              onReRun={handleRefresh}
             />
           );
         })}
@@ -234,28 +228,18 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
   };
 
   const handleStatusFilterChange = (value: string) => {
-    setIsLoading(true);
     pushQueryParams({ status_filter: value });
     setStatusSelectorVal(value);
   };
 
-  const renderMain = () => {
-    return (
-      <Container>
-        <EventsGrid>{renderDeploymentList()}</EventsGrid>
-      </Container>
-    );
-  }
-
   return (
     <>
+      <PreviewEnvironmentsHeader />
       <Flex>
-        <i
-          className="material-icons"
-          onClick={() => pushQueryParams({}, ["status_filter", "repository"])}
-        >
+        <BackButton to={"/preview-environments"} className="material-icons">
           keyboard_backspace
-        </i>
+        </BackButton>
+
         <Icon
           src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
           alt="git repository icon"
@@ -267,6 +251,16 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
             <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}
@@ -278,7 +272,7 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
                 {
                   value: "inactive",
                   label: "Inactive",
-                }
+                },
               ]}
               dropdownLabel="Status"
               width="150px"
@@ -288,9 +282,11 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
           </StyledStatusSelector>
         </ActionsWrapper>
       </Flex>
-      {renderMain()}
+      <Container>
+        <EventsGrid>{renderDeploymentList()}</EventsGrid>
+      </Container>
     </>
-  )
+  );
 };
 
 export default DeploymentList;
@@ -309,16 +305,16 @@ const mockRequest = () =>
 const Flex = styled.div`
   display: flex;
   align-items: center;
+`;
 
-  > i {
-    cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
-    padding: 3px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
+const BackButton = styled(DynamicLink)`
+  cursor: pointer;
+  font-size: 24px;
+  color: #969fbbaa;
+  padding: 3px;
+  border-radius: 100px;
+  :hover {
+    background: #ffffff11;
   }
 `;
 
@@ -390,15 +386,6 @@ const Container = styled.div`
   padding-bottom: 120px;
 `;
 
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
 const EventsGrid = styled.div`
   display: grid;
   grid-row-gap: 20px;
@@ -414,25 +401,37 @@ const StyledStatusSelector = styled.div`
   }
 `;
 
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subheader = styled.div`
-  width: 50%;
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 20px;
 `;
 
-const Label = styled.div`
+const SearchRow = styled.div`
   display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
   align-items: center;
-  margin-right: 12px;
+  padding: 10px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  max-height: 35px;
+  background: #ffffff11;
+  margin-right: 15px;
 
-  > i {
-    margin-right: 8px;
-    font-size: 18px;
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
   }
 `;

+ 8 - 35
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx

@@ -9,6 +9,7 @@ import { ActionButton } from "../components/ActionButton";
 import Loading from "components/Loading";
 import DynamicLink from "components/DynamicLink";
 import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 
 const PullRequestCard = ({
   pullRequest,
@@ -20,7 +21,6 @@ const PullRequestCard = ({
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
   const [
     openRecreateWorkflowFilesModal,
@@ -29,8 +29,6 @@ const PullRequestCard = ({
   const [isLoading, setIsLoading] = useState(false);
   const [hasError, setHasError] = useState(false);
 
-  const repository = `${pullRequest.repo_owner}/${pullRequest.repo_name}`;
-
   const createPreviewEnvironment = async () => {
     setIsLoading(true);
     try {
@@ -40,8 +38,7 @@ const PullRequestCard = ({
       });
       onCreation(pullRequest);
     } catch (error) {
-      debugger;
-      setCurrentError(error);
+      setCurrentError(error?.response?.data?.error || error);
       setHasError(true);
       setTimeout(() => {
         setHasError(false);
@@ -62,7 +59,9 @@ const PullRequestCard = ({
         <DataContainer>
           <PRName>
             <PRIcon src={pr_icon} alt="pull request icon" />
-            {pullRequest.pr_title}
+            <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
+              {pullRequest.pr_title}
+            </EllipsisTextWrapper>
             <InfoWrapper>
               <MergeInfo
                 onMouseOver={() => setShowMergeInfoTooltip(true)}
@@ -80,10 +79,8 @@ const PullRequestCard = ({
               )}
             </InfoWrapper>
             <RepoLink
-              onClick={e => {
-                e.stopPropagation();
-                window.open(`https://github.com/${pullRequest.repo_owner}/${pullRequest.repo_name}/pull/${pullRequest.pr_number}`, "_blank")
-              }}
+              to={`https://github.com/${pullRequest.repo_owner}/${pullRequest.repo_name}/pull/${pullRequest.pr_number}`}
+              target="_blank"
             >
               <i className="material-icons">open_in_new</i>
               View PR
@@ -122,30 +119,6 @@ const PullRequestCard = ({
 
 export default PullRequestCard;
 
-const RepoLink = styled.div`
-  height: 22px;
-  border-radius: 50px;
-  margin-left: 6px;
-  display: flex;
-  font-size: 12px;
-  cursor: pointer;
-  color: #a7a6bb;
-  align-items: center;
-  justify-content: center;
-  :hover {
-    color: #ffffff;
-    > i {
-      color: #ffffff;
-    }
-  }
-
-  > i {
-    margin-right: 5px;
-    color: #a7a6bb;
-    font-size: 16px;
-  }
-`;
-
 const Flex = styled.div`
   display: flex;
   align-items: center;
@@ -300,4 +273,4 @@ const MergeInfo = styled.div`
     font-size: 16px;
     margin: 0 2px;
   }
-`;
+`;

+ 13 - 46
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -1,19 +1,14 @@
-import React, {
-  FormEvent,
-  FormEventHandler,
-  useContext,
-  useState,
-} from "react";
+import React, { useContext, useState } from "react";
 import { capitalize } from "shared/string_utils";
 import styled from "styled-components";
 import { Environment } from "../types";
 import Options from "components/OptionsDropdown";
-import { useRouting } from "shared/routing";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import Modal from "main/home/modals/Modal";
 import InputRow from "components/form-components/InputRow";
 import DynamicLink from "components/DynamicLink";
+import { RepoLink } from "../components/styled";
 
 type Props = {
   environment: Environment;
@@ -24,7 +19,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
-  const { pushFiltered } = useRouting();
 
   const [showDeleteModal, setShowDeleteModal] = useState(false);
   const [deleteConfirmationRepoName, setDeleteConfirmationRepoName] = useState(
@@ -41,12 +35,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
     last_deployment_status,
   } = environment;
 
-  const showOpenPrs = () => {
-    pushFiltered("/preview-environments", [], {
-      repository: `${git_repo_owner}/${git_repo_name}`,
-    });
-  };
-
   const handleDelete = () => {
     if (!canDelete()) {
       return;
@@ -94,7 +82,8 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
           onRequestClose={closeForm}
         >
           <Warning highlight>
-            ⚠️ All Preview Environment deployments associated with this repo will be deleted.
+            ⚠️ All Preview Environment deployments associated with this repo
+            will be deleted.
           </Warning>
           <InputRow
             type="text"
@@ -114,7 +103,9 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
           </ActionWrapper>
         </Modal>
       ) : null}
-      <EnvironmentCardWrapper onClick={showOpenPrs}>
+      <EnvironmentCardWrapper
+        to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
+      >
         <DataContainer>
           <RepoName>
             <Icon
@@ -123,10 +114,8 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             />
             {git_repo_owner}/{git_repo_name}
             <RepoLink
-              onClick={e => {
-                e.stopPropagation();
-                window.open(`https://github.com/${git_repo_owner}/${git_repo_name}`, "_blank")
-              }}
+              to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
+              target="_blank"
             >
               <i className="material-icons">open_in_new</i>
               View Repo
@@ -142,8 +131,8 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             ) : null}
             {deployment_count > 0 ? (
               <Span>
-                {deployment_count || 0}{" "}
-                pull {deployment_count > 1 ? "requests" : "request"} deployed
+                {deployment_count || 0} pull{" "}
+                {deployment_count > 1 ? "requests" : "request"} deployed
               </Span>
             ) : (
               <Span>
@@ -176,31 +165,9 @@ const OptionWrapper = styled.div`
   justify-content: center;
 `;
 
-const RepoLink = styled.div`
-  height: 22px;
-  border-radius: 50px;
-  margin-left: 10px;
-  display: flex;
-  font-size: 12px;
-  color: #a7a6bb;
-  align-items: center;
-  justify-content: center;
-  :hover {
-    color: #ffffff;
-    > i {
-      color: #ffffff;
-    }
-  }
-
-  > i {
-    margin-right: 5px;
-    color: #a7a6bb;
-    font-size: 16px;
-  }
-`;
-
-const EnvironmentCardWrapper = styled.div`
+const EnvironmentCardWrapper = styled(DynamicLink)`
   display: flex;
+  color: #ffffff;
   background: #2b2e3699;
   justify-content: space-between;
   border-radius: 5px;

+ 19 - 7
dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts

@@ -1,3 +1,5 @@
+import { PRDeployment } from "./types";
+
 export const environments = [
   {
     id: 29,
@@ -102,54 +104,64 @@ export const environments = [
   },
 ];
 
-export const deployments = [
+export const deployments: PRDeployment[] = [
   {
     gh_deployment_id: 534980099,
-    gh_pr_name: "Update porter.yaml",
+    gh_pr_name: "Update porter.yaml to enable preview environments on porter",
+    gh_pr_branch_from: "some-branch-name",
+    gh_pr_branch_into: "master",
     gh_repo_name: "preview",
     gh_repo_owner: "porter-dev",
     gh_commit_sha: "74a1191",
     id: 43,
     created_at: "2022-03-28T19:28:11.012729Z",
     updated_at: "2022-03-28T19:31:53.871666Z",
-    git_installation_id: 0,
+    gh_installation_id: 0,
     environment_id: 43,
     namespace: "pr-3-preview",
     status: "failed",
     subdomain: "",
     pull_request_id: 3,
+    last_workflow_run_url: "https://something.com",
   },
   {
     gh_deployment_id: 532608734,
     gh_pr_name: "Testing pr preview",
+    gh_pr_branch_from: "some-branch-name",
+    gh_pr_branch_into: "master",
     gh_repo_name: "porter-docs",
     gh_repo_owner: "jnfrati",
     gh_commit_sha: "6a4b67e",
     id: 41,
     created_at: "2022-03-24T20:24:17.103471Z",
     updated_at: "2022-03-24T20:45:06.684096Z",
-    git_installation_id: 0,
+    gh_installation_id: 0,
     environment_id: 37,
     namespace: "pr-1-porter-docs",
     status: "inactive",
-    subdomain: "https://docs-web-7b93751b98e68139.staging-onporter.run",
+    subdomain: "",
     pull_request_id: 1,
+    last_workflow_run_url: "",
   },
   {
     gh_deployment_id: 514002155,
-    gh_pr_name: "Testing PR with job run",
+    gh_pr_name:
+      "Testing PR with job run and a really long name to explain what's going on over this pull request",
+    gh_pr_branch_from: "some-branch-name",
+    gh_pr_branch_into: "master",
     gh_repo_name: "porter-docs",
     gh_repo_owner: "porter-dev",
     gh_commit_sha: "443d930",
     id: 32,
     created_at: "2022-01-30T11:04:14.496147Z",
     updated_at: "2022-02-24T22:02:27.17928Z",
-    git_installation_id: 0,
+    gh_installation_id: 0,
     environment_id: 29,
     namespace: "pr-20-porter-docs",
     status: "created",
     subdomain: "https://docs-web-78a048205ac7869b.staging-onporter.run",
     pull_request_id: 20,
+    last_workflow_run_url: "https://something.com",
   },
 ];
 

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

@@ -3,10 +3,11 @@ import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
+import DeploymentList from "./deployments/DeploymentList";
 import PreviewEnvironmentsHome from "./PreviewEnvironmentsHome";
 
 export const Routes = () => {
-  const { url } = useRouteMatch();
+  const { path } = useRouteMatch();
   const { currentProject } = useContext(Context);
 
   if (!currentProject?.preview_envs_enabled) {
@@ -16,13 +17,18 @@ export const Routes = () => {
   return (
     <>
       <Switch>
-        <Route path={`${url}/connect-repo`}>
+        <Route path={`${path}/connect-repo`}>
           <ConnectNewRepo />
         </Route>
-        <Route path={`${url}/details/:namespace?`}>
+        <Route path={`${path}/details/:namespace?`}>
           <DeploymentDetail />
         </Route>
-        <Route path={`${url}/:selected_tab?`}>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name`}
+        >
+          <DeploymentList />
+        </Route>
+        <Route path={`${path}/`}>
           <PreviewEnvironmentsHome />
         </Route>
       </Switch>

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

@@ -1,12 +1,26 @@
+export enum DeploymentStatus {
+  Failed = "failed",
+  Created = "created",
+  Creating = "creating",
+  Inactive = "inactive",
+  TimedOut = "timed_out",
+  Updating = "updating",
+}
+
+export type DeploymentStatusUnion = `${DeploymentStatus}`;
+
 export type PRDeployment = {
   id: number;
   created_at: string;
   updated_at: string;
   subdomain: string;
-  status: "creating" | "failed" | "created" | "inactive";
+  status: DeploymentStatusUnion;
   environment_id: number;
   pull_request_id: number;
   namespace: string;
+  last_workflow_run_url: string;
+  gh_installation_id: number;
+  gh_deployment_id: number;
   gh_pr_name: string;
   gh_repo_owner: string;
   gh_repo_name: string;
@@ -23,7 +37,7 @@ export type Environment = {
   name: string;
   git_repo_owner: string;
   git_repo_name: string;
-  last_deployment_status: "failed" | "created" | "inactive" | "disabled";
+  last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   mode: "manual" | "auto";
 };

+ 2 - 2
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -118,7 +118,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
         }
       )
       .then((_) => {
-        window.analytics.track("Deployed Add-on", {
+        window.analytics?.track("Deployed Add-on", {
           name: props.currentTemplate.name,
           namespace: selectedNamespace,
           values: values,
@@ -132,7 +132,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
         setSaveValuesStatus(err);
 
         setCurrentError(err);
-        window.analytics.track("Failed to Deploy Add-on", {
+        window.analytics?.track("Failed to Deploy Add-on", {
           name: props.currentTemplate.name,
           namespace: selectedNamespace,
           values: values,

+ 1 - 1
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -137,7 +137,7 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
       }
 
       if (err) {
-        window.analytics.track("Provisioning Error", {
+        window.analytics?.track("Provisioning Error", {
           error: err,
         });
         let e = ansiparse(err).map((el: any) => {

+ 1 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -51,7 +51,7 @@ class ClusterSection extends Component<PropsType, StateType> {
     api
       .getClusters("<token>", {}, { id: currentProject.id })
       .then((res) => {
-        window.analytics.identify(user.userId, {
+        window.analytics?.identify(user.userId, {
           currentProject,
           clusters: res.data,
         });

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

@@ -313,7 +313,7 @@ const updateNotificationConfig = baseApi<
 
 const getPRDeploymentList = baseApi<
   {
-    status?: string[];
+    environment_id?: number;
   },
   {
     cluster_id: number;
@@ -1713,6 +1713,31 @@ const updateBuildConfig = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/buildconfig`
 );
 
+const reRunGHWorkflow = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    git_installation_id: number;
+    owner: string;
+    name: string;
+    filename: string;
+  }
+>(
+  "POST",
+  ({ project_id, git_installation_id, owner, name, cluster_id, filename }) =>
+    `/api/projects/${project_id}/gitrepos/${git_installation_id}/${owner}/${name}/clusters/${cluster_id}/rerun_workflow?filename=${filename}`
+);
+
+const triggerPreviewEnvWorkflow = baseApi<
+  {},
+  { project_id: number; cluster_id: number; deployment_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id, deployment_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}/trigger_workflow`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1875,4 +1900,6 @@ export default {
   upgradePorterAgent,
   deletePRDeployment,
   updateBuildConfig,
+  reRunGHWorkflow,
+  triggerPreviewEnvWorkflow,
 };

+ 2 - 2
dashboard/src/shared/hooks/useChart.ts

@@ -76,7 +76,7 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
         }
       );
 
-      window.analytics.track("Chart Upgraded", {
+      window.analytics?.track("Chart Upgraded", {
         chart: chart.name,
         values: valuesYaml,
       });
@@ -88,7 +88,7 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
       }
       setCurrentError(parsedErr);
 
-      window.analytics.track("Failed to Upgrade Chart", {
+      window.analytics?.track("Failed to Upgrade Chart", {
         chart: chart.name,
         values: valuesYaml,
         error: err,

+ 1 - 0
dashboard/tsconfig.json

@@ -1,5 +1,6 @@
 {
   "compilerOptions": {
+    "lib": ["ESNext"],
     "baseUrl": "src",
     "outDir": "./build/",
     "sourceMap": true,

+ 17 - 4
dashboard/webpack.config.js

@@ -24,6 +24,22 @@ module.exports = () => {
   if (process.env.NODE_ENV !== env.NODE_ENV) {
     isDevelopment = process.env.NODE_ENV !== "production";
   }
+
+  let htmlPluginOpts = {
+    template: path.resolve(__dirname, "src", "index.html"),
+  };
+
+  if (env.IS_HOSTED) {
+    htmlPluginOpts = {
+      template: path.resolve(__dirname, "src", "hosted.index.html"),
+      cohereKey: `${env.COHERE_KEY}`,
+      intercomAppId: `${env.INTERCOM_APP_ID}`,
+      intercomSrc: `${process.env.INTERCOM_SRC}`,
+      segmentWriteKey: `${process.env.SEGMENT_WRITE_KEY}`,
+      segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
+    };
+  }
+
   /**
    * @type {webpack.Configuration}
    */
@@ -93,10 +109,7 @@ module.exports = () => {
       hot: true,
     },
     plugins: [
-      new HtmlWebpackPlugin({
-        template: path.resolve(__dirname, "src", "index.html"),
-        segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
-      }),
+      new HtmlWebpackPlugin(htmlPluginOpts),
       new webpack.DefinePlugin(envKeys),
       isDevelopment && new ReactRefreshWebpackPlugin(),
     ].filter(Boolean),

+ 0 - 39
internal/models/environment.go

@@ -79,42 +79,3 @@ func (d *Deployment) ToDeploymentType() *types.Deployment {
 		GitHubMetadata: ghMetadata,
 	}
 }
-
-type DeploymentWithEnvironment struct {
-	gorm.Model
-
-	Environment    *Environment
-	Namespace      string
-	Status         types.DeploymentStatus
-	Subdomain      string
-	PullRequestID  uint
-	GHDeploymentID int64
-	PRName         string
-	RepoName       string
-	RepoOwner      string
-	CommitSHA      string
-}
-
-func (d *DeploymentWithEnvironment) ToDeploymentType() *types.Deployment {
-
-	ghMetadata := &types.GitHubMetadata{
-		DeploymentID: d.GHDeploymentID,
-		PRName:       d.PRName,
-		RepoName:     d.RepoName,
-		RepoOwner:    d.RepoOwner,
-		CommitSHA:    d.CommitSHA,
-	}
-
-	return &types.Deployment{
-		CreatedAt:         d.CreatedAt,
-		UpdatedAt:         d.UpdatedAt,
-		ID:                d.Model.ID,
-		EnvironmentID:     d.Environment.ID,
-		GitInstallationID: d.Environment.GitInstallationID,
-		Namespace:         d.Namespace,
-		Status:            d.Status,
-		Subdomain:         d.Subdomain,
-		PullRequestID:     d.PullRequestID,
-		GitHubMetadata:    ghMetadata,
-	}
-}

+ 14 - 1
internal/registry/registry.go

@@ -129,9 +129,22 @@ func (r *Registry) listGCRRepositories(
 	// for oauth. This also prevents us from making more requests.
 	client := &http.Client{}
 
+	regURL := r.URL
+
+	if !strings.HasPrefix(regURL, "http") {
+		regURL = fmt.Sprintf("https://%s", regURL)
+	}
+
+	regURLParsed, err := url.Parse(regURL)
+	regHostname := "gcr.io"
+
+	if err == nil {
+		regHostname = regURLParsed.Host
+	}
+
 	req, err := http.NewRequest(
 		"GET",
-		"https://gcr.io/v2/_catalog",
+		fmt.Sprintf("https://%s/v2/_catalog", regHostname),
 		nil,
 	)