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

Merge pull request #1926 from porter-dev/staging

Preview env improvements + RDS form -> production
abelanger5 4 лет назад
Родитель
Сommit
b067b19e05
32 измененных файлов с 668 добавлено и 463 удалено
  1. 0 1
      .github/workflows/dev.yaml
  2. 5 0
      .github/workflows/production.yaml
  3. 5 0
      .github/workflows/staging.yaml
  4. 69 8
      api/server/handlers/environment/list_deployments_by_cluster.go
  5. 100 0
      api/server/handlers/gitinstallation/rerun_workflow.go
  6. 2 0
      api/server/handlers/infra/forms.go
  7. 36 0
      api/server/router/git_installation.go
  8. 12 9
      api/types/environment.go
  9. 1 0
      dashboard/src/components/OptionsDropdown.tsx
  10. 3 3
      dashboard/src/components/repo-selector/RepoList.tsx
  11. 186 0
      dashboard/src/hosted.index.html
  12. 0 144
      dashboard/src/index.html
  13. 12 5
      dashboard/src/main/CurrentError.tsx
  14. 10 5
      dashboard/src/main/Main.tsx
  15. 3 3
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  16. 5 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  17. 3 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  18. 29 63
      dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx
  19. 7 66
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  20. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  21. 21 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  22. 108 113
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  23. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  24. 6 16
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  25. 10 4
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  26. 1 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  27. 2 2
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  28. 1 1
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  29. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  30. 9 2
      dashboard/src/shared/api.tsx
  31. 2 2
      dashboard/src/shared/hooks/useChart.ts
  32. 17 4
      dashboard/webpack.config.js

+ 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

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

@@ -56,6 +56,13 @@ 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 {
+				updateDeploymentWithGithubWorkflowRunStatus(r.Context(), c.Config(), env, deployment)
+			}
+
 			deployments = append(deployments, deployment)
 		}
 
@@ -77,7 +84,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 +105,10 @@ 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
+			deployments = append(deployments, deployment)
 		}
 
 		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
@@ -117,6 +127,42 @@ 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,
+			},
+		)
+
+		if err == nil && workflowRuns.GetTotalCount() > 0 {
+			latestWorkflowRun := workflowRuns.WorkflowRuns[0]
+
+			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
+
+			if deployment.Status != types.DeploymentStatusCreating &&
+				(latestWorkflowRun.GetStatus() == "in_progress" ||
+					latestWorkflowRun.GetStatus() == "queued") {
+				deployment.Status = types.DeploymentStatusUpdating
+			} else if latestWorkflowRun.GetStatus() == "completed" {
+				if latestWorkflowRun.GetConclusion() == "failed" {
+					deployment.Status = types.DeploymentStatusFailed
+				} else if latestWorkflowRun.GetConclusion() == "timed_out" {
+					deployment.Status = types.DeploymentStatusTimedOut
+				}
+			}
+		}
+	}
+}
+
 func fetchOpenPullRequests(
 	ctx context.Context,
 	config *config.Config,
@@ -132,7 +178,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 +193,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{

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

@@ -0,0 +1,100 @@
+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{},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

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

+ 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"`
+	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"`
+	LastWorkflowRunURL string           `json:"last_workflow_run_url"`
 }
 
 type CreateGHDeploymentRequest struct {

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

+ 3 - 3
dashboard/src/components/repo-selector/RepoList.tsx

@@ -76,9 +76,9 @@ const RepoList: React.FC<Props> = ({
     try {
       const resolvedRepoList = await Promise.allSettled(repoListPromises);
 
-      const repos: RepoType[][] = resolvedRepoList
-        .map((repo) => (repo.status === "fulfilled" ? repo.value.data : null))
-        .filter(Boolean);
+      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

+ 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

@@ -338,7 +338,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,
       });
@@ -353,7 +353,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,
@@ -392,7 +392,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,
         });
@@ -408,7 +408,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,
@@ -704,7 +704,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   useEffect(() => {
-    window.analytics.track("Opened Chart", {
+    window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
     });
 

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

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

@@ -71,7 +71,7 @@ const DeploymentCard: React.FC<{
       .catch((err) => {
         setHasErrorOnReEnabling(true);
         setIsLoading(false);
-        setCurrentError(err);
+        setCurrentError(err?.response?.data?.error || err);
         setTimeout(() => {
           setHasErrorOnReEnabling(false);
         }, 500);

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

+ 108 - 113
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,56 +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(d).find(searchFilter) !== undefined;
     });
-  }, [selectedRepo, pullRequests]);
+  }, [pullRequests, statusSelectorVal, searchValue]);
 
   const renderDeploymentList = () => {
     if (isLoading) {
@@ -237,28 +227,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"
@@ -270,6 +250,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}
@@ -291,7 +281,9 @@ const DeploymentList = ({ environments }: { environments: Environment[] }) => {
           </StyledStatusSelector>
         </ActionsWrapper>
       </Flex>
-      {renderMain()}
+      <Container>
+        <EventsGrid>{renderDeploymentList()}</EventsGrid>
+      </Container>
     </>
   );
 };
@@ -312,16 +304,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;
   }
 `;
 
@@ -393,15 +385,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;
@@ -417,25 +400,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;
   }
 `;

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

@@ -38,7 +38,7 @@ const PullRequestCard = ({
       });
       onCreation(pullRequest);
     } catch (error) {
-      setCurrentError(error);
+      setCurrentError(error?.response?.data?.error || error);
       setHasError(true);
       setTimeout(() => {
         setHasError(false);

+ 6 - 16
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -1,14 +1,8 @@
-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";
@@ -25,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(
@@ -42,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;
@@ -116,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
@@ -176,8 +165,9 @@ const OptionWrapper = styled.div`
   justify-content: center;
 `;
 
-const EnvironmentCardWrapper = styled.div`
+const EnvironmentCardWrapper = styled(DynamicLink)`
   display: flex;
+  color: #ffffff;
   background: #2b2e3699;
   justify-content: space-between;
   border-radius: 5px;

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

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

@@ -13,6 +13,7 @@ export type PRDeployment = {
   gh_commit_sha: string;
   gh_pr_branch_from?: string;
   gh_pr_branch_into?: string;
+  last_workflow_run_url: string;
 };
 
 export type Environment = {

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

+ 9 - 2
dashboard/src/shared/api.tsx

@@ -313,7 +313,7 @@ const updateNotificationConfig = baseApi<
 
 const getPRDeploymentList = baseApi<
   {
-    status?: string[];
+    environment_id?: number;
   },
   {
     cluster_id: number;
@@ -373,7 +373,14 @@ const deletePRDeployment = baseApi<
     pr_number: number;
   }
 >("DELETE", (pathParams) => {
-  const { cluster_id, project_id, environment_id, repo_owner, repo_name, pr_number } = pathParams;
+  const {
+    cluster_id,
+    project_id,
+    environment_id,
+    repo_owner,
+    repo_name,
+    pr_number,
+  } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${environment_id}/${repo_owner}/${repo_name}/${pr_number}`;
 });
 

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

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