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

Merge branch 'belanger/improve-recommender-policies' into dev

Alexander Belanger 3 лет назад
Родитель
Сommit
4be4b2760d
50 измененных файлов с 1116 добавлено и 1063 удалено
  1. 0 1
      api/server/handlers/gitinstallation/get_buildpack.go
  2. 4 0
      api/server/handlers/infra/forms.go
  3. 0 1
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  4. 7 2
      api/server/handlers/user/welcome_webhook.go
  5. 79 0
      api/server/handlers/user/welcome_webhook_test.go
  6. 8 2
      cli/cmd/connect/dockerhub.go
  7. 3 0
      cli/cmd/deploy/create.go
  8. 13 0
      cli/cmd/docker/builder.go
  9. 52 32
      cli/cmd/run.go
  10. 8 0
      dashboard/src/assets/cluster.svg
  11. BIN
      dashboard/src/assets/gradient.png
  12. 2 2
      dashboard/src/components/form-components/CheckboxRow.tsx
  13. 142 123
      dashboard/src/hosted.index.html
  14. 1 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  15. 67 76
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  16. 2 2
      dashboard/src/main/home/cluster-dashboard/TagFilter.tsx
  17. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  18. 3 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  19. 4 6
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  20. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  21. 53 85
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  22. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  23. 18 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  24. 3 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx
  25. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  26. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  27. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  28. 2 4
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  29. 1 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  30. 0 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  31. 0 1
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  32. 13 13
      dashboard/src/main/home/launch/TemplateList.tsx
  33. 3 5
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  34. 2 6
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  35. 2 2
      dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx
  36. 0 1
      dashboard/src/main/home/new-project/NewProject.tsx
  37. 4 0
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  38. 3 7
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx
  39. 1 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  40. 306 290
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  41. 207 0
      dashboard/src/main/home/sidebar/Clusters.tsx
  42. 0 242
      dashboard/src/main/home/sidebar/Drawer.tsx
  43. 6 6
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  44. 25 121
      dashboard/src/main/home/sidebar/Sidebar.tsx
  45. 5 1
      dashboard/src/shared/hooks/useWebsockets.ts
  46. 4 1
      internal/opa/config.yaml
  47. 5 4
      internal/opa/loader.go
  48. 20 8
      internal/opa/opa.go
  49. 26 0
      internal/opa/policies/certificates/expired.rego
  50. 5 6
      internal/opa/policies/web/web_version.rego

+ 0 - 1
api/server/handlers/gitinstallation/get_buildpack.go

@@ -23,7 +23,6 @@ func initBuilderInfo() map[string]*buildpacks.BuilderInfo {
 		Name: "Paketo",
 		Builders: []string{
 			"paketobuildpacks/builder:full",
-			"paketobuildpacks/builder:base",
 		},
 	}
 	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{

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

@@ -408,6 +408,10 @@ tabs:
           value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: r5.large
+          value: r5.large
+        - value: r5.xlarge
+          value: r5.xlarge
     - type: string-input
       label: 👤 Issuer Email
       required: true

+ 0 - 1
api/server/handlers/project_integration/get_gitlab_repo_buildpack.go

@@ -147,7 +147,6 @@ func initBuilderInfo() map[string]*buildpacks.BuilderInfo {
 		Name: "Paketo",
 		Builders: []string{
 			"paketobuildpacks/builder:full",
-			"paketobuildpacks/builder:base",
 		},
 	}
 	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{

+ 7 - 2
api/server/handlers/user/welcome_webhook.go

@@ -27,14 +27,19 @@ func NewUserWelcomeHandler(
 }
 
 func (u *UserWelcomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Skip if no welcome hook is configured.
+	welcomeFormWebhook := u.Config().ServerConf.WelcomeFormWebhook
+	if welcomeFormWebhook == "" {
+		return
+	}
+
 	reqVals := &types.WelcomeWebhookRequest{}
 
 	if ok := u.DecodeAndValidate(w, r, reqVals); !ok {
 		return
 	}
 
-	req, err := http.NewRequest("GET", u.Config().ServerConf.WelcomeFormWebhook, nil)
-
+	req, err := http.NewRequest("GET", welcomeFormWebhook, nil)
 	if err != nil {
 		return
 	}

+ 79 - 0
api/server/handlers/user/welcome_webhook_test.go

@@ -0,0 +1,79 @@
+package user_test
+
+import (
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func TestWelcomeWebhookWithoutURL(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbPost),
+		"/api/welcome",
+		&types.WelcomeWebhookRequest{
+			Email:     "test@test.it",
+			IsCompany: true,
+			Company:   "Awesome Company",
+			Role:      "Founder",
+			Name:      "John Doe",
+		},
+	)
+
+	config := apitest.LoadConfig(t)
+	config.ServerConf.WelcomeFormWebhook = ""
+
+	handler := user.NewUserWelcomeHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, rr.Result().StatusCode, 200, "incorrect status code")
+}
+
+func helloWebhook(w http.ResponseWriter, r *http.Request) {
+	io.WriteString(w, "Hello!\n")
+}
+
+func TestWelcomeWebhookWithURL(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbPost),
+		"/api/welcome",
+		&types.WelcomeWebhookRequest{
+			Email:     "test@test.it",
+			IsCompany: true,
+			Company:   "Awesome Company",
+			Role:      "Founder",
+			Name:      "John Doe",
+		},
+	)
+
+	go func() {
+		http.HandleFunc("/hello", helloWebhook)
+		http.ListenAndServe(":10044", nil)
+	}()
+
+	config := apitest.LoadConfig(t)
+	config.ServerConf.WelcomeFormWebhook = "http://localhost:10044/hello"
+
+	handler := user.NewUserWelcomeHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, rr.Result().StatusCode, 200, "incorrect status code")
+}

+ 8 - 2
cli/cmd/connect/dockerhub.go

@@ -3,6 +3,7 @@ package connect
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/porter-dev/porter/api/types"
 
@@ -22,12 +23,17 @@ func Dockerhub(
 
 	// query for dockerhub name
 
-	repoName, err := utils.PromptPlaintext("Provide the Docker Hub organization name. For example, if your Docker Hub repository is 'myorg/myrepo', enter 'myorg'.\nName: ")
-
+	repoName, err := utils.PromptPlaintext("Provide the Docker Hub repository, in the form of ${org_name}/${repo_name}. For example, porter1/porter.\nRepository: ")
 	if err != nil {
 		return 0, err
 	}
 
+	orgRepo := strings.Split(repoName, "/")
+
+	if len(orgRepo) != 2 || orgRepo[0] == "" || orgRepo[1] == "" {
+		return 0, fmt.Errorf("invalid Docker Hub repository: %s", repoName)
+	}
+
 	username, err := utils.PromptPlaintext("Docker Hub username: ")
 
 	if err != nil {

+ 3 - 0
cli/cmd/deploy/create.go

@@ -438,6 +438,9 @@ func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, err
 	if strings.Contains(imageURI, "pkg.dev") {
 		repoSlice := strings.Split(imageURI, "/")
 		imageURI = fmt.Sprintf("%s/%s", imageURI, repoSlice[len(repoSlice)-1])
+	} else if strings.Contains(imageURI, "index.docker.io") {
+		repoSlice := strings.Split(imageURI, "/")
+		imageURI = strings.Join(repoSlice[:len(repoSlice)-1], "/")
 	}
 
 	return regID, imageURI, nil

+ 13 - 0
cli/cmd/docker/builder.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
+	"github.com/docker/docker/pkg/fileutils"
 	"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/stringid"
@@ -47,6 +48,8 @@ func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 		}
 	}
 
+	excludes = trimBuildFilesFromExcludes(excludes, dockerfilePath)
+
 	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{
 		ExcludePatterns: excludes,
 	})
@@ -107,6 +110,16 @@ func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 	return jsonmessage.DisplayJSONMessagesStream(out.Body, os.Stderr, termFd, isTerm, nil)
 }
 
+func trimBuildFilesFromExcludes(excludes []string, dockerfile string) []string {
+	if keep, _ := fileutils.Matches(".dockerignore", excludes); keep {
+		excludes = append(excludes, "!.dockerignore")
+	}
+	if keep, _ := fileutils.Matches(dockerfile, excludes); keep {
+		excludes = append(excludes, "!"+dockerfile)
+	}
+	return excludes
+}
+
 // AddDockerfileToBuildContext from a ReadCloser, returns a new archive and
 // the relative path to the dockerfile in the context.
 func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) {

+ 52 - 32
cli/cmd/run.go

@@ -528,6 +528,24 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 }
 
 func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
+	// try and create the cron job and all of the other required resources as necessary,
+	// starting with the service account, then role and then a role binding
+
+	err := checkForServiceAccount(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForClusterRole(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForRoleBinding(config)
+	if err != nil {
+		return err
+	}
+
 	namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
 	if err != nil {
 		return err
@@ -541,7 +559,13 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 			return err
 		}
 
-		if namespace.Name != "default" {
+		if namespace.Name == "default" {
+			for _, cronJob := range cronJobs.Items {
+				if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+					return nil
+				}
+			}
+		} else {
 			for _, cronJob := range cronJobs.Items {
 				if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
 					err = config.Clientset.BatchV1beta1().CronJobs(namespace.Name).Delete(
@@ -553,30 +577,6 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 				}
 			}
 		}
-
-		for _, cronJob := range cronJobs.Items {
-			if namespace.Name == "default" && cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
-				return nil
-			}
-		}
-	}
-
-	// try and create the cron job and all of the other required resources as necessary,
-	// starting with the service account, then role and then a role binding
-
-	err = checkForServiceAccount(config)
-	if err != nil {
-		return err
-	}
-
-	err = checkForClusterRole(config)
-	if err != nil {
-		return err
-	}
-
-	err = checkForRoleBinding(config)
-	if err != nil {
-		return err
 	}
 
 	// create the cronjob
@@ -618,16 +618,36 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 }
 
 func checkForServiceAccount(config *PorterRunSharedConfig) error {
-	serviceAccounts, err := config.Clientset.CoreV1().ServiceAccounts(namespace).List(
-		context.Background(), metav1.ListOptions{},
-	)
+	namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
 	if err != nil {
 		return err
 	}
 
-	for _, serviceAccount := range serviceAccounts.Items {
-		if serviceAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
-			return nil
+	for _, namespace := range namespaces.Items {
+		serviceAccounts, err := config.Clientset.CoreV1().ServiceAccounts(namespace.Name).List(
+			context.Background(), metav1.ListOptions{},
+		)
+		if err != nil {
+			return err
+		}
+
+		if namespace.Name == "default" {
+			for _, svcAccount := range serviceAccounts.Items {
+				if svcAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
+					return nil
+				}
+			}
+		} else {
+			for _, svcAccount := range serviceAccounts.Items {
+				if svcAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
+					err = config.Clientset.CoreV1().ServiceAccounts(namespace.Name).Delete(
+						context.Background(), svcAccount.Name, metav1.DeleteOptions{},
+					)
+					if err != nil {
+						return err
+					}
+				}
+			}
 		}
 	}
 
@@ -636,7 +656,7 @@ func checkForServiceAccount(config *PorterRunSharedConfig) error {
 			Name: "porter-ephemeral-pod-deletion-service-account",
 		},
 	}
-	_, err = config.Clientset.CoreV1().ServiceAccounts(namespace).Create(
+	_, err = config.Clientset.CoreV1().ServiceAccounts("default").Create(
 		context.Background(), serviceAccount, metav1.CreateOptions{},
 	)
 	if err != nil {

+ 8 - 0
dashboard/src/assets/cluster.svg

@@ -0,0 +1,8 @@
+<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M9.15631 13.4409C11.4924 13.4409 13.3656 11.5569 13.3656 9.20736C13.3656 6.85691 11.4924 4.97385 9.15631 4.97385C6.8202 4.97385 4.94702 6.85691 4.94702 9.20736C4.94702 11.5569 6.8202 13.4409 9.15631 13.4409" fill="white"/>
+<path opacity="0.4" d="M18.2952 10.1931C18.8996 7.81564 17.1276 5.68042 14.8712 5.68042C14.6259 5.68042 14.3913 5.70744 14.162 5.75336C14.1316 5.76057 14.0976 5.77588 14.0797 5.8029C14.0591 5.83712 14.0743 5.88305 14.0967 5.91276C14.7745 6.86915 15.164 8.03357 15.164 9.28354C15.164 10.4813 14.8067 11.598 14.1799 12.5246C14.1155 12.6201 14.1728 12.7489 14.2865 12.7687C14.4441 12.7966 14.6053 12.811 14.77 12.8155C16.4131 12.8588 17.8878 11.7952 18.2952 10.1931" fill="white"/>
+<path opacity="0.4" d="M4.25211 5.75364C4.02378 5.70681 3.78829 5.68069 3.54295 5.68069C1.28653 5.68069 -0.485469 7.81591 0.119824 10.1934C0.526336 11.7955 2.00106 12.859 3.64413 12.8158C3.80888 12.8113 3.97095 12.796 4.12765 12.769C4.24136 12.7492 4.29867 12.6204 4.2342 12.5249C3.60742 11.5973 3.25015 10.4816 3.25015 9.28382C3.25015 8.03295 3.64055 6.86853 4.31837 5.91304C4.33986 5.88332 4.35597 5.83739 4.33448 5.80317C4.31658 5.77525 4.28345 5.76084 4.25211 5.75364" fill="white"/>
+<path d="M13.4409 9.25842C13.4409 6.92232 11.5569 5.04914 9.20739 5.04914C6.85694 5.04914 4.97388 6.92232 4.97388 9.25842C4.97388 11.5945 6.85694 13.4677 9.20739 13.4677C11.5569 13.4677 13.4409 11.5945 13.4409 9.25842" fill="white"/>
+<path opacity="0.4" d="M10.1931 0.119514C7.81564 -0.484882 5.68042 1.28712 5.68042 3.54353C5.68042 3.78887 5.70744 4.02347 5.75336 4.25269C5.76057 4.28314 5.77588 4.31716 5.8029 4.33507C5.83712 4.35566 5.88305 4.34044 5.91276 4.31806C6.86915 3.64024 8.03357 3.25074 9.28354 3.25074C10.4813 3.25074 11.598 3.608 12.5246 4.23479C12.6201 4.29925 12.7489 4.24195 12.7687 4.12823C12.7966 3.97064 12.811 3.80947 12.8155 3.64471C12.8588 2.00165 11.7952 0.526922 10.1931 0.119514" fill="white"/>
+<path opacity="0.4" d="M5.75361 14.1626C5.70678 14.391 5.68066 14.6264 5.68066 14.8718C5.68066 17.1282 7.81588 18.9002 10.1934 18.2949C11.7954 17.8884 12.859 16.4137 12.8158 14.7706C12.8113 14.6059 12.796 14.4438 12.7689 14.2871C12.7491 14.1734 12.6203 14.1161 12.5249 14.1805C11.5973 14.8073 10.4815 15.1646 9.28379 15.1646C8.03292 15.1646 6.8685 14.7742 5.91301 14.0964C5.88329 14.0749 5.83736 14.0588 5.80314 14.0802C5.77522 14.0982 5.76081 14.1313 5.75361 14.1626" fill="white"/>
+</svg>

BIN
dashboard/src/assets/gradient.png


+ 2 - 2
dashboard/src/components/form-components/CheckboxRow.tsx

@@ -53,8 +53,8 @@ const CheckboxWrapper = styled.div<{ disabled?: boolean }>`
 `;
 
 const Checkbox = styled.div<{ checked: boolean }>`
-  width: 16px;
-  height: 16px;
+  width: 12px;
+  height: 12px;
   border: 1px solid #ffffff55;
   margin: 1px 10px 0px 1px;
   border-radius: 3px;

+ 142 - 123
dashboard/src/hosted.index.html

@@ -1,133 +1,152 @@
 <!DOCTYPE html>
 <html lang="en">
+  <head>
+    <title>Porter | Dashboard</title>
 
-<head>
-  <title>Porter | Dashboard</title>
+    <script>
+      window.intercomSettings = {
+        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+        custom_launcher_selector: "#intercom_help",
+      };
+    </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);
+    <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 {
-          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;
-            };
+          var d = document;
+          var i = function () {
+            i.c(arguments);
           };
-          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;
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
           };
-          analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
-          analytics.SNIPPET_VERSION = "4.13.2";
-          analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-          analytics.page();
+          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>
-  <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>
+      })();
+    </script>
 
-<body>
-  <div id="output"></div>
-  <div id="modal-root"></div>
-</body>
+    <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>
 
-</html>
+  <body>
+    <div id="output"></div>
+    <div id="modal-root"></div>
+  </body>
+</html>

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

@@ -16,7 +16,7 @@ import {
 import DashboardHeader from "./DashboardHeader";
 import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
-import NamespaceSelector from "./NamespaceSelector";
+import { NamespaceSelector } from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";

+ 67 - 76
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -6,7 +6,7 @@ import api from "shared/api";
 
 import Selector from "components/Selector";
 
-type PropsType = {
+type Props = {
   setNamespace: (x: string) => void;
   namespace: string;
 };
@@ -16,15 +16,22 @@ type StateType = {
 };
 
 // TODO: fix update to unmounted component
-export default class NamespaceSelector extends Component<PropsType, StateType> {
-  _isMounted = false;
-
-  state = {
-    namespaceOptions: [] as { label: string; value: string }[],
-  };
-
-  updateOptions = () => {
-    let { currentCluster, currentProject } = this.context;
+export const NamespaceSelector: React.FunctionComponent<Props> = ({
+  setNamespace,
+  namespace,
+}) => {
+  const context = useContext(Context);
+  let _isMounted = true;
+  const [namespaceOptions, setNamespaceOptions] = useState<
+    {
+      label: string;
+      value: string;
+    }[]
+  >([]);
+  const [defaultNamespace, setDefaultNamespace] = useState<string>("default");
+
+  const updateOptions = () => {
+    let { currentCluster, currentProject } = context;
 
     api
       .getNamespaces(
@@ -36,7 +43,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        if (this._isMounted) {
+        if (_isMounted) {
           let namespaceOptions: { label: string; value: string }[] = [
             { label: "All", value: "ALL" },
           ];
@@ -49,84 +56,68 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
             urlNamespace = "ALL";
           }
 
-          let defaultNamespace = "default";
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
-          availableNamespaces.forEach(
-            (x: { name: string }, i: number) => {
-              namespaceOptions.push({
-                label: x.name,
-                value: x.name,
-              });
-              if (x.name === urlNamespace) {
-                defaultNamespace = urlNamespace;
-              }
-            }
-          );
-          this.setState({ namespaceOptions }, () => {
-            if (
-              urlNamespace === "" ||
-              defaultNamespace === "" ||
-              urlNamespace === "ALL"
-            ) {
-              this.props.setNamespace("ALL");
-            } else if (this.props.namespace !== defaultNamespace) {
-              this.props.setNamespace(defaultNamespace);
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
+          setDefaultNamespace("default");
+          availableNamespaces.forEach((x: { name: string }, i: number) => {
+            namespaceOptions.push({
+              label: x.name,
+              value: x.name,
+            });
+            if (x.name === urlNamespace) {
+              setDefaultNamespace(urlNamespace);
             }
           });
+          setNamespaceOptions(namespaceOptions);
         }
       })
       .catch((err) => {
-        if (this._isMounted) {
-          this.setState({ namespaceOptions: [{ label: "All", value: "ALL" }] });
+        if (_isMounted) {
+          setNamespaceOptions([{ label: "All", value: "ALL" }]);
         }
       });
   };
 
-  componentDidMount() {
-    this._isMounted = true;
-    this.updateOptions();
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.namespace !== this.props.namespace) {
-      this.updateOptions();
+  useEffect(() => {
+    let urlParams = new URLSearchParams(window.location.search);
+    let urlNamespace = urlParams.get("namespace");
+    if (
+      urlNamespace === "" ||
+      defaultNamespace === "" ||
+      urlNamespace === "ALL"
+    ) {
+      setNamespace("ALL");
+    } else if (namespace !== defaultNamespace) {
+      setNamespace(defaultNamespace);
     }
-  }
+  }, [namespaceOptions]);
 
-  componentWillUnmount() {
-    this._isMounted = false;
-  }
+  useEffect(() => {
+    updateOptions();
+  }, [namespace, context.currentCluster]);
 
-  handleSetActive = (namespace: any) => {
-    // console.log("SELECTED", namespace);
-    this.props.setNamespace(namespace);
+  const handleSetActive = (namespace: any) => {
+    setNamespace(namespace);
   };
 
-  render() {
-    return (
-      <StyledNamespaceSelector>
-        <Label>
-          <i className="material-icons">filter_alt</i> Namespace
-        </Label>
-        <Selector
-          activeValue={this.props.namespace}
-          setActiveValue={this.handleSetActive}
-          options={this.state.namespaceOptions}
-          dropdownLabel="Namespace"
-          width="150px"
-          dropdownWidth="230px"
-          closeOverlay={true}
-        />
-      </StyledNamespaceSelector>
-    );
-  }
-}
-
-NamespaceSelector.contextType = Context;
+  return (
+    <StyledNamespaceSelector>
+      <Label>
+        <i className="material-icons">filter_alt</i> Namespace
+      </Label>
+      <Selector
+        activeValue={namespace}
+        setActiveValue={handleSetActive}
+        options={namespaceOptions}
+        dropdownLabel="Namespace"
+        width="150px"
+        dropdownWidth="230px"
+        closeOverlay={true}
+      />
+    </StyledNamespaceSelector>
+  );
+};
 
 const Label = styled.div`
   display: flex;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/TagFilter.tsx

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 import styled from "styled-components";
 
 const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, currentCluster } = useContext(Context);
   const [selectedTag, setSelectedTag] = useState("none");
   const [tags, setTags] = useState([]);
 
@@ -22,7 +22,7 @@ const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
     return () => {
       isSubscribed = false;
     };
-  }, [currentProject]);
+  }, [currentProject, currentCluster]);
 
   useEffect(() => {
     const currentTag = tags.find((tag) => tag.name === selectedTag);

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

@@ -323,7 +323,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     return () => {
       isSubscribed = false;
     };
-  }, [namespace, currentView]);
+  }, [namespace, currentView, context.currentCluster]);
 
   const filteredCharts = useMemo(() => {
     if (!Array.isArray(charts)) {

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -22,7 +22,9 @@ const tabOptions: {
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
+  /*
   { label: "Incidents", value: "incidents" },
+  */
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -85,7 +87,7 @@ export const Dashboard: React.FunctionComponent = () => {
           </InfoLabel>
         </TopRow>
         <Description>
-          Cluster dashboard for {context.currentCluster.name}
+          Cluster settings for {context.currentCluster.name}
         </Description>
       </InfoSection>
 

+ 4 - 6
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -122,11 +122,9 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
           const namespaceOptions = availableNamespaces.map(
             (x: { name: string }) => {
               return { label: x.name, value: x.name };
@@ -341,7 +339,7 @@ const HeaderSection = styled.div`
   > i {
     cursor: pointer;
     font-size: 20px;
-    color: #969Fbbaa;
+    color: #969fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;
     border-radius: 100px;

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

@@ -7,7 +7,7 @@ import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
 import DashboardHeader from "../DashboardHeader";
-import NamespaceSelector from "../NamespaceSelector";
+import { NamespaceSelector } from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
 import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";

+ 53 - 85
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -10,49 +10,47 @@ import Loading from "components/Loading";
 import { getQueryParam, pushQueryParams } from "shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type Props = RouteComponentProps & {
   currentCluster: ClusterType;
   namespace: string;
   sortType: string;
   setExpandedEnvGroup: (envGroup: any) => void;
 };
 
-type StateType = {
+type State = {
   envGroups: any[];
   loading: boolean;
   error: boolean;
 };
 
-const dummyEnvGroups = [
-  { name: "sapporo", last_updated: "12", namespace: "default" },
-  { name: "backend-staging", last_updated: "4", namespace: "default" },
-  { name: "backend-production", last_updated: "7", namespace: "default" },
-];
-
-class EnvGroupList extends Component<PropsType, StateType> {
-  state = {
-    envGroups: [] as any[],
-    loading: false,
-    error: false,
-  };
+const EnvGroupList: React.FunctionComponent<Props> = (props) => {
+  const context = useContext(Context);
+
+  const { currentCluster, namespace, sortType, setExpandedEnvGroup } = props;
+
+  const [envGroups, setEnvGroups] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [hasError, setHasError] = useState<boolean>(false);
 
-  updateEnvGroups = async () => {
-    const { currentCluster, namespace, sortType } = this.props;
+  const updateEnvGroups = async () => {
+    let { currentProject, currentCluster } = context;
     try {
       const envGroups = await api
         .listEnvGroups(
           "<token>",
           {},
           {
-            id: this.context.currentProject.id,
-            namespace: this.props.namespace,
-            cluster_id: this.props.currentCluster.id,
+            id: currentProject.id,
+            namespace: namespace,
+            cluster_id: currentCluster.id,
           }
         )
-        .then((res) => res.data);
+        .then((res) => {
+          return res.data;
+        });
 
       let sortedGroups = envGroups;
-      switch (this.props.sortType) {
+      switch (sortType) {
         case "Oldest":
           sortedGroups.sort((a: any, b: any) =>
             Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
@@ -69,76 +67,50 @@ class EnvGroupList extends Component<PropsType, StateType> {
 
       return sortedGroups;
     } catch (error) {
-      console.log(error);
-      this.setState({ loading: false, error: true });
+      setIsLoading(false);
+      setHasError(true);
     }
   };
 
-  componentDidMount() {
-    this.setState({ loading: true });
-    this.updateEnvGroups().then((envGroups) => {
-      const selectedEnvGroup = getQueryParam(this.props, "selected_env_group");
-
-      if (selectedEnvGroup) {
-        // find env group by selectedEnvGroup
-        const envGroup = envGroups.find(
-          (envGroup: any) => envGroup.name === selectedEnvGroup
-        );
-        if (envGroup) {
-          this.props.setExpandedEnvGroup(envGroup);
-          return;
+  useEffect(() => {
+    // Prevents reload when opening ClusterConfigModal
+    (namespace || namespace === "") &&
+      updateEnvGroups().then((envGroups) => {
+        const selectedEnvGroup = getQueryParam(props, "selected_env_group");
+
+        setEnvGroups(envGroups);
+        if (envGroups && envGroups.length > 0) {
+          setHasError(false);
         }
-      }
-      this.setState({ envGroups, loading: false });
-    });
-  }
+        setIsLoading(false);
 
-  componentDidUpdate(prevProps: PropsType) {
-    // Prevents reload when opening ClusterConfigModal
-    if (
-      prevProps.currentCluster !== this.props.currentCluster ||
-      prevProps.namespace !== this.props.namespace ||
-      prevProps.sortType !== this.props.sortType
-    ) {
-      (this.props.namespace || this.props.namespace === "") &&
-        this.updateEnvGroups().then((envGroups) => {
-          const selectedEnvGroup = getQueryParam(
-            this.props,
-            "selected_env_group"
+        if (selectedEnvGroup) {
+          // find env group by selectedEnvGroup
+          const envGroup = envGroups.find(
+            (envGroup: any) => envGroup.name === selectedEnvGroup
           );
-
-          this.setState({ envGroups, loading: false });
-
-          if (selectedEnvGroup) {
-            // find env group by selectedEnvGroup
-            const envGroup = envGroups.find(
-              (envGroup: any) => envGroup.name === selectedEnvGroup
-            );
-            if (envGroup) {
-              this.props.setExpandedEnvGroup(envGroup);
-            } else {
-              pushQueryParams(this.props, {}, ["selected_env_group"]);
-            }
+          if (envGroup) {
+            setExpandedEnvGroup(envGroup);
+          } else {
+            pushQueryParams(props, {}, ["selected_env_group"]);
           }
-        });
-    }
-  }
+        }
+      });
+  }, [currentCluster, namespace, sortType]);
 
-  handleExpand = (envGroup: any) => {
-    pushQueryParams(this.props, { selected_env_group: envGroup.name }, []);
-    this.props.setExpandedEnvGroup(envGroup);
+  const handleExpand = (envGroup: any) => {
+    pushQueryParams(props, { selected_env_group: envGroup.name }, []);
+    props.setExpandedEnvGroup(envGroup);
   };
 
-  renderEnvGroupList = () => {
-    let { loading, error, envGroups } = this.state;
-
-    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
+  const renderEnvGroupList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error) {
+    } else if (hasError) {
       return (
         <Placeholder>
           <i className="material-icons">error</i> Error connecting to cluster.
@@ -153,23 +125,19 @@ class EnvGroupList extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.envGroups.map((envGroup: any, i: number) => {
+    return envGroups.map((envGroup: any, i: number) => {
       return (
         <EnvGroup
           key={i}
           envGroup={envGroup}
-          setExpanded={() => this.handleExpand(envGroup)}
+          setExpanded={() => handleExpand(envGroup)}
         />
       );
     });
   };
 
-  render() {
-    return <StyledEnvGroupList>{this.renderEnvGroupList()}</StyledEnvGroupList>;
-  }
-}
-
-EnvGroupList.contextType = Context;
+  return <StyledEnvGroupList>{renderEnvGroupList()}</StyledEnvGroupList>;
+};
 
 export default withRouter(EnvGroupList);
 

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

@@ -530,9 +530,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
 
+    /* Temporarily disable incident detection
     if (!DisabledNamespacesForIncidents.includes(currentChart.namespace)) {
       leftTabOptions.push({ label: "Incidents", value: "incidents" });
     }
+    */
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

+ 18 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -109,9 +109,25 @@ export default class JobResource extends Component<PropsType, StateType> {
       }
     });
 
+    if (!completeCondition) {
+      // otherwise look for a failed reason
+      this.props.job.status?.conditions?.forEach(
+        (condition: any, i: number) => {
+          if (condition.type == "Failed") {
+            completeCondition = condition;
+          }
+        }
+      );
+    }
+
+    // if still no complete condition, return unknown
+    if (!completeCondition) {
+      return "Succeeded";
+    }
+
     return (
-      completeCondition.reason ||
-      `Completed at ${readableDate(completeCondition.lastTransitionTime)}`
+      completeCondition?.reason ||
+      `Completed at ${readableDate(completeCondition?.lastTransitionTime)}`
     );
   };
 

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx

@@ -45,7 +45,9 @@ const PodRow: React.FunctionComponent<PodRowProps> = ({
           <Grey>Created on: {pod.podAge}</Grey>
           {podStatus === "failed" ? (
             <FailedStatusContainer>
-              <Grey>Failure Reason: {pod?.containerStatus?.state?.waiting?.reason}</Grey>
+              <Grey>
+                Failure Reason: {pod?.containerStatus?.state?.waiting?.reason}
+              </Grey>
               <Grey>{pod?.containerStatus?.state?.waiting?.message}</Grey>
             </FailedStatusContainer>
           ) : null}

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

@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
-import NamespaceSelector from "../NamespaceSelector";
+import { NamespaceSelector } from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
 import { Action } from "./components/styles";
 import StackList from "./_StackList";

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

@@ -153,7 +153,7 @@ export const SelectStyles = {
     height: 35px;
     border: 1px solid #ffffff55;
     font-size: 13px;
-    color: ${props => props.readOnly ? "#ffffff44" : ""};
+    color: ${(props) => (props.readOnly ? "#ffffff44" : "")};
     padding: 5px 10px;
     padding-left: 15px;
     border-radius: 3px;

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

@@ -91,7 +91,7 @@ const StackList = ({
     return () => {
       isSubscribed = false;
     };
-  }, [namespace]);
+  }, [namespace, currentCluster]);
 
   const sortedStacks = useMemo(() => {
     return (

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

@@ -62,9 +62,7 @@ export const AddResourceButton = () => {
       <AddResourceButtonStyles.Flex>
         <LinkMask
           to={`/stacks/launch/new-app/${currentTemplate?.name}/${currentVersion}`}
-        >
-          
-        </LinkMask>
+        ></LinkMask>
         <Icon>
           <i className="material-icons">add</i>
         </Icon>
@@ -103,4 +101,4 @@ const Icon = styled.div`
     font-size: 20px;
     color: #aaaabb;
   }
-`;
+`;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -162,7 +162,7 @@ class Templates extends Component<PropsType, StateType> {
           <TemplateBlock
             onClick={() => {
               this.context.setCurrentCluster(cluster);
-              pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+              pushFiltered(this.props, "/applications", ["project_id"], {
                 cluster: cluster.name,
               });
             }}

+ 0 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -291,7 +291,6 @@ const Overlay = styled.div`
   height: 100%;
   width: 100%;
   position: absolute;
-  background: #00000028;
   top: 0;
   left: 0;
   border-radius: 5px;

+ 0 - 1
dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx

@@ -126,7 +126,6 @@ const GitlabForm: React.FC<Props> = () => {
           makeFlush={true}
           text="Save Gitlab Settings"
           status={buttonStatus || error?.message}
-          
         />
       </StyledForm>
     </>

+ 13 - 13
dashboard/src/main/home/launch/TemplateList.tsx

@@ -49,19 +49,19 @@ const TemplateList: React.FC<Props> = ({
           throw Error("Data is not an array");
         }
 
-        let sortedVersionData = data.map((template: any) => {
-          let versions = template.versions.reverse();
-
-          versions = template.versions.sort(semver.rcompare);
-
-          return {
-            ...template,
-            versions,
-            currentVersion: versions[0],
-          };
-        }).sort((a: any, b: any) =>
-          a.name > b.name ? 1 : -1
-        );
+        let sortedVersionData = data
+          .map((template: any) => {
+            let versions = template.versions.reverse();
+
+            versions = template.versions.sort(semver.rcompare);
+
+            return {
+              ...template,
+              versions,
+              currentVersion: versions[0],
+            };
+          })
+          .sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
 
         setTemplateList(sortedVersionData);
         setIsLoading(false);

+ 3 - 5
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -99,11 +99,9 @@ class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
           const namespaceOptions = availableNamespaces.map(
             (x: { name: string }) => {
               return { label: x.name, value: x.name };

+ 2 - 6
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -29,13 +29,9 @@ export default class ClusterInstructionsModal extends Component<
         return (
           <Placeholder>
             1. To install the Porter CLI, run the following in your terminal:
-            <Code>
-              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
-            </Code>
+            <Code>/bin/bash -c "$(curl -fsSL https://install.porter.run)"</Code>
             Alternatively, on macOS you can use Homebrew:
-            <Code>
-              brew install porter-dev/porter/porter
-            </Code>
+            <Code>brew install porter-dev/porter/porter</Code>
             2. Log in to the Porter CLI:
             <Code>
               porter config set-host {location.protocol + "//" + location.host}

+ 2 - 2
dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx

@@ -10,8 +10,8 @@ const ConnectToDatabaseInstructionsModal = () => {
     <Container>
       In order to get connection credentials for your RDS Postgres database,
       select <b>Load from Env Group</b> when launching or updating your
-      application. Then, select the rds-credentials-{currentModalData?.name}{" "}
-      env group.
+      application. Then, select the rds-credentials-{currentModalData?.name} env
+      group.
       <p>
         This will set the following environment variables in your application:
       </p>

+ 0 - 1
dashboard/src/main/home/new-project/NewProject.tsx

@@ -217,7 +217,6 @@ const Letter = styled.div`
   height: 100%;
   width: 100%;
   position: absolute;
-  background: #00000028;
   top: 0;
   left: 0;
   display: flex;

+ 4 - 0
dashboard/src/main/home/onboarding/components/ProviderSelector.tsx

@@ -37,11 +37,13 @@ export const registryOptions = [
     icon: integrationList["gcr"]?.icon,
     label: "Google Artifact Registry (GAR)",
   },
+  /*
   {
     value: "do",
     icon: integrationList["do"]?.icon,
     label: "DigitalOcean Container Registry (DOCR)",
   },
+  */
 ];
 
 export const provisionerOptions = [
@@ -56,11 +58,13 @@ export const provisionerOptions = [
     label: "Google Cloud Platform (GCP)",
   },
 
+  /*
   {
     value: "do",
     icon: integrationList["do"]?.icon,
     label: "DigitalOcean (DO)",
   },
+  */
 ];
 
 export const provisionerOptionsWithExternal = [

+ 3 - 7
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx

@@ -48,7 +48,7 @@ const ConnectExternalCluster: React.FC<Props> = ({
           }
         }
       });
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -65,13 +65,9 @@ const ConnectExternalCluster: React.FC<Props> = ({
         return (
           <Placeholder>
             1. To install the Porter CLI, run the following in your terminal:
-            <Code>
-              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
-            </Code>
+            <Code>/bin/bash -c "$(curl -fsSL https://install.porter.run)"</Code>
             Alternatively, on macOS you can use Homebrew:
-            <Code>
-              brew install porter-dev/porter/porter
-            </Code>
+            <Code>brew install porter-dev/porter/porter</Code>
           </Placeholder>
         );
       case 1:

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

@@ -27,7 +27,7 @@ type Props = {
   provisioner?: boolean;
 };
 
-const providers = ["aws", "gcp", "do"];
+const providers = ["aws", "gcp"];
 
 const ProvisionerSettings: React.FC<Props> = ({
   provisioner,

+ 306 - 290
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -1,331 +1,348 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import drawerBg from "assets/drawer-bg.png";
-
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { ClusterType } from "shared/types";
+import React, { useEffect, useState } from "react";
 
-import Drawer from "./Drawer";
-import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+import { ClusterType, ProjectType } from "shared/types";
 import { Tooltip } from "@material-ui/core";
-import SidebarLink from "./SidebarLink";
+import settings from "assets/settings.svg";
 
-type PropsType = RouteComponentProps & {
-  forceCloseDrawer: boolean;
-  releaseDrawer: () => void;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  isSelected: boolean;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
+import sliders from "assets/sliders.svg";
+import cluster from "assets/cluster.svg";
 
-type StateType = {
-  showDrawer: boolean;
-  initializedDrawer: boolean;
-  clusters: ClusterType[];
+import SidebarLink from "./SidebarLink";
 
-  // Track last project id for refreshing clusters on project change
-  prevProjectId: number;
+type Props = {
+  cluster: ClusterType;
+  currentCluster: ClusterType;
+  currentProject: ProjectType;
+  setCurrentCluster: (x: ClusterType, callback?: any) => void;
+  navToClusterDashboard: () => void;
 };
 
-class ClusterSection extends Component<PropsType, StateType> {
-  // Need to track initialized for animation mounting
-  state = {
-    showDrawer: false,
-    initializedDrawer: false,
-    clusters: [] as ClusterType[],
-    prevProjectId: this.context.currentProject.id,
-  };
-
-  updateClusters = () => {
-    let {
-      user,
-      currentProject,
-      setCurrentCluster,
-      currentCluster,
-    } = this.context;
-
-    // TODO: query with selected filter once implemented
-    api
-      .getClusters("<token>", {}, { id: currentProject.id })
-      .then((res) => {
-        window.analytics?.identify(user.userId, {
-          currentProject,
-          clusters: res.data,
-        });
+export const ClusterSection: React.FC<Props> = ({
+  cluster,
+  currentCluster,
+  currentProject,
+  setCurrentCluster,
+  navToClusterDashboard,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useEffect(() => {
+    if (!isExpanded) {
+      currentCluster.id === cluster.id && setIsExpanded(true);
+    }
+  }, [currentCluster]);
 
-        this.props.setWelcome(false);
-        // TODO: handle uninitialized kubeconfig
-        if (res.data) {
-          let clusters = res.data;
-          clusters.sort((a: any, b: any) => a.id - b.id);
-          if (clusters.length > 0) {
-            let queryString = window.location.search;
-            let urlParams = new URLSearchParams(queryString);
-            let paramClusterName = urlParams.get("cluster");
-            let params = this.props.match.params as any;
-            let pathClusterName = params.cluster;
+  const renderClusterContent = (cluster: any) => {
+    let clusterId = cluster.id;
 
-            // Set cluster from URL if in path or params
-            let defaultCluster = null as ClusterType;
-            if (paramClusterName || pathClusterName) {
-              clusters.forEach((cluster: ClusterType) => {
-                if (!defaultCluster) {
-                  if (cluster.name === pathClusterName) {
-                    defaultCluster = cluster;
-                  } else if (cluster.name === paramClusterName) {
-                    defaultCluster = cluster;
-                  }
-                }
-              });
+    if (currentCluster && isExpanded) {
+      return (
+        <Relative>
+          <SideLine />
+          <NavButton
+            path="/applications"
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname === "/applications"
             }
-
-            this.setState({ clusters });
-            let saved = JSON.parse(
-              localStorage.getItem(currentProject.id + "-cluster")
-            );
-            if (!defaultCluster && saved && saved !== "null") {
-              // Ensures currentCluster isn't prematurely set (causes issues downstream)
-              let loaded = false;
-              for (let i = 0; i < clusters.length; i++) {
-                if (
-                  clusters[i].id === saved.id &&
-                  clusters[i].project_id === saved.project_id &&
-                  clusters[i].name === saved.name
-                ) {
-                  loaded = true;
-                  setCurrentCluster(clusters[i]);
-                  break;
+          >
+            <Img src={monoweb} />
+            Applications
+          </NavButton>
+          <NavButton
+            path="/jobs"
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname === "/jobs"
+            }
+          >
+            <Img src={monojob} />
+            Jobs
+          </NavButton>
+          <NavButton
+            path="/env-groups"
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname === "/env-groups"
+            }
+          >
+            <Img src={sliders} />
+            Env groups
+          </NavButton>
+          {cluster.service === "eks" &&
+            cluster.infra_id > 0 &&
+            currentProject.enable_rds_databases && (
+              <NavButton
+                path="/databases"
+                active={
+                  currentCluster.id === clusterId &&
+                  window.location.pathname === "/databases"
                 }
+              >
+                <Icon className="material-icons-outlined">storage</Icon>
+                Databases
+              </NavButton>
+            )}
+          {currentProject?.stacks_enabled ? (
+            <NavButton
+              path="/stacks"
+              active={
+                currentCluster.id === clusterId &&
+                window.location.pathname === "/stacks"
               }
-              if (!loaded) {
-                setCurrentCluster(clusters[0]);
+            >
+              <Icon className="material-icons-outlined">lan</Icon>
+              Stacks
+            </NavButton>
+          ) : null}
+          {currentProject?.preview_envs_enabled && (
+            <NavButton 
+              path="/preview-environments"
+              active={
+                currentCluster.id === clusterId &&
+                window.location.pathname === "/preview-environments"
               }
-            } else {
-              setCurrentCluster(defaultCluster || clusters[0]);
+            >
+              <InlineSVGWrapper
+                id="Flat"
+                fill="#FFFFFF"
+                xmlns="http://www.w3.org/2000/svg"
+                viewBox="0 0 256 256"
+              >
+                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
+              </InlineSVGWrapper>
+              Preview envs
+            </NavButton>
+          )}
+          <NavButton
+            path={"/cluster-dashboard"}
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname === "/cluster-dashboard"
             }
-          } else if (
-            this.props.currentView !== "provisioner" &&
-            this.props.currentView !== "new-project"
-          ) {
-            this.setState({ clusters: [] });
-            setCurrentCluster(null);
-          }
-        }
-      })
-      .catch((err) => this.props.setWelcome(true));
-  };
-
-  componentDidMount() {
-    this.updateClusters();
-  }
-
-  // Need to override showDrawer when the sidebar is closed
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
-      // Refresh clusters on project change
-      if (this.state.prevProjectId !== this.context.currentProject.id) {
-        this.updateClusters();
-        this.setState({ prevProjectId: this.context.currentProject.id });
-      } else if (this.props.forceRefreshClusters === true) {
-        this.updateClusters();
-        this.props.setRefreshClusters(false);
-      }
-
-      if (this.props.forceCloseDrawer && this.state.showDrawer) {
-        this.setState({ showDrawer: false });
-        this.props.releaseDrawer();
-      }
-    }
-  }
-
-  toggleDrawer = (): void => {
-    if (!this.state.initializedDrawer) {
-      this.setState({ initializedDrawer: true });
-    }
-    this.setState({ showDrawer: !this.state.showDrawer });
-  };
-
-  renderDrawer = (): JSX.Element | undefined => {
-    if (this.state.initializedDrawer) {
-      return (
-        <Drawer
-          toggleDrawer={this.toggleDrawer}
-          showDrawer={this.state.showDrawer}
-          clusters={this.state.clusters}
-        />
-      );
-    }
-  };
-
-  showClusterConfigModal = () => {
-    this.context.setCurrentModal("ClusterConfigModal", {
-      updateClusters: this.updateClusters,
-    });
-  };
-
-  renderContents = (): JSX.Element => {
-    let { clusters, showDrawer } = this.state;
-    let { currentCluster } = this.context;
-
-    if (clusters.length > 0) {
-      return (
-        <ClusterSelector path="/cluster-dashboard">
-          <LinkWrapper>
-            <ClusterIcon>
-              <i className="material-icons">device_hub</i>
-            </ClusterIcon>
-            <Tooltip title={currentCluster?.name}>
-              <ClusterName>{currentCluster?.name}</ClusterName>
-            </Tooltip>
-          </LinkWrapper>
-          <DrawerButton
-            onClick={(e) => {
-              e.preventDefault();
-              this.toggleDrawer();
-            }}
           >
-            <BgAccent src={drawerBg} />
-            <DropdownIcon showDrawer={showDrawer}>
-              <i className="material-icons">arrow_drop_down</i>
-            </DropdownIcon>
-          </DrawerButton>
-        </ClusterSelector>
+            <Icon className="material-icons">device_hub</Icon>
+            Cluster settings
+          </NavButton>
+        </Relative>
       );
     }
+  };
 
-    return (
-      <InitializeButton
-        onClick={() =>
-          this.context.setCurrentModal("ClusterInstructionsModal", {})
+  return (
+    <>
+      <ClusterSelector 
+        onClick={() => setIsExpanded(!isExpanded)}
+        active={
+          !isExpanded && cluster.id === currentCluster.id && [
+            "/cluster-dashboard",
+            "/preview-environments",
+            "/stacks",
+            "/databases",
+            "/env-groups",
+            "/jobs",
+            "/applications"
+          ].includes(window.location.pathname)
         }
       >
-        <Plus>+</Plus> Connect a Cluster
-      </InitializeButton>
-    );
-  };
+        <LinkWrapper>
+          <ClusterIcon>
+            <svg
+              width="19"
+              height="19"
+              viewBox="0 0 19 19"
+              fill="none"
+              xmlns="http://www.w3.org/2000/svg"
+            >
+              <path
+                d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                fill-rule="evenodd"
+                clip-rule="evenodd"
+                d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                fill-rule="evenodd"
+                clip-rule="evenodd"
+                d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+            </svg>
+          </ClusterIcon>
+          <Tooltip title={cluster?.name}>
+            <ClusterName>{cluster?.name}</ClusterName>
+          </Tooltip>
+          <I isExpanded={isExpanded} className="material-icons">
+            arrow_drop_down
+          </I>
+          <Spacer />
+        </LinkWrapper>
+      </ClusterSelector>
+      <div onClick={() => setCurrentCluster(cluster)}>
+        {renderClusterContent(cluster)}
+      </div>
+    </>
+  );
+};
 
-  render() {
-    return (
-      <>
-        {this.renderDrawer()}
-        {this.renderContents()}
-      </>
-    );
+const InlineSVGWrapper = styled.svg`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+  padding-left: 0;
+
+  > path {
+    fill: #ffffff;
   }
-}
+`;
 
-ClusterSection.contextType = Context;
+const Spacer = styled.div`
+  flex: 1;
+`;
 
-export default withRouter(ClusterSection);
+const Settings = styled.p`
+  color: #ffffff44;
+  width: 16px;
+  padding-right: 7px;
+  height: 100%;
+  border-radius: 3px;
+  cursor: pointer;
+  margin-left: 1px;
+  :hover {
+    color: #ffffff;
+  }
+  > i {
+    font-size: 16px;
+    display: flex;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+  }
+`;
 
-const Plus = styled.div`
-  margin-right: 10px;
-  font-size: 15px;
+const I = styled.i`
+  color: #ffffff99;
+  font-size: 20px;
+  border-radius: 100px;
+  transform: ${(props: { isExpanded: boolean }) =>
+    props.isExpanded ? "" : "rotate(-90deg)"};
 `;
 
-const InitializeButton = styled.div`
+const Relative = styled.div`
   position: relative;
+`;
+
+const SideLine = styled.div`
+  position: absolute;
+  left: 32px;
+  width: 1px;
+  top: 5px;
+  height: calc(100% - 12px);
+  background: #383a3f;
+`;
+
+const Icon = styled.span`
+  padding: 4px;
+  width: 22px;
+  padding-top: 4px;
+  border-radius: 3px;
+  margin-right: 8px;
+  font-size: 16px;
+`;
+
+const NavButton = styled(SidebarLink)`
   display: flex;
   align-items: center;
-  justify-content: center;
-  width: calc(100% - 30px);
-  height: 38px;
-  margin: 10px 15px 12px;
+  border-radius: 5px;
+  position: relative;
+  text-decoration: none;
+  height: 34px;
+  margin: 5px 15px;
+  margin-left: 39px;
+  padding: 0 30px 2px 8px;
   font-size: 13px;
-  font-weight: 500;
-  border-radius: 3px;
+  font-family: "Work Sans", sans-serif;
   color: #ffffff;
-  padding-bottom: 1px;
-  cursor: pointer;
-  background: #ffffff11;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: any) => (props.active ? "#ffffff11" : "")};
 
   :hover {
-    background: #ffffff22;
+    background: ${(props: any) => (props.active ? "#ffffff11" : "#ffffff08")};
   }
-`;
 
-const BgAccent = styled.img`
-  height: 30px;
-  background: #819bfd;
-  width: 30px;
-  border-top-left-radius: 100px;
-  max-width: 30px;
-  border-bottom-left-radius: 100px;
-  position: absolute;
-  top: 6px;
-  right: -8px;
-  border: none;
-  outline: none;
+  > i {
+    font-size: 20px;
+    padding-top: 4px;
+    border-radius: 3px;
+    margin-right: 10px;
+  }
 `;
 
-const DrawerButton = styled.div`
-  position: absolute;
-  height: 42px;
+const Img = styled.img<{ enlarge?: boolean }>`
+  padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
+  height: 22px;
   width: 22px;
-  top: 0px;
-  right: 0px;
-  z-index: 0;
-  overflow: hidden;
-  border: none;
-  outline: none;
+  padding-top: 4px;
+  border-radius: 3px;
+  margin-right: 8px;
 `;
 
 const ClusterName = styled.div`
   white-space: nowrap;
   overflow: hidden;
-  padding-right: 15px;
   text-overflow: ellipsis;
   display: inline-block;
-  width: 130px;
   margin-left: 3px;
+  margin-right: 4px;
   font-weight: 400;
   color: #ffffff;
 `;
 
-const DropdownIcon = styled.span`
-  position: absolute;
-  right: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "-2px" : "2px"};
-  top: 10px;
-  > i {
-    font-size: 18px;
-  }
-  -webkit-transform: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotate(-90deg)" : "rotate(90deg)"};
-  transform: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotate(-90deg)" : "rotate(90deg)"};
-  animation: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotateLeft 0.5s" : "rotateRight 0.5s"};
-  animation-fill-mode: forwards;
-
-  @keyframes rotateLeft {
-    100% {
-      right: 2px;
-      -webkit-transform: rotate(90deg);
-      transform: rotate(90deg);
-    }
-  }
-
-  @keyframes rotateRight {
-    100% {
-      right: -2px;
-      -webkit-transform: rotate(-90deg);
-      transform: rotate(-90deg);
-    }
-  }
-`;
-
 const ClusterIcon = styled.div`
-  > i {
-    font-size: 16px;
+  > svg {
+    width: 13px;
     display: flex;
     align-items: center;
-    margin-bottom: 0px;
-    margin-left: 17px;
-    margin-right: 10px;
+    margin-bottom: -1x;
+    margin-right: 9px;
     color: #ffffff;
   }
 `;
@@ -335,31 +352,30 @@ const LinkWrapper = styled.div`
   height: 100%;
   display: flex;
   align-items: center;
+  justify-content: space-between;
   width: 100%;
 `;
 
-const ClusterSelector = styled(SidebarLink)`
+const ClusterSelector = styled.div`
   position: relative;
   display: block;
-  padding-left: 7px;
-  width: 100%;
-  height: 42px;
-  margin: 0 auto 0 auto;
-  font-size: 14px;
+  border-radius: 5px;
+  width: calc(100% - 30px);
+  height: 34px;
+  padding: 0 6px 2px 11px;
+  font-size: 13px;
+  margin: 5px 15px;
   font-weight: 500;
   color: white;
   cursor: pointer;
   z-index: 1;
-
-  &.active {
-    background: #ffffff11;
-
-    :hover {
-      background: #ffffff11;
-    }
-  }
-
+  background: ${(props: { active?: boolean }) =>
+    props.active ? "#ffffff11" : ""};
   :hover {
-    background: #ffffff08;
+    > div {
+      > i {
+        background: #ffffff11;
+      }
+    }
   }
 `;

+ 207 - 0
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -0,0 +1,207 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { pushFiltered } from "shared/routing";
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+import { ClusterSection } from "./ClusterSection";
+
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {
+  setWelcome: (x: boolean) => void;
+  currentView: string;
+  isSelected: boolean;
+  forceRefreshClusters: boolean;
+  setRefreshClusters: (x: boolean) => void;
+};
+
+type StateType = {
+  clusters: ClusterType[];
+
+  // Track last project id for refreshing clusters on project change
+  prevProjectId: number;
+};
+
+class Clusters extends Component<PropsType, StateType> {
+  // Need to track initialized for animation mounting
+  state = {
+    clusters: [] as ClusterType[],
+    prevProjectId: this.context.currentProject.id,
+  };
+
+  updateClusters = () => {
+    let {
+      user,
+      currentProject,
+      setCurrentCluster,
+      currentCluster,
+    } = this.context;
+
+    // TODO: query with selected filter once implemented
+    api
+      .getClusters("<token>", {}, { id: currentProject.id })
+      .then((res) => {
+        window.analytics?.identify(user.userId, {
+          currentProject,
+          clusters: res.data,
+        });
+
+        this.props.setWelcome(false);
+        // TODO: handle uninitialized kubeconfig
+        if (res.data) {
+          let clusters = res.data;
+          clusters.sort((a: any, b: any) => a.id - b.id);
+          if (clusters.length > 0) {
+            let queryString = window.location.search;
+            let urlParams = new URLSearchParams(queryString);
+            let paramClusterName = urlParams.get("cluster");
+            let params = this.props.match.params as any;
+            let pathClusterName = params.cluster;
+
+            // Set cluster from URL if in path or params
+            let defaultCluster = null as ClusterType;
+            if (paramClusterName || pathClusterName) {
+              clusters.forEach((cluster: ClusterType) => {
+                if (!defaultCluster) {
+                  if (cluster.name === pathClusterName) {
+                    defaultCluster = cluster;
+                  } else if (cluster.name === paramClusterName) {
+                    defaultCluster = cluster;
+                  }
+                }
+              });
+            }
+
+            this.setState({ clusters });
+            let saved = JSON.parse(
+              localStorage.getItem(currentProject.id + "-cluster")
+            );
+            if (!defaultCluster && saved && saved !== "null") {
+              // Ensures currentCluster isn't prematurely set (causes issues downstream)
+              let loaded = false;
+              for (let i = 0; i < clusters.length; i++) {
+                if (
+                  clusters[i].id === saved.id &&
+                  clusters[i].project_id === saved.project_id &&
+                  clusters[i].name === saved.name
+                ) {
+                  loaded = true;
+                  setCurrentCluster(clusters[i]);
+                  break;
+                }
+              }
+              if (!loaded) {
+                setCurrentCluster(clusters[0]);
+              }
+            } else {
+              setCurrentCluster(defaultCluster || clusters[0]);
+            }
+          } else if (
+            this.props.currentView !== "provisioner" &&
+            this.props.currentView !== "new-project"
+          ) {
+            this.setState({ clusters: [] });
+            setCurrentCluster(null);
+          }
+        }
+      })
+      .catch((err) => this.props.setWelcome(true));
+  };
+
+  componentDidMount() {
+    this.updateClusters();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps !== this.props) {
+      // Refresh clusters on project change
+      if (this.state.prevProjectId !== this.context.currentProject.id) {
+        this.updateClusters();
+        this.setState({ prevProjectId: this.context.currentProject.id });
+      } else if (this.props.forceRefreshClusters === true) {
+        this.updateClusters();
+        this.props.setRefreshClusters(false);
+      }
+    }
+  }
+
+  showClusterConfigModal = () => {
+    this.context.setCurrentModal("ClusterConfigModal", {
+      updateClusters: this.updateClusters,
+    });
+  };
+
+  renderContents = (): JSX.Element[] | JSX.Element => {
+    let { clusters } = this.state;
+    let { currentCluster, setCurrentCluster, currentProject } = this.context;
+
+    if (clusters.length > 0 && currentCluster) {
+      clusters.sort((a, b) => a.id - b.id);
+
+      return clusters.map((cluster: ClusterType, i: number) => {
+        return (
+          <ClusterSection
+            key={i}
+            cluster={cluster}
+            currentCluster={currentCluster}
+            currentProject={currentProject}
+            setCurrentCluster={setCurrentCluster}
+            navToClusterDashboard={() => {
+              setCurrentCluster(cluster, () => {
+                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              });
+            }}
+          />
+        );
+      });
+    }
+
+    return (
+      <InitializeButton
+        onClick={() =>
+          this.context.setCurrentModal("ClusterInstructionsModal", {})
+        }
+      >
+        <Plus>+</Plus> Connect a Cluster
+      </InitializeButton>
+    );
+  };
+
+  render() {
+    return <>{this.renderContents()}</>;
+  }
+}
+
+Clusters.contextType = Context;
+
+export default withRouter(Clusters);
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
+const InitializeButton = styled.div`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 30px);
+  height: 38px;
+  margin: 10px 15px 12px;
+  font-size: 13px;
+  font-weight: 500;
+  border-radius: 3px;
+  color: #ffffff;
+  padding-bottom: 1px;
+  cursor: pointer;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 0 - 242
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -1,242 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import close from "assets/close.png";
-
-import { Context } from "shared/Context";
-import { ClusterType } from "shared/types";
-import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
-import { Tooltip } from "@material-ui/core";
-
-type PropsType = RouteComponentProps & {
-  toggleDrawer: () => void;
-  showDrawer: boolean;
-  clusters: ClusterType[];
-};
-
-type StateType = {};
-
-class Drawer extends Component<PropsType, StateType> {
-  renderClusterList = (): JSX.Element[] | JSX.Element => {
-    let { clusters } = this.props;
-    let { currentCluster, setCurrentCluster } = this.context;
-
-    if (clusters.length > 0 && currentCluster) {
-      clusters.sort((a, b) => a.id - b.id);
-
-      return clusters.map((cluster: ClusterType, i: number) => {
-        /*
-        let active = this.context.activeProject &&
-          this.context.activeProject.namespace == val.namespace; 
-        */
-
-        return (
-          <ClusterOption
-            key={i}
-            active={cluster.name === currentCluster.name}
-            onClick={() => {
-              setCurrentCluster(cluster, () => {
-                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
-                  cluster: cluster.name,
-                });
-              });
-            }}
-          >
-            <ClusterIcon>
-              <i className="material-icons">device_hub</i>
-            </ClusterIcon>
-            <Tooltip title={cluster?.name}>
-              <ClusterName>{cluster.name}</ClusterName>
-            </Tooltip>
-          </ClusterOption>
-        );
-      });
-    }
-
-    return <Placeholder>No clusters selected</Placeholder>;
-  };
-
-  renderCloseOverlay = (): JSX.Element | undefined => {
-    if (this.props.showDrawer) {
-      return <CloseOverlay onClick={this.props.toggleDrawer} />;
-    }
-  };
-
-  render() {
-    return (
-      <div>
-        {this.renderCloseOverlay()}
-        <StyledDrawer showDrawer={this.props.showDrawer}>
-          <CloseButton onClick={this.props.toggleDrawer}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-
-          {this.renderClusterList()}
-
-          <InitializeButton
-            onClick={() => {
-              this.context.setCurrentModal("ClusterInstructionsModal", {});
-            }}
-          >
-            <Plus>+</Plus> Connect a Cluster
-          </InitializeButton>
-        </StyledDrawer>
-      </div>
-    );
-  }
-}
-
-Drawer.contextType = Context;
-
-export default withRouter(Drawer);
-
-const Plus = styled.div`
-  margin-right: 10px;
-  font-size: 15px;
-`;
-
-const ButtonLabel = styled.div`
-  display: inline-block;
-  font-size: 14px;
-  position: absolute;
-  top: 11px;
-  left: 61px;
-`;
-
-const InitializeButton = styled.div`
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: calc(100% - 30px);
-  height: 38px;
-  margin: 45px 15px 12px;
-  font-size: 13px;
-  font-weight: 500;
-  border-radius: 3px;
-  color: #ffffff;
-  padding-bottom: 3px;
-  cursor: pointer;
-  background: #ffffff22;
-
-  :hover {
-    background: #ffffff33;
-  }
-`;
-
-const ClusterOption = styled.div`
-  width: 100%;
-  padding: 2px 7px;
-  padding-right: 30px;
-  display: flex;
-  align-items: center;
-  height: 42px;
-  text-decoration: none;
-  color: white;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  cursor: pointer;
-  background: ${(props: { active?: boolean }) =>
-    props.active ? "#ffffff18" : ""};
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
-const Placeholder = styled(ClusterOption)`
-  color: #ffffff99;
-  justify-content: center;
-  padding: 0;
-  cursor: default;
-  :hover {
-    background: none;
-  }
-`;
-
-const CloseOverlay = styled.div`
-  background: transparent;
-  width: 100vw;
-  height: 100vh;
-  position: absolute;
-  top: 0;
-  left: 0;
-  z-index: -2;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  right: 10px;
-  top: 7px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff20;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 12px;
-  margin: 0 auto;
-`;
-
-const ClusterIcon = styled.div`
-  > i {
-    font-size: 16px;
-    display: flex;
-    align-items: center;
-    margin-bottom: 0px;
-    margin-left: 17px;
-    margin-right: 10px;
-  }
-`;
-
-const StyledDrawer = styled.div`
-  position: absolute;
-  height: 100%;
-  padding-top: 41px;
-  width: 230px;
-  overflow-y: auto;
-  padding-bottom: 40px;
-  top: 0;
-  left: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "-30px" : "200px"};
-  z-index: -2;
-  background: #00000fd4;
-  animation: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "slideDrawerRight 0.4s" : "slideDrawerLeft 0.4s"};
-  animation-fill-mode: forwards;
-  @keyframes slideDrawerRight {
-    from {
-      left: -30px;
-      opacity: 0;
-    }
-    to {
-      left: 200px;
-      opacity: 1;
-    }
-  }
-  @keyframes slideDrawerLeft {
-    from {
-      left: 200px;
-      opacity: 1;
-    }
-    to {
-      left: -30px;
-      opacity: 0;
-    }
-  }
-`;
-
-const ClusterName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-left: 3px;
-`;

+ 6 - 6
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -74,7 +74,7 @@ class ProjectSection extends Component<PropsType, StateType> {
         <div>
           <Dropdown>
             {this.renderOptionList()}
-            {this.context.canCreateProject && (
+            {this.context.user?.email.includes("porter.run") && (
               <Option
                 selected={false}
                 lastItem={true}
@@ -83,7 +83,7 @@ class ProjectSection extends Component<PropsType, StateType> {
                 }
               >
                 <ProjectIconAlt>+</ProjectIconAlt>
-                <ProjectLabel>Create a Project</ProjectLabel>
+                <ProjectLabel>Create a project</ProjectLabel>
               </Option>
             )}
           </Dropdown>
@@ -124,7 +124,7 @@ class ProjectSection extends Component<PropsType, StateType> {
           })
         }
       >
-        <Plus>+</Plus> Create a Project
+        <Plus>+</Plus> Create a project
       </InitializeButton>
     );
   }
@@ -197,10 +197,10 @@ const Option = styled.div`
 
 const Dropdown = styled.div`
   position: absolute;
-  right: 10px;
+  right: 13px;
   top: calc(100% + 5px);
   background: #26282f;
-  width: 180px;
+  width: 210px;
   max-height: 500px;
   border-radius: 3px;
   z-index: 999;
@@ -221,7 +221,6 @@ const Letter = styled.div`
   position: absolute;
   padding-bottom: 2px;
   font-weight: 500;
-  background: #00000028;
   top: 0;
   left: 0;
   display: flex;
@@ -254,6 +253,7 @@ const ProjectIconAlt = styled(ProjectIcon)`
 
 const StyledProjectSection = styled.div`
   position: relative;
+  margin-left: 3px;
 `;
 
 const MainSelector = styled.div`

+ 25 - 121
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,14 +3,11 @@ import styled from "styled-components";
 import category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
 import rocket from "assets/rocket.png";
-import monojob from "assets/monojob.png";
-import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
-import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 
-import ClusterSection from "./ClusterSection";
+import Clusters from "./Clusters";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, pushFiltered } from "shared/routing";
@@ -102,83 +99,12 @@ class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
-  renderClusterContent = () => {
-    let { currentCluster, currentProject } = this.context;
-
-    if (currentCluster) {
-      return (
-        <>
-          <NavButton path="/applications">
-            <Img src={monoweb} />
-            Applications
-          </NavButton>
-          <NavButton path="/jobs">
-            <Img src={monojob} />
-            Jobs
-          </NavButton>
-          <NavButton path="/env-groups">
-            <Img src={sliders} />
-            Env Groups
-          </NavButton>
-          {currentCluster.service === "eks" &&
-            currentCluster.infra_id > 0 &&
-            currentProject.enable_rds_databases && (
-              <NavButton path="/databases">
-                <Icon className="material-icons-outlined">storage</Icon>
-                Databases
-              </NavButton>
-            )}
-          {currentProject?.preview_envs_enabled && (
-            <NavButton path="/preview-environments">
-              <InlineSVGWrapper
-                id="Flat"
-                fill="#FFFFFF"
-                xmlns="http://www.w3.org/2000/svg"
-                viewBox="0 0 256 256"
-              >
-                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
-              </InlineSVGWrapper>
-              <EllipsisTextWrapper
-                onMouseOver={() => {
-                  this.setState((prev) => ({
-                    ...prev,
-                    showLinkTooltip: {
-                      ...prev.showLinkTooltip,
-                      prev_envs: true,
-                    },
-                  }));
-                }}
-                onMouseOut={() => {
-                  this.setState((prev) => ({
-                    ...prev,
-                    showLinkTooltip: {
-                      ...prev.showLinkTooltip,
-                      prev_envs: false,
-                    },
-                  }));
-                }}
-              >
-                Preview Envs
-              </EllipsisTextWrapper>
-            </NavButton>
-          )}
-          {currentProject?.stacks_enabled ? (
-            <NavButton path={"/stacks"}>
-              <Icon className="material-icons-outlined">lan</Icon>
-              Stacks
-            </NavButton>
-          ) : null}
-        </>
-      );
-    }
-  };
-
   renderProjectContents = () => {
     let { currentView } = this.props;
     let { currentProject } = this.context;
     if (currentProject) {
       return (
-        <>
+        <ScrollWrapper>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton path={"/dashboard"}>
             <Img src={category} />
@@ -212,7 +138,7 @@ class Sidebar extends Component<PropsType, StateType> {
           ]) && (
             <NavButton path={"/project-settings"}>
               <Img enlarge={true} src={settings} />
-              Settings
+              Project settings
             </NavButton>
           )}
 
@@ -220,20 +146,17 @@ class Sidebar extends Component<PropsType, StateType> {
 
           {this.context.hasFinishedOnboarding && (
             <>
-              <SidebarLabel>Current Cluster</SidebarLabel>
-              <ClusterSection
-                forceCloseDrawer={this.state.forceCloseDrawer}
-                releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
+              <SidebarLabel>Clusters</SidebarLabel>
+              <Clusters
                 setWelcome={this.props.setWelcome}
                 currentView={currentView}
                 isSelected={false}
                 forceRefreshClusters={this.props.forceRefreshClusters}
                 setRefreshClusters={this.props.setRefreshClusters}
               />
-              {this.renderClusterContent()}
             </>
           )}
-        </>
+        </ScrollWrapper>
       );
     }
 
@@ -276,13 +199,10 @@ Sidebar.contextType = Context;
 
 export default withRouter(withAuth(Sidebar));
 
-const Icon = styled.span`
-  padding: 4px;
-  width: 23px;
-  padding-top: 4px;
-  border-radius: 3px;
-  margin-right: 10px;
-  font-size: 18px;
+const ScrollWrapper = styled.div`
+  overflow-y: auto;
+  padding-bottom: 25px;
+  max-height: calc(100vh - 95px);
 `;
 
 const ProjectPlaceholder = styled.div`
@@ -307,11 +227,13 @@ const ProjectPlaceholder = styled.div`
 const NavButton = styled(SidebarLink)`
   display: flex;
   align-items: center;
+  border-radius: 5px;
   position: relative;
   text-decoration: none;
-  height: 42px;
-  padding: 0 30px 2px 20px;
-  font-size: 14px;
+  height: 34px;
+  margin: 5px 15px;
+  padding: 0 30px 2px 6px;
+  font-size: 13px;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
   cursor: ${(props: { disabled?: boolean }) =>
@@ -339,29 +261,11 @@ const NavButton = styled(SidebarLink)`
 
 const Img = styled.img<{ enlarge?: boolean }>`
   padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
-  height: 23px;
-  width: 23px;
+  height: 22px;
+  width: 22px;
   padding-top: 4px;
   border-radius: 3px;
-  margin-right: 10px;
-`;
-
-const InlineSVGWrapper = styled.svg`
-  width: 32px;
-  height: 32px;
-  padding: 8px;
-  padding-left: 0;
-
-  > path {
-    fill: #ffffff;
-  }
-`;
-
-const EllipsisTextWrapper = styled.span`
-  display: block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  margin-right: 8px;
 `;
 
 const SidebarBg = styled.div`
@@ -369,17 +273,17 @@ const SidebarBg = styled.div`
   top: 0;
   left: 0;
   width: 100%;
-  background-color: #292c35;
+  background-color: #202227;
   height: 100%;
   z-index: -1;
-  box-shadow: 8px 0px 8px 0px #00000010;
+  border-right: 1px solid #383a3f;
 `;
 
 const SidebarLabel = styled.div`
   color: #ffffff99;
-  padding: 5px 16px;
+  padding: 5px 23px;
   margin-bottom: 5px;
-  font-size: 14px;
+  font-size: 13px;
   z-index: 1;
   font-weight: 500;
 `;
@@ -465,7 +369,7 @@ const CollapseButton = styled.div`
 
 const StyledSidebar = styled.section`
   font-family: "Work Sans", sans-serif;
-  width: 200px;
+  width: 235px;
   position: relative;
   padding-top: 20px;
   height: 100vh;
@@ -475,7 +379,7 @@ const StyledSidebar = styled.section`
   animation-fill-mode: forwards;
   @keyframes showSidebar {
     from {
-      margin-left: -200px;
+      margin-left: -235px;
     }
     to {
       margin-left: 0px;
@@ -486,7 +390,7 @@ const StyledSidebar = styled.section`
       margin-left: 0px;
     }
     to {
-      margin-left: -200px;
+      margin-left: -235px;
     }
   }
 `;

+ 5 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -99,7 +99,11 @@ export const useWebsockets = () => {
   /**
    * Close specific websocket
    */
-  const closeWebsocket = (id: string, code: number =  4000, reason: string = "User closed the websocket connection") => {
+  const closeWebsocket = (
+    id: string,
+    code: number = 4000,
+    reason: string = "User closed the websocket connection"
+  ) => {
     const ws = websocketMap.current[id];
 
     if (!ws) {

+ 4 - 1
internal/opa/config.yaml

@@ -56,6 +56,7 @@ prometheus:
     name: "prometheus.version"
 nginx_pod:
   kind: "pod"
+  override_severity: "critical"
   match:
     namespace: ingress-nginx
     labels:
@@ -116,4 +117,6 @@ certificates:
     resource: certificates
   policies:
   - path: "./policies/certificates/expiry_two_weeks.rego"
-    name: "certificates.expiry_two_weeks"
+    name: "certificates.expiry_two_weeks"
+  - path: "./policies/certificates/expired.rego"
+    name: "certificates.expired"

+ 5 - 4
internal/opa/loader.go

@@ -13,10 +13,11 @@ import (
 type ConfigFile map[string]ConfigFilePolicyCollection
 
 type ConfigFilePolicyCollection struct {
-	Kind      string             `yaml:"kind"`
-	Match     MatchParameters    `yaml:"match"`
-	MustExist bool               `yaml:"mustExist"`
-	Policies  []ConfigFilePolicy `yaml:"policies"`
+	Kind             string             `yaml:"kind"`
+	Match            MatchParameters    `yaml:"match"`
+	MustExist        bool               `yaml:"mustExist"`
+	OverrideSeverity string             `yaml:"override_severity"`
+	Policies         []ConfigFilePolicy `yaml:"policies"`
 }
 
 type ConfigFilePolicy struct {

+ 20 - 8
internal/opa/opa.go

@@ -39,10 +39,11 @@ const (
 )
 
 type KubernetesOPAQueryCollection struct {
-	Kind      KubernetesBuiltInKind
-	Match     MatchParameters
-	MustExist bool
-	Queries   []rego.PreparedEvalQuery
+	Kind             KubernetesBuiltInKind
+	Match            MatchParameters
+	MustExist        bool
+	OverrideSeverity string
+	Queries          []rego.PreparedEvalQuery
 }
 
 type MatchParameters struct {
@@ -158,7 +159,7 @@ func (runner *KubernetesOPARunner) runHelmReleaseQueries(name string, collection
 						ObjectID:       fmt.Sprintf("helm_release/%s/%s/%s", collection.Match.Namespace, collection.Match.Name, "exists"),
 						CategoryName:   name,
 						PolicyVersion:  "v0.0.1",
-						PolicySeverity: "high",
+						PolicySeverity: getSeverity("high", collection),
 						PolicyTitle:    fmt.Sprintf("The helm release %s must exist", collection.Match.Name),
 						PolicyMessage:  "The helm release was not found on the cluster",
 					},
@@ -172,7 +173,7 @@ func (runner *KubernetesOPARunner) runHelmReleaseQueries(name string, collection
 				ObjectID:       fmt.Sprintf("helm_release/%s/%s/%s", collection.Match.Namespace, collection.Match.Name, "exists"),
 				CategoryName:   name,
 				PolicyVersion:  "v0.0.1",
-				PolicySeverity: "high",
+				PolicySeverity: getSeverity("high", collection),
 				PolicyTitle:    fmt.Sprintf("The helm release %s must exist", collection.Match.Name),
 				PolicyMessage:  "The helm release was found",
 			})
@@ -232,6 +233,7 @@ func (runner *KubernetesOPARunner) runHelmReleaseQueries(name string, collection
 					rawQueryRes,
 					fmt.Sprintf("helm_release/%s/%s/%s", helmRelease.Namespace, helmRelease.Name, rawQueryRes.PolicyID),
 					name,
+					collection,
 				))
 			}
 		}
@@ -240,6 +242,14 @@ func (runner *KubernetesOPARunner) runHelmReleaseQueries(name string, collection
 	return res, nil
 }
 
+func getSeverity(defaultSeverity string, collection KubernetesOPAQueryCollection) string {
+	if collection.OverrideSeverity != "" {
+		return collection.OverrideSeverity
+	}
+
+	return defaultSeverity
+}
+
 func (runner *KubernetesOPARunner) runPodQueries(name string, collection KubernetesOPAQueryCollection) ([]*OPARecommenderQueryResult, error) {
 	res := make([]*OPARecommenderQueryResult, 0)
 
@@ -287,6 +297,7 @@ func (runner *KubernetesOPARunner) runPodQueries(name string, collection Kuberne
 					rawQueryRes,
 					fmt.Sprintf("pod/%s/%s", pod.Namespace, pod.Name),
 					name,
+					collection,
 				))
 			}
 		}
@@ -334,6 +345,7 @@ func (runner *KubernetesOPARunner) runCRDListQueries(name string, collection Kub
 					rawQueryRes,
 					fmt.Sprintf("%s/%s/%s/%s", collection.Match.Group, collection.Match.Version, collection.Match.Resource, rawQueryRes.PolicyID),
 					name,
+					collection,
 				))
 			}
 		}
@@ -342,7 +354,7 @@ func (runner *KubernetesOPARunner) runCRDListQueries(name string, collection Kub
 	return res, nil
 }
 
-func rawQueryResToRecommenderQueryResult(rawQueryRes *rawQueryResult, objectID, categoryName string) *OPARecommenderQueryResult {
+func rawQueryResToRecommenderQueryResult(rawQueryRes *rawQueryResult, objectID, categoryName string, collection KubernetesOPAQueryCollection) *OPARecommenderQueryResult {
 	queryRes := &OPARecommenderQueryResult{
 		ObjectID:     objectID,
 		CategoryName: categoryName,
@@ -357,7 +369,7 @@ func rawQueryResToRecommenderQueryResult(rawQueryRes *rawQueryResult, objectID,
 
 	queryRes.PolicyMessage = message
 	queryRes.Allow = rawQueryRes.Allow
-	queryRes.PolicySeverity = rawQueryRes.PolicySeverity
+	queryRes.PolicySeverity = getSeverity(rawQueryRes.PolicySeverity, collection)
 	queryRes.PolicyTitle = rawQueryRes.PolicyTitle
 	queryRes.PolicyVersion = rawQueryRes.PolicyVersion
 

+ 26 - 0
internal/opa/policies/certificates/expired.rego

@@ -0,0 +1,26 @@
+package certificates.expired
+
+import future.keywords
+
+POLICY_ID := sprintf("certificates_expired_%s_%s", [input.metadata.namespace, input.metadata.name])
+
+POLICY_VERSION := "v0.0.1"
+
+POLICY_SEVERITY := "critical"
+
+POLICY_TITLE := sprintf("Certificate %s/%s should not be expired", [input.metadata.namespace, input.metadata.name])
+
+POLICY_SUCCESS_MESSAGE := sprintf("Success: certificate %s/%s is not expired", [input.metadata.namespace, input.metadata.name])
+
+allow if {
+	not rfc3339_expired(input.status.notAfter)
+}
+
+FAILURE_MESSAGE contains msg if {
+	rfc3339_expired(input.status.notAfter)
+	msg := sprintf("Certificate expired at %s", [input.status.notAfter])
+}
+
+rfc3339_expired(a) if {
+	time.parse_rfc3339_ns(a) < time.now_ns()
+}

+ 5 - 6
internal/opa/policies/web/web_version.rego

@@ -6,14 +6,13 @@ POLICY_ID := "web_version"
 
 POLICY_VERSION := "v0.0.1"
 
-POLICY_SEVERITY := "high"
+POLICY_SEVERITY := "low"
 
-# TODO: set the actual latest stable version
-latest_stable_version := "0.115.0"
+latest_stable_version := "0.50.0"
 
-POLICY_TITLE := sprintf("The web version should be at least v%s", [latest_stable_version])
+POLICY_TITLE := sprintf("The web version for application %s/%s should be at least v%s", [input.namespace, input.name, latest_stable_version])
 
-POLICY_SUCCESS_MESSAGE := sprintf("Success: web version is up-to-date", [])
+POLICY_SUCCESS_MESSAGE := sprintf("Success: web version for %s/%s is up-to-date", [input.namespace, input.name])
 
 trimmedVersion := trim_left(input.version, "v")
 
@@ -22,5 +21,5 @@ allow if semver.compare(latest_stable_version, trimmedVersion) == -1
 
 FAILURE_MESSAGE contains msg if {
 	not allow
-	msg := sprintf("Failed: latest stable version is %s, but you are on %s", [latest_stable_version, trimmedVersion])
+	msg := sprintf("Failed: latest stable version is %s, but %s/%s is on %s", [latest_stable_version, input.namespace, input.name, trimmedVersion])
 }