jusrhee 4 лет назад
Родитель
Сommit
3bee3fd5bb
71 измененных файлов с 2844 добавлено и 2216 удалено
  1. 10 5
      cli/cmd/api/api.go
  2. 59 52
      cli/cmd/auth.go
  3. 19 3
      cli/cmd/deploy/deploy.go
  4. 8 2
      cli/cmd/login/server.go
  5. 14 0
      cli/cmd/project.go
  6. 5 0
      dashboard/package-lock.json
  7. BIN
      dashboard/src/assets/back_arrow.png
  8. 2 5
      dashboard/src/components/ResourceTab.tsx
  9. 1 0
      dashboard/src/components/TabRegion.tsx
  10. 1 1
      dashboard/src/components/Table.tsx
  11. 96 0
      dashboard/src/components/TitleSection.tsx
  12. 4 3
      dashboard/src/components/YamlEditor.tsx
  13. 63 62
      dashboard/src/components/repo-selector/RepoList.tsx
  14. 110 100
      dashboard/src/components/values-form/FormWrapper.tsx
  15. 4 5
      dashboard/src/index.html
  16. 4 4
      dashboard/src/main/auth/VerifyEmail.tsx
  17. 2 4
      dashboard/src/main/home/Home.tsx
  18. 11 59
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  19. 9 38
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  20. 282 282
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  21. 3 35
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  22. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx
  23. 17 8
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx
  24. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  25. 198 158
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  26. 98 118
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  27. 57 101
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  28. 17 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  29. 20 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  30. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  31. 20 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  32. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  33. 325 235
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  34. 96 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts
  35. 376 468
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  36. 65 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts
  37. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  38. 19 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  39. 2 2
      dashboard/src/main/home/dashboard/ClusterList.tsx
  40. 9 35
      dashboard/src/main/home/dashboard/Dashboard.tsx
  41. 29 61
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  42. 19 40
      dashboard/src/main/home/integrations/Integrations.tsx
  43. 3 34
      dashboard/src/main/home/launch/Launch.tsx
  44. 2 2
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  45. 4 54
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  46. 4 28
      dashboard/src/main/home/new-project/NewProject.tsx
  47. 3 23
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  48. 16 1
      dashboard/src/shared/api.tsx
  49. 2 4
      dashboard/src/shared/hardcodedNameDict.tsx
  50. 57 0
      dashboard/src/shared/types.tsx
  51. 67 0
      docs/guides/update-instance-type-eks.md
  52. 32 1
      docs/reference/cli.md
  53. 2 2
      go.mod
  54. 1 0
      internal/analytics/track_events.go
  55. 44 3
      internal/analytics/tracks.go
  56. 6 0
      internal/forms/k8s.go
  57. 11 15
      internal/integrations/ci/actions/actions.go
  58. 13 57
      internal/integrations/ci/actions/steps.go
  59. 9 0
      internal/kubernetes/agent.go
  60. 229 4
      internal/kubernetes/prometheus/metrics.go
  61. 0 2
      internal/oauth/config.go
  62. 21 0
      server/api/cluster_handler.go
  63. 1 0
      server/api/deploy_handler.go
  64. 1 1
      server/api/dns_record_handler.go
  65. 1 0
      server/api/git_action_handler.go
  66. 0 31
      server/api/git_repo_handler.go
  67. 14 1
      server/api/integration_handler.go
  68. 140 46
      server/api/k8s_handler.go
  69. 63 0
      server/api/provision_handler.go
  70. 2 0
      server/api/release_handler.go
  71. 14 0
      server/router/router.go

+ 10 - 5
cli/cmd/api/api.go

@@ -144,11 +144,11 @@ type TokenProjectID struct {
 	ProjectID uint `json:"project_id"`
 }
 
-func GetProjectIDFromToken(token string) (uint, error) {
+func GetProjectIDFromToken(token string) (uint, bool, error) {
 	var encoded string
 
 	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
-		return 0, fmt.Errorf("invalid jwt token format")
+		return 0, false, fmt.Errorf("invalid jwt token format")
 	} else {
 		encoded = tokenSplit[1]
 	}
@@ -156,7 +156,7 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 
 	if err != nil {
-		return 0, fmt.Errorf("could not decode jwt token from base64: %v", err)
+		return 0, false, fmt.Errorf("could not decode jwt token from base64: %v", err)
 	}
 
 	res := &TokenProjectID{}
@@ -164,8 +164,13 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	err = json.Unmarshal(decodedBytes, res)
 
 	if err != nil {
-		return 0, fmt.Errorf("could not get token project id: %v", err)
+		return 0, false, fmt.Errorf("could not get token project id: %v", err)
 	}
 
-	return res.ProjectID, nil
+	// if the project ID is 0, this is a token signed for a user, not a specific project
+	if res.ProjectID == 0 {
+		return 0, false, nil
+	}
+
+	return res.ProjectID, true, nil
 }

+ 59 - 52
cli/cmd/auth.go

@@ -56,7 +56,6 @@ var logoutCmd = &cobra.Command{
 	},
 }
 
-var token string = ""
 var manual bool = false
 
 func init() {
@@ -80,18 +79,40 @@ func login() error {
 	user, _ := client.AuthCheck(context.Background())
 
 	if user != nil {
+		// set the token if the user calls login with the --token flag or the PORTER_TOKEN env
 		if config.Token != "" {
-			// set the token if the user calls login with the --token flag
 			config.SetToken(config.Token)
 			color.New(color.FgGreen).Println("Successfully logged in!")
 
-			projID, err := api.GetProjectIDFromToken(config.Token)
+			projID, exists, err := api.GetProjectIDFromToken(config.Token)
 
 			if err != nil {
 				return err
 			}
 
-			config.SetProject(projID)
+			// if project ID does not exist for the token, this is a user-issued CLI token, so the project
+			// ID should be queried
+			if !exists {
+				err = setProjectForUser(client, user.ID)
+
+				if err != nil {
+					return err
+				}
+			} else {
+				// if the project ID does exist for the token, this is a project-issued token, and
+				// the project should be set automatically
+				err = config.SetProject(projID)
+
+				if err != nil {
+					return err
+				}
+
+				err = setProjectCluster(client, projID)
+
+				if err != nil {
+					return err
+				}
+			}
 		} else {
 			color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
 		}
@@ -104,70 +125,50 @@ func login() error {
 		return loginManual()
 	}
 
-	// check for a token
-	var err error
-
-	if token == "" {
-		token, err = loginBrowser.Login(config.Host)
+	// log the user in
+	token, err := loginBrowser.Login(config.Host)
 
-		if err != nil {
-			return err
-		}
-
-		// set the token in config
-		err = config.SetToken(token)
-
-		if err != nil {
-			return err
-		}
-
-		client := api.NewClientWithToken(config.Host+"/api", token)
+	if err != nil {
+		return err
+	}
 
-		user, err := client.AuthCheck(context.Background())
+	// set the token in config
+	err = config.SetToken(token)
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	client = api.NewClientWithToken(config.Host+"/api", token)
 
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), user.ID)
+	user, err = client.AuthCheck(context.Background())
 
-		if err != nil {
-			return err
-		}
-
-		if len(projects) > 0 {
-			config.SetProject(projects[0].ID)
-		}
-	} else {
-		// set the token in config
-		err = config.SetToken(token)
+	if user == nil {
+		color.Red("Invalid token.")
+		return err
+	}
 
-		if err != nil {
-			return err
-		}
+	color.New(color.FgGreen).Println("Successfully logged in!")
 
-		client := api.NewClientWithToken(config.Host+"/api", token)
+	return setProjectForUser(client, user.ID)
+}
 
-		user, err := client.AuthCheck(context.Background())
+func setProjectForUser(client *api.Client, userID uint) error {
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), userID)
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	if len(projects) > 0 {
+		config.SetProject(projects[0].ID)
 
-		projID, err := api.GetProjectIDFromToken(token)
+		err = setProjectCluster(client, projects[0].ID)
 
 		if err != nil {
 			return err
 		}
-
-		config.SetProject(projID)
 	}
 
 	return nil
@@ -215,6 +216,12 @@ func loginManual() error {
 
 	if len(projects) > 0 {
 		config.SetProject(projects[0].ID)
+
+		err = setProjectCluster(client, projects[0].ID)
+
+		if err != nil {
+			return err
+		}
 	}
 
 	return nil

+ 19 - 3
cli/cmd/deploy/deploy.go

@@ -91,10 +91,10 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
 				deployAgent.opts.Method = DeployBuildTypeDocker
+			} else {
+				// otherwise build type is pack
+				deployAgent.opts.Method = DeployBuildTypePack
 			}
-
-			// otherwise build type is pack
-			deployAgent.opts.Method = DeployBuildTypePack
 		} else {
 			// if the git action config does not exist, we use docker by default
 			deployAgent.opts.Method = DeployBuildTypeDocker
@@ -279,6 +279,22 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
+	// if the current image section is hello-porter, the image must be overriden
+	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
+		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
+		newImage, err := d.getReleaseImage()
+
+		if err != nil {
+			return fmt.Errorf("could not overwrite hello-porter image: %s", err.Error())
+		}
+
+		currImageSection["repository"] = newImage
+
+		// set to latest just to be safe -- this will be overriden if "d.tag" is set in
+		// the agent
+		currImageSection["tag"] = "latest"
+	}
+
 	if d.tag != "" && currImageSection["tag"] != d.tag {
 		currImageSection["tag"] = d.tag
 	}

+ 8 - 2
cli/cmd/login/server.go

@@ -19,9 +19,15 @@ func redirect(
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 		fmt.Fprint(w, successScreen)
 
-		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+		queryParams, err := url.ParseQuery(r.URL.RawQuery)
 
-		codechan <- queryParams["code"][0]
+		if err != nil {
+			return
+		}
+
+		if codeParam, exists := queryParams["code"]; exists && len(codeParam) > 0 {
+			codechan <- queryParams["code"][0]
+		}
 	}
 }
 

+ 14 - 0
cli/cmd/project.go

@@ -140,3 +140,17 @@ func deleteProject(_ *api.AuthCheckResponse, client *api.Client, args []string)
 
 	return nil
 }
+
+func setProjectCluster(client *api.Client, projectID uint) error {
+	clusters, err := client.ListProjectClusters(context.Background(), projectID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(clusters) > 0 {
+		config.SetCluster(clusters[0].ID)
+	}
+
+	return nil
+}

+ 5 - 0
dashboard/package-lock.json

@@ -556,6 +556,11 @@
       "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
       "dev": true
     },
+    "@types/js-yaml": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.2.tgz",
+      "integrity": "sha512-KbeHS/Y4R+k+5sWXEYzAZKuB1yQlZtEghuhRxrVRLaqhtoG5+26JwQsa4HyS3AWX8v1Uwukma5HheduUDskasA=="
+    },
     "@types/json-schema": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",

BIN
dashboard/src/assets/back_arrow.png


+ 2 - 5
dashboard/src/components/ResourceTab.tsx

@@ -142,15 +142,12 @@ export default class ResourceTab extends Component<PropsType, StateType> {
 const StyledResourceTab = styled.div`
   width: 100%;
   margin-bottom: 2px;
+  overflow: hidden;
   background: #ffffff11;
   border-bottom-left-radius: ${(props: {
     isLast: boolean;
     roundAllCorners: boolean;
-  }) => (props.isLast ? "5px" : "")};
-  border-bottom-right-radius: ${(props: {
-    isLast: boolean;
-    roundAllCorners: boolean;
-  }) => (props.roundAllCorners && props.isLast ? "5px" : "")};
+  }) => (props.isLast ? "10px" : "")};
 `;
 
 const Tooltip = styled.div`

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

@@ -96,4 +96,5 @@ const StyledTabRegion = styled.div`
   height: 100%;
   position: relative;
   overflow-y: auto;
+  overflow: visible;
 `;

+ 1 - 1
dashboard/src/components/Table.tsx

@@ -55,7 +55,7 @@ const Table: React.FC<TableProps> = ({
       columns: columnsData,
       data,
     },
-    useGlobalFilter,
+    useGlobalFilter
   );
 
   const renderRows = () => {

+ 96 - 0
dashboard/src/components/TitleSection.tsx

@@ -0,0 +1,96 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  children: React.ReactNode;
+  icon?: any;
+  iconWidth?: string;
+  capitalize?: boolean;
+  handleNavBack?: () => void;
+}
+
+const TitleSection: React.FC<Props> = ({
+  children,
+  icon,
+  iconWidth,
+  capitalize,
+  handleNavBack,
+}) => {
+  return (
+    <StyledTitleSection>
+      {handleNavBack && (
+        <BackButton>
+          <i className="material-icons" onClick={handleNavBack}>
+            keyboard_backspace
+          </i>
+        </BackButton>
+      )}
+      {icon && <Icon width={iconWidth} src={icon} />}
+      <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
+    </StyledTitleSection>
+  );
+};
+
+export default TitleSection;
+
+const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const StyledTitleSection = styled.div`
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+`;
+
+const Icon = styled.img<{ width: string }>`
+  width: ${(props) => props.width || "28px"};
+  margin-right: 16px;
+`;
+
+const StyledTitle = styled.div<{ capitalize: boolean }>`
+  font-size: 24px;
+  font-weight: 600;
+  user-select: text;
+  text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
+  display: flex;
+  align-items: center;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size: 18px;
+    color: #858faaaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 15px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;

+ 4 - 3
dashboard/src/components/YamlEditor.tsx

@@ -52,7 +52,7 @@ class YamlEditor extends Component<PropsType, StateType> {
             editorProps={{ $blockScrolling: true }}
             height={this.props.height}
             width="100%"
-            style={{ borderRadius: "5px" }}
+            style={{ borderRadius: "10px" }}
             showPrintMargin={false}
             showGutter={true}
             highlightActiveLine={true}
@@ -67,9 +67,10 @@ class YamlEditor extends Component<PropsType, StateType> {
 export default YamlEditor;
 
 const Editor = styled.form`
-  border-radius: ${(props: { border: boolean }) => (props.border ? "5px" : "")};
+  border-radius: ${(props: { border: boolean }) =>
+    props.border ? "10px" : ""};
   border: ${(props: { border: boolean }) =>
-    props.border ? "1px solid #ffffff22" : ""};
+    props.border ? "1px solid #ffffff33" : ""};
 `;
 
 const Holder = styled.div`

+ 63 - 62
dashboard/src/components/repo-selector/RepoList.tsx

@@ -51,74 +51,75 @@ const RepoList: React.FC<Props> = ({
       .catch(() => {
         setAccessError(true);
         setAccessLoading(false);
-      });
-
-    // load git repo ids, and then repo names from that
-    // this only happens once during the lifecycle
-    new Promise((resolve, reject) => {
-      if (!userId && userId !== 0) {
-        api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
-          .then(async (res) => {
-            resolve(res.data);
-          })
-          .catch(() => {
-            resolve([]);
-          });
-      } else {
-        reject(null);
-      }
-    })
-      .then((ids: number[]) => {
-        Promise.all(
-          ids.map((id) => {
-            return new Promise((resolve, reject) => {
-              api
-                .getGitRepoList(
-                  "<token>",
-                  {},
-                  { project_id: currentProject.id, git_repo_id: id }
-                )
-                .then((res) => {
-                  resolve(res.data);
-                })
-                .catch((err) => {
-                  reject(err);
+      })
+      .finally(() => {
+        // load git repo ids, and then repo names from that
+        // this only happens once during the lifecycle
+        new Promise((resolve, reject) => {
+          if (!userId && userId !== 0) {
+            api
+              .getGitRepos("<token>", {}, { project_id: currentProject.id })
+              .then(async (res) => {
+                resolve(res.data);
+              })
+              .catch(() => {
+                resolve([]);
+              });
+          } else {
+            reject(null);
+          }
+        })
+          .then((ids: number[]) => {
+            Promise.all(
+              ids.map((id) => {
+                return new Promise((resolve, reject) => {
+                  api
+                    .getGitRepoList(
+                      "<token>",
+                      {},
+                      { project_id: currentProject.id, git_repo_id: id }
+                    )
+                    .then((res) => {
+                      resolve(res.data);
+                    })
+                    .catch((err) => {
+                      reject(err);
+                    });
                 });
-            });
-          })
-        )
-          .then((repos: RepoType[][]) => {
-            const names = new Set();
-            // note: would be better to use .flat() here but you need es2019 for
-            setRepos(
-              repos
-                .map((arr, idx) =>
-                  arr.map((el) => {
-                    el.GHRepoID = ids[idx];
-                    return el;
-                  })
-                )
-                .reduce((acc, val) => acc.concat(val), [])
-                .reduce((acc, val) => {
-                  if (!names.has(val.FullName)) {
-                    names.add(val.FullName);
-                    return acc.concat(val);
-                  } else {
-                    return acc;
-                  }
-                }, [])
-            );
-            setRepoLoading(false);
+              })
+            )
+              .then((repos: RepoType[][]) => {
+                const names = new Set();
+                // note: would be better to use .flat() here but you need es2019 for
+                setRepos(
+                  repos
+                    .map((arr, idx) =>
+                      arr.map((el) => {
+                        el.GHRepoID = ids[idx];
+                        return el;
+                      })
+                    )
+                    .reduce((acc, val) => acc.concat(val), [])
+                    .reduce((acc, val) => {
+                      if (!names.has(val.FullName)) {
+                        names.add(val.FullName);
+                        return acc.concat(val);
+                      } else {
+                        return acc;
+                      }
+                    }, [])
+                );
+                setRepoLoading(false);
+              })
+              .catch((_) => {
+                setRepoLoading(false);
+                setRepoError(true);
+              });
           })
           .catch((_) => {
             setRepoLoading(false);
             setRepoError(true);
           });
-      })
-      .catch((_) => {
-        setRepoLoading(false);
-        setRepoError(true);
       });
   }, []);
 

+ 110 - 100
dashboard/src/components/values-form/FormWrapper.tsx

@@ -76,102 +76,113 @@ export default class FormWrapper extends Component<PropsType, StateType> {
           value: this.context.currentCluster.service == "doks",
         },
       };
-      if (tabs) {
-        tabs.forEach((tab: any, i: number) => {
-          // Exclude value if omitFromLaunch is set
-          let omit =
-            tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
-          if (tab?.name && tab.label && !omit) {
-            // If a tab is valid, extract state
-            tab.sections?.forEach((section: Section, i: number) => {
-              section?.contents?.forEach((item: FormElement, i: number) => {
-                if (item === null || item === undefined) {
-                  return;
-                }
-
-                if (
-                  item.type === "variable" &&
-                  item.variable &&
-                  item.settings?.default
-                ) {
-                  metaState[item.variable] = { value: item.settings.default };
-                  return;
-                }
-
-                // If no name is assigned use values.yaml variable as identifier
-                let key = item.name || item.variable;
-
-                let def =
-                  item.settings &&
-                  item.settings.unit &&
-                  !item.settings.omitUnitFromValue
-                    ? `${item.settings.default}${item.settings.unit}`
-                    : item.settings?.default;
-                def = (item.value && item.value[0]) || def;
-
-                if (item.type === "checkbox") {
-                  def = item.value && item.value[0];
-                }
-
-                // Handle add to list of required fields
-                if (item.required && key) {
-                  requiredFields.push(key);
-                }
-
-                let value: any = def;
-                switch (item.type) {
-                  case "checkbox":
-                    value = def || false;
-                    break;
-                  case "string-input":
-                    value = def || "";
-                    break;
-                  case "string-input-password":
-                    value = def || item.settings.default;
-                  case "array-input":
-                    value = def || [];
-                    break;
-                  case "env-key-value-array":
-                    value = def || {};
-                    break;
-                  case "key-value-array":
-                    value = def || {};
-                    break;
-                  case "number-input":
-                    value = def?.toString() ? def : "";
-                    break;
-                  case "select":
-                    value = def || item.settings.options[0].value;
-                    break;
-                  case "provider-select":
-                    let providerMap: any = {
-                      gke: "gcp",
-                      eks: "aws",
-                      doks: "do",
-                    };
-                    def = providerMap[this.context.currentCluster.service];
-                    value = def || "aws";
-                    break;
-                  case "base-64":
-                    value = def || "";
-                  case "base-64-password":
-                    value = def || "";
-                  default:
-                }
-                if (value !== null && value !== undefined) {
-                  metaState[key] = { value };
-                }
-              });
+      tabs?.forEach((tab: any, i: number) => {
+        // Exclude value if omitFromLaunch is set
+        let omit =
+          tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
+        if (tab?.name && tab.label && !omit) {
+          // If a tab is valid, extract state
+          tab.sections?.forEach((section: Section, i: number) => {
+            section?.contents?.forEach((item: FormElement, i: number) => {
+              if (item === null || item === undefined) {
+                return;
+              }
+
+              if (
+                item.type === "variable" &&
+                item.variable &&
+                item.settings?.default
+              ) {
+                metaState[item.variable] = { value: item.settings.default };
+                return;
+              }
+
+              // If no name is assigned use values.yaml variable as identifier
+              let key = item.name || item.variable;
+
+              let def =
+                item.settings &&
+                item.settings.unit &&
+                !item.settings.omitUnitFromValue
+                  ? `${item.settings.default}${item.settings.unit}`
+                  : item.settings?.default;
+              def = (item.value && item.value[0]) || def;
+
+              if (item.type === "checkbox") {
+                def = item.value && item.value[0];
+              }
+
+              // Handle add to list of required fields
+              if (item.required && key) {
+                requiredFields.push(key);
+              }
+
+              let value: any = def;
+              switch (item.type) {
+                case "checkbox":
+                  value = def || false;
+                  break;
+                case "string-input":
+                  value = def || "";
+                  break;
+                case "string-input-password":
+                  value = def || item.settings.default;
+                case "array-input":
+                  value = def || [];
+                  break;
+                case "env-key-value-array":
+                  value = def || {};
+                  break;
+                case "key-value-array":
+                  value = def || {};
+                  break;
+                case "number-input":
+                  value = def?.toString() ? def : "";
+                  break;
+                case "select":
+                  value = def || item.settings.options[0].value;
+                  break;
+                case "provider-select":
+                  let providerMap: any = {
+                    gke: "gcp",
+                    eks: "aws",
+                    doks: "do",
+                  };
+                  def = providerMap[this.context.currentCluster.service];
+                  value = def || "aws";
+                  break;
+                case "base-64":
+                  value = def || "";
+                case "base-64-password":
+                  value = def || "";
+                default:
+              }
+              if (value !== null && value !== undefined) {
+                metaState[key] = { value };
+              }
             });
-            if (!this.props.tabOptionsOnly) {
-              tabOptions.push({ value: tab.name, label: tab.label });
-            }
+          });
+          if (!this.props.tabOptionsOnly) {
+            tabOptions.push({ value: tab.name, label: tab.label });
           }
-        });
-      }
+        }
+      });
+
       if (this.props.tabOptions?.length > 0) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
+        let prependTabs = [] as { value: string; label: string }[];
+        let appendTabs = [] as { value: string; label: string }[];
+        this.props.tabOptions.forEach(
+          (tab: { value: string; label: string }) => {
+            if (tab.value === "status" || tab.value === "metrics") {
+              prependTabs.push(tab);
+            } else {
+              appendTabs.push(tab);
+            }
+          }
+        );
+        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
       }
+
       if (tabOptions.length > 0) {
         this.setState(
           {
@@ -193,13 +204,12 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       // Handle change only to external tabs (e.g. DevOps mode toggle)
       let tabOptions = [] as { value: string; label: string }[];
       let tabs = this.props.formData?.tabs;
-      if (tabs) {
-        tabs.forEach((tab: any, i: number) => {
-          if (tab?.name && tab.label) {
-            tabOptions.push({ value: tab.name, label: tab.label });
-          }
-        });
-      }
+      tabs?.forEach((tab: any, i: number) => {
+        if (tab?.name && tab.label) {
+          tabOptions.push({ value: tab.name, label: tab.label });
+        }
+      });
+
       if (this.props.tabOptions?.length > 0) {
         let prependTabs = [] as { value: string; label: string }[];
         let appendTabs = [] as { value: string; label: string }[];
@@ -435,7 +445,7 @@ export default class FormWrapper extends Component<PropsType, StateType> {
     let showSave = this.showSaveButton();
     return (
       <>
-        {this.props.isInModal ? (
+        {this.props.isInModal || !showSave ? (
           <StyledValuesWrapper showSave={showSave}>
             {this.renderContents(showSave)}
           </StyledValuesWrapper>

+ 4 - 5
dashboard/src/index.html

@@ -67,7 +67,7 @@
       })();
     </script>
 
-    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png" />
+    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
     <meta
       name="description"
       content="Kubernetes powered PaaS that runs in your own cloud."
@@ -75,15 +75,14 @@
     <meta property="og:title" content="Porter" />
     <meta
       property="og:image"
-      content="https://i.ibb.co/DL4695L/logo-wide.png"
+      content="https://i.ibb.co/52g2g7C/porter-wide.png"
     />
     <meta
       property="og:description"
-      content="Fully-managed remote dev environments for any team."
+      content="Kubernetes powered PaaS that runs in your own cloud."
     />
-    <meta property="og:url" content="https://getporter.dev" />
+    <meta property="og:url" content="https://porter.run" />
 
-    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png" />
     <link
       href="https://fonts.googleapis.com/icon?family=Material+Icons"
       rel="stylesheet"

+ 4 - 4
dashboard/src/main/auth/VerifyEmail.tsx

@@ -39,10 +39,10 @@ export default class VerifyEmail extends Component<PropsType, StateType> {
           <StatusText>A verification email should have been sent to</StatusText>
           <Email>{this.context.user?.email}</Email>
         </InputWrapper>
-        <StatusText>
-          Didn't get it?
-        </StatusText>
-        <Button onClick={this.handleSendEmail}>Resend Verification Email</Button>
+        <StatusText>Didn't get it?</StatusText>
+        <Button onClick={this.handleSendEmail}>
+          Resend Verification Email
+        </Button>
       </div>
     );
 

+ 2 - 4
dashboard/src/main/home/Home.tsx

@@ -605,7 +605,7 @@ export default withRouter(withAuth(Home));
 const ViewWrapper = styled.div`
   height: 100%;
   width: 100vw;
-  padding-top: 30px;
+  padding-top: 10vh;
   overflow-y: auto;
   display: flex;
   flex: 1;
@@ -615,10 +615,8 @@ const ViewWrapper = styled.div`
 `;
 
 const DashboardWrapper = styled.div`
-  width: 80%;
-  padding-top: 50px;
+  width: calc(83% + 50px);
   min-width: 300px;
-  padding-bottom: 120px;
 `;
 
 const StyledHome = styled.div`

+ 11 - 59
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -13,6 +13,7 @@ import {
   pushQueryParams,
 } from "shared/routing";
 
+import DashboardHeader from "./DashboardHeader";
 import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
@@ -112,14 +113,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   }
 
-  renderDashboardIcon = () => {
-    if (this.props.currentView === "jobs") {
-      return <Img src={monojob} />;
-    } else {
-      return <Img src={monoweb} />;
-    }
-  };
-
   getDescription = (currentView: string): string => {
     if (currentView === "jobs") {
       return "Scripts and tasks that run once or on a repeating interval.";
@@ -183,22 +176,11 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
     return (
       <>
-        <TitleSection>
-          {this.renderDashboardIcon()}
-          <Title>{currentView}</Title>
-        </TitleSection>
-
-        <InfoSection>
-          <TopRow>
-            <InfoLabel>
-              <i className="material-icons">info</i> Info
-            </InfoLabel>
-          </TopRow>
-          <Description>{this.getDescription(currentView)}</Description>
-        </InfoSection>
-
-        <LineBreak />
-
+        <DashboardHeader
+          image={currentView === "jobs" ? monojob : monoweb}
+          title={currentView}
+          description={this.getDescription(currentView)}
+        />
         {this.renderBody()}
       </>
     );
@@ -250,6 +232,11 @@ ClusterDashboard.contextType = Context;
 
 export default withRouter(withAuth(ClusterDashboard));
 
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
+
 const ControlRow = styled.div`
   display: flex;
   justify-content: ${(props: { hasMultipleChilds: boolean }) => {
@@ -401,41 +388,6 @@ const Img = styled.img`
   width: 30px;
 `;
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 18px;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  text-transform: capitalize;
-`;
-
-const TitleSection = styled.div`
-  height: 80px;
-  margin-top: 10px;
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding-left: 0px;
-
-  > i {
-    margin-left: 10px;
-    cursor: pointer;
-    font-size: 18px;
-    color: #858faaaa;
-    padding: 5px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-    margin-bottom: -3px;
-  }
-`;
-
 const SortFilterWrapper = styled.div`
   width: 468px;
   display: flex;

+ 9 - 38
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -3,6 +3,8 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
+import TitleSection from "components/TitleSection";
+
 type PropsType = {
   image: any;
   title: string;
@@ -15,11 +17,12 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        <TitleSection>
-          <Img src={this.props.image} />
-          <Title>{this.props.title}</Title>
+        <TitleSection capitalize={true} icon={this.props.image}>
+          {this.props.title}
         </TitleSection>
 
+        <Br />
+
         <InfoSection>
           <TopRow>
             <InfoLabel>
@@ -37,8 +40,9 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
 
 DashboardHeader.contextType = Context;
 
-const Img = styled.img`
-  width: 30px;
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
 `;
 
 const LineBreak = styled.div`
@@ -82,16 +86,6 @@ const InfoSection = styled.div`
   margin-bottom: 35px;
 `;
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 18px;
-  color: #ffffff;
-  text-transform: capitalize;
-  white-space: nowrap;
-`;
-
 const ClusterLabel = styled.div`
   color: #ffffff22;
   font-size: 14px;
@@ -101,26 +95,3 @@ const ClusterLabel = styled.div`
   overflow: hidden;
   text-overflow: ellipsis;
 `;
-
-const TitleSection = styled.div`
-  height: 80px;
-  margin-top: 10px;
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding-left: 0px;
-
-  > i {
-    margin-left: 10px;
-    cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
-    padding: 5px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-    margin-bottom: -3px;
-  }
-`;

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

@@ -1,282 +1,282 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
-
-import Chart from "./Chart";
-import Loading from "components/Loading";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-type Props = {
-  currentCluster: ClusterType;
-  namespace: string;
-  // TODO Convert to enum
-  sortType: string;
-  currentView: PorterUrl;
-};
-
-const ChartList: React.FunctionComponent<Props> = ({
-  namespace,
-  sortType,
-  currentView,
-}) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-  const [charts, setCharts] = useState<ChartType[]>([]);
-  const [controllers, setControllers] = useState<
-    Record<string, Record<string, any>>
-  >({});
-  const [releases, setReleases] = useState<Record<string, any>>({});
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-
-  const context = useContext(Context);
-
-  const updateCharts = async () => {
-    try {
-      const { currentCluster, currentProject } = context;
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          namespace: namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "superseded",
-            "failed",
-          ],
-        },
-        { id: currentProject.id }
-      );
-      const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
-      setIsError(false);
-      return sortedCharts;
-    } catch (error) {
-      console.log(error);
-      context.setCurrentError(JSON.stringify(error));
-      setIsError(true);
-    }
-  };
-
-  const setupHelmReleasesWebsocket = () => {
-    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to chart live updates websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        const object = event.Object;
-        setReleases((oldReleases) => {
-          const currentRelease = oldReleases[object?.name];
-          const currentReleaseVersion = Number(currentRelease?.version);
-          const newReleaseVersion = Number(object?.version);
-          if (currentReleaseVersion > newReleaseVersion) {
-            return {
-              ...oldReleases,
-            };
-          }
-
-          return {
-            ...oldReleases,
-            [object.name]: object,
-          };
-        });
-      },
-
-      onclose: () => {
-        console.log("closing chart live updates websocket");
-      },
-
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket("helm_releases");
-      },
-    };
-
-    newWebsocket("helm_releases", apiPath, wsConfig);
-    openWebsocket("helm_releases");
-  };
-
-  const setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = context;
-    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [object.metadata.uid]: object,
-        }));
-      },
-      onclose: () => {
-        console.log("closing websocket");
-      },
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiPath, wsConfig);
-
-    openWebsocket(kind);
-  };
-
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
-  };
-
-  // Setup basic websockets on start
-  useEffect(() => {
-    setControllerWebsockets([
-      "deployment",
-      "statefulset",
-      "daemonset",
-      "replicaset",
-    ]);
-    setupHelmReleasesWebsocket();
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (namespace || namespace === "") {
-      updateCharts().then((charts) => {
-        if (isSubscribed) {
-          setCharts(charts);
-          setIsLoading(false);
-        }
-      });
-    }
-    return () => (isSubscribed = false);
-  }, [namespace, currentView]);
-
-  const renderChartList = () => {
-    if (isLoading || (!namespace && namespace !== "")) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (isError) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error connecting to cluster.
-        </Placeholder>
-      );
-    } else if (charts.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
-        </Placeholder>
-      );
-    }
-
-    return charts.map((chart: ChartType, i: number) => {
-      return (
-        <Chart
-          key={`${chart.namespace}-${chart.name}`}
-          chart={chart}
-          controllers={controllers || {}}
-          release={releases[chart.name] || {}}
-        />
-      );
-    });
-  };
-
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
-};
-
-export default ChartList;
-
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 320px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding-top: 100px;
-`;
-
-const StyledChartList = styled.div`
-  padding-bottom: 85px;
-`;
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import Chart from "./Chart";
+import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  currentCluster: ClusterType;
+  namespace: string;
+  // TODO Convert to enum
+  sortType: string;
+  currentView: PorterUrl;
+};
+
+const ChartList: React.FunctionComponent<Props> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          namespace: namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "superseded",
+            "failed",
+          ],
+        },
+        { id: currentProject.id }
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    }
+  };
+
+  const setupHelmReleasesWebsocket = () => {
+    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to chart live updates websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        const object = event.Object;
+        setReleases((oldReleases) => {
+          const currentRelease = oldReleases[object?.name];
+          const currentReleaseVersion = Number(currentRelease?.version);
+          const newReleaseVersion = Number(object?.version);
+          if (currentReleaseVersion > newReleaseVersion) {
+            return {
+              ...oldReleases,
+            };
+          }
+
+          return {
+            ...oldReleases,
+            [object.name]: object,
+          };
+        });
+      },
+
+      onclose: () => {
+        console.log("closing chart live updates websocket");
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket("helm_releases");
+      },
+    };
+
+    newWebsocket("helm_releases", apiPath, wsConfig);
+    openWebsocket("helm_releases");
+  };
+
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiPath, wsConfig);
+
+    openWebsocket(kind);
+  };
+
+  const setControllerWebsockets = (controllers: any[]) => {
+    controllers.map((kind: string) => {
+      return setupWebsocket(kind);
+    });
+  };
+
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
+      "deployment",
+      "statefulset",
+      "daemonset",
+      "replicaset",
+    ]);
+    setupHelmReleasesWebsocket();
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          setIsLoading(false);
+        }
+      });
+    }
+    return () => (isSubscribed = false);
+  }, [namespace, currentView]);
+
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (isError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
+        </Placeholder>
+      );
+    }
+
+    return charts.map((chart: ChartType, i: number) => {
+      return (
+        <Chart
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
+          controllers={controllers || {}}
+          release={releases[chart.name] || {}}
+        />
+      );
+    });
+  };
+
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
+
+export default ChartList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 85px;
+`;

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

@@ -3,6 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import TabSelector from "components/TabSelector";
+import TitleSection from "components/TitleSection";
 
 import NodeList from "./NodeList";
 
@@ -56,7 +57,7 @@ export const Dashboard: React.FunctionComponent = () => {
         <DashboardIcon>
           <i className="material-icons">device_hub</i>
         </DashboardIcon>
-        <Title>{context.currentCluster.name}</Title>
+        {context.currentCluster.name}
       </TitleSection>
 
       <InfoSection>
@@ -86,6 +87,7 @@ const DashboardIcon = styled.div`
   min-width: 45px;
   width: 45px;
   border-radius: 5px;
+  margin-right: 17px;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -128,37 +130,3 @@ const InfoSection = styled.div`
   margin-left: 0px;
   margin-bottom: 35px;
 `;
-
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 18px;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  height: 80px;
-  margin-top: 10px;
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding-left: 0px;
-
-  > i {
-    margin-left: 10px;
-    cursor: pointer;
-    font-size: 18px;
-    color: #858faaaa;
-    padding: 5px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-    margin-bottom: -3px;
-  }
-`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx

@@ -185,7 +185,7 @@ const IconWrapper = styled.div`
 `;
 
 const Title = styled.div`
-  font-size: 18px;
+  font-size: 20px;
   font-weight: 500;
   display: flex;
   align-items: center;

+ 17 - 8
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx

@@ -41,22 +41,31 @@ const NodeUsage: React.FunctionComponent<NodeUsageProps> = ({ node }) => {
             <Bolded>CPU:</Bolded>{" "}
             {!node?.cpu_reqs && !node?.allocatable_cpu
               ? "Loading..."
-              : `${percentFormatter(node?.fraction_cpu_reqs)} (${node?.cpu_reqs}/${
-                  node?.allocatable_cpu
-                }m)`}
+              : `${percentFormatter(node?.fraction_cpu_reqs)} (${
+                  node?.cpu_reqs
+                }/${node?.allocatable_cpu}m)`}
           </span>
           <Buffer />
           <span>
             <Bolded>RAM:</Bolded>{" "}
             {!node?.memory_reqs && !node?.allocatable_memory
               ? "Loading..."
-              : `${percentFormatter(node?.fraction_memory_reqs)} (${formatMemoryUnitToMi(
+              : `${percentFormatter(
+                  node?.fraction_memory_reqs
+                )} (${formatMemoryUnitToMi(
                   node?.memory_reqs
-                )}/${formatMemoryUnitToMi(
-                  node?.allocatable_memory
-                )})`}
+                )}/${formatMemoryUnitToMi(node?.allocatable_memory)})`}
           </span>
-          <I onClick={() => window.open("https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable")} className="material-icons">help_outline</I>
+          <I
+            onClick={() =>
+              window.open(
+                "https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable"
+              )
+            }
+            className="material-icons"
+          >
+            help_outline
+          </I>
         </UsageWrapper>
       </Wrapper>
     </NodeUsageWrapper>

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

@@ -325,8 +325,8 @@ const Subtitle = styled.div`
 `;
 
 const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
+  font-size: 20px;
+  font-weight: 500;
   font-family: "Work Sans", sans-serif;
   margin-left: 15px;
   border-radius: 2px;

+ 198 - 158
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -1,13 +1,16 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import close from "assets/close.png";
+import backArrow from "assets/back_arrow.png";
 import key from "assets/key.svg";
 import _ from "lodash";
 
 import { ChartType, StorageType, ClusterType } from "shared/types";
 import { Context } from "shared/Context";
+import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 
+import TitleSection from "components/TitleSection";
 import SaveButton from "components/SaveButton";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
@@ -15,6 +18,7 @@ import TabRegion from "components/TabRegion";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import InputRow from "components/values-form/InputRow";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = WithAuthProps & {
@@ -30,8 +34,15 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
-  envVariables: KeyValueType[];
+  envGroup: EnvGroup;
   tabOptions: { value: string; label: string }[];
+  newEnvGroupName: string;
+};
+
+type EnvGroup = {
+  name: string;
+  timestamp: string;
+  variables: KeyValueType[];
 };
 
 const tabOptions = [
@@ -46,29 +57,48 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
-    envVariables: [] as KeyValueType[],
+    envGroup: {
+      name: null as string,
+      timestamp: null as string,
+      variables: [] as KeyValueType[],
+    },
     tabOptions: [
       { value: "environment", label: "Environment Variables" },
       { value: "settings", label: "Settings" },
     ],
+    newEnvGroupName: null as string,
   };
 
-  componentDidMount() {
+  populateEnvGroup = (envGroup: any) => {
+    const {
+      metadata: { name, creationTimestamp: timestamp },
+      data,
+    } = envGroup;
     // parse env group props into values type
-    let envVariables = [] as KeyValueType[];
-    let envGroupData = this.props.envGroup.data;
+    const variables = [] as KeyValueType[];
 
-    for (const key in envGroupData) {
-      envVariables.push({
+    for (const key in data) {
+      variables.push({
         key: key,
-        value: envGroupData[key],
-        hidden: envGroupData[key].includes("PORTERSECRET"),
-        locked: envGroupData[key].includes("PORTERSECRET"),
+        value: data[key],
+        hidden: data[key].includes("PORTERSECRET"),
+        locked: data[key].includes("PORTERSECRET"),
         deleted: false,
       });
     }
 
-    this.setState({ envVariables });
+    this.setState({
+      envGroup: {
+        name,
+        timestamp,
+        variables,
+      },
+      newEnvGroupName: name,
+    });
+  };
+
+  componentDidMount() {
+    this.populateEnvGroup(this.props.envGroup);
 
     // Filter the settings tab options as for now it only shows the delete button.
     // In a future this should be removed and return to a constant if we want to show data
@@ -86,25 +116,49 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
     });
   }
 
-  handleUpdateValues = () => {
-    let { envGroup } = this.props;
-    let name = envGroup.metadata.name;
-    let namespace = envGroup.metadata.namespace;
+  handleRename = () => {
+    const { namespace } = this.props;
+    const {
+      envGroup: { name },
+      newEnvGroupName: newName,
+    } = this.state;
 
-    let apiEnvVariables: Record<string, string> = {};
-    let secretEnvVariables: Record<string, string> = {};
+    api
+      .renameConfigMap(
+        "<token>",
+        {
+          name,
+          namespace,
+          new_name: newName,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.populateEnvGroup(res.data);
+      });
+  };
 
-    let envVariables = this.state.envVariables;
+  handleUpdateValues = () => {
+    const { namespace } = this.props;
+    const {
+      envGroup: { name, variables: envVariables },
+    } = this.state;
+
+    const apiEnvVariables: Record<string, string> = {};
+    const secretEnvVariables: Record<string, string> = {};
 
     envVariables
       .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
         // remove any collisions that are marked as deleted and are duplicates, unless they are
         // all delete collisions
-        let numDeleteCollisions = self.reduce((n, _envVar: KeyValueType) => {
+        const numDeleteCollisions = self.reduce((n, _envVar: KeyValueType) => {
           return n + (_envVar.key === envVar.key && envVar.deleted ? 1 : 0);
         }, 0);
 
-        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+        const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
           return n + (_envVar.key === envVar.key ? 1 : 0);
         }, 0);
 
@@ -171,9 +225,15 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
   };
 
   renderTabContents = () => {
-    let currentTab = this.state.currentTab;
-    let { envGroup, namespace } = this.props;
-    let name = envGroup.metadata.name;
+    const { namespace } = this.props;
+    const {
+      envGroup: { name, variables },
+      newEnvGroupName: newName,
+      currentTab,
+    } = this.state;
+
+    const isEnvGroupNameValid = isAlphanumeric(newName) && newName !== "";
+    const isEnvGroupNameDifferent = newName !== name;
 
     switch (currentTab) {
       case "environment":
@@ -187,8 +247,12 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
               </Helper>
               <EnvGroupArray
                 namespace={namespace}
-                values={this.state.envVariables}
-                setValues={(x: any) => this.setState({ envVariables: x })}
+                values={variables}
+                setValues={(x: any) =>
+                  this.setState((prevState) => ({
+                    envGroup: { ...prevState.envGroup, variables: x },
+                  }))
+                }
                 fileUpload={true}
                 secretOption={true}
                 disabled={
@@ -216,6 +280,29 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
           <TabWrapper>
             {this.props.isAuthorized("env_group", "", ["get", "delete"]) && (
               <InnerWrapper full={true}>
+                <Heading>Name</Heading>
+                <Subtitle>
+                  <Warning makeFlush={true} highlight={!isEnvGroupNameValid}>
+                    Lowercase letters, numbers, and "-" only.
+                  </Warning>
+                </Subtitle>
+                <DarkMatter antiHeight="-29px" />
+                <InputRow
+                  type="text"
+                  value={newName}
+                  setValue={(x: string) =>
+                    this.setState({ newEnvGroupName: x })
+                  }
+                  placeholder="ex: doctor-scientist"
+                  width="100%"
+                />
+                <Button
+                  color="#616FEEcc"
+                  disabled={!(isEnvGroupNameDifferent && isEnvGroupNameValid)}
+                  onClick={this.handleRename}
+                >
+                  Rename {name}
+                </Button>
                 <Heading>Manage Environment Group</Heading>
                 <Helper>
                   Permanently delete this set of environment variables. This
@@ -235,9 +322,9 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
   };
 
   readableDate = (s: string) => {
-    let ts = new Date(s);
-    let date = ts.toLocaleDateString();
-    let time = ts.toLocaleTimeString([], {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
       hour: "numeric",
       minute: "2-digit",
     });
@@ -245,9 +332,10 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
   };
 
   handleDeleteEnvGroup = () => {
-    let { envGroup } = this.props;
-    let name = envGroup.metadata.name;
-    let namespace = envGroup.metadata.namespace;
+    const { namespace } = this.props;
+    const {
+      envGroup: { name },
+    } = this.state;
 
     this.setState({ deleting: true });
     api
@@ -280,16 +368,18 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { closeExpanded } = this.props;
-    let { envGroup } = this.props;
-    let name = envGroup.metadata.name;
-    let timestamp = envGroup.metadata.creationTimestamp;
-    let namespace = envGroup.metadata.namespace;
+    const { namespace, closeExpanded } = this.props;
+    const {
+      envGroup: { name, timestamp },
+    } = this.state;
 
     return (
       <>
-        <CloseOverlay onClick={closeExpanded} />
         <StyledExpandedChart>
+          <BackButton onClick={closeExpanded}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
             message={`Are you sure you want to delete ${name}?`}
@@ -298,29 +388,18 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
           />
           {this.renderDeleteOverlay()}
 
-          <HeaderWrapper>
-            <TitleSection>
-              <Title>
-                <IconWrapper>
-                  <Icon src={key} />
-                </IconWrapper>
-                {name}
-              </Title>
-              <InfoWrapper>
-                <LastDeployed>
-                  Last updated {this.readableDate(timestamp)}
-                </LastDeployed>
-              </InfoWrapper>
-
-              <TagWrapper>
-                Namespace <NamespaceTag>{namespace}</NamespaceTag>
-              </TagWrapper>
-            </TitleSection>
-
-            <CloseButton onClick={closeExpanded}>
-              <CloseButtonImg src={close} />
-            </CloseButton>
-          </HeaderWrapper>
+          <TitleSection icon={key} iconWidth="33px">
+            {name}
+            <TagWrapper>
+              Namespace <NamespaceTag>{namespace}</NamespaceTag>
+            </TagWrapper>
+          </TitleSection>
+
+          <InfoWrapper>
+            <LastDeployed>
+              Last updated {this.readableDate(timestamp)}
+            </LastDeployed>
+          </InfoWrapper>
 
           <TabRegion
             currentTab={this.state.currentTab}
@@ -340,6 +419,33 @@ ExpandedEnvGroup.contextType = Context;
 
 export default withAuth(ExpandedEnvGroup);
 
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const Button = styled.button`
   height: 35px;
   font-size: 13px;
@@ -379,6 +485,7 @@ const InnerWrapper = styled.div<{ full?: boolean }>`
 const TabWrapper = styled.div`
   height: 100%;
   width: 100%;
+  padding-bottom: 65px;
   overflow: hidden;
 `;
 
@@ -414,37 +521,10 @@ const DeleteOverlay = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: #202227;
-  animation: fadeIn 0.2s 0s;
-  opacity: 0;
-  animation-fill-mode: forwards;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const HeaderWrapper = styled.div``;
-
-const Dot = styled.div`
-  margin-right: 9px;
-  margin-left: 9px;
-`;
-
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  margin: 24px 0px 17px 0px;
+  margin: 10px 0px 17px 0px;
   height: 20px;
 `;
 
@@ -458,13 +538,13 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  right: 0px;
-  bottom: 0px;
   height: 20px;
   font-size: 12px;
   display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
   align-items: center;
+  font-weight: 400;
   justify-content: center;
   color: #ffffff44;
   border: 1px solid #ffffff44;
@@ -489,85 +569,45 @@ const NamespaceTag = styled.div`
   border-bottom-left-radius: 0px;
 `;
 
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const IconWrapper = styled.div`
-  color: #efefef;
-  font-size: 16px;
-  height: 20px;
-  width: 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 3px;
-  margin-right: 12px;
-
-  > i {
-    font-size: 20px;
-  }
-`;
-
-const Title = styled.div`
-  font-size: 18px;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-`;
-
-const TitleSection = styled.div`
-  width: 100%;
-  position: relative;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
 const StyledExpandedChart = styled.div`
-  width: calc(100% - 50px);
-  height: calc(100% - 50px);
+  width: 100%;
   z-index: 0;
-  position: absolute;
-  top: 25px;
-  left: 25px;
-  overflow: hidden;
-  border-radius: 10px;
-  background: #26272f;
-  box-shadow: 0 5px 12px 4px #00000033;
-  animation: floatIn 0.3s;
+  position: relative;
+  animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  padding: 25px;
   display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
   flex-direction: column;
+  overflow: visible;
 
-  @keyframes floatIn {
+  @keyframes fadeIn {
     from {
       opacity: 0;
-      transform: translateY(30px);
     }
     to {
       opacity: 1;
-      transform: translateY(0px);
     }
   }
 `;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;

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

@@ -8,7 +8,7 @@ import React, {
 } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
-import close from "assets/close.png";
+import backArrow from "assets/back_arrow.png";
 import _ from "lodash";
 import loadingSrc from "assets/loading.gif";
 
@@ -34,6 +34,7 @@ import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
+import TitleSection from "components/TitleSection";
 
 type Props = {
   namespace: string;
@@ -59,6 +60,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     props.currentChart
   );
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
+  const [loading, setLoading] = useState<boolean>(false);
   const [components, setComponents] = useState<ResourceType[]>([]);
   const [isPreview, setIsPreview] = useState<boolean>(false);
   const [devOpsMode, setDevOpsMode] = useState<boolean>(
@@ -171,21 +173,27 @@ const ExpandedChart: React.FC<Props> = (props) => {
     const wsConfig = {
       onmessage(evt: MessageEvent) {
         const event = JSON.parse(evt.data);
-
-        if (event.event_type == "UPDATE") {
-          let object = event.Object;
-          object.metadata.kind = event.Kind;
-
-          setControllers((oldControllers) => {
-            if (oldControllers[object.metadata.uid]) {
-              return oldControllers;
-            }
-            return {
-              ...oldControllers,
-              [object.metadata.uid]: object,
-            };
-          });
-        }
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => {
+          switch (event.event_type) {
+            case "DELETE":
+              delete oldControllers[object.metadata.uid];
+            case "UPDATE":
+              if (
+                oldControllers &&
+                oldControllers[object.metadata.uid]?.status?.conditions ==
+                  object.status?.conditions
+              ) {
+                return oldControllers;
+              }
+              return {
+                ...oldControllers,
+                [object.metadata.uid]: object,
+              };
+          }
+        });
       },
       onerror() {
         closeWebsocket(kind);
@@ -196,6 +204,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const updateComponents = async (currentChart: ChartType) => {
+    setLoading(true);
     try {
       const res = await api.getChartComponents(
         "<token>",
@@ -211,8 +220,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
         }
       );
       setComponents(res.data.Objects);
+      setLoading(false);
     } catch (error) {
       console.log(error);
+      setLoading(false);
     }
   };
 
@@ -529,7 +540,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       return c.Kind === "Service";
     });
 
-    if (!service?.Name || !service?.Namespace) {
+    if (loading) {
       return (
         <Url>
           <Bolded>Loading...</Bolded>
@@ -537,6 +548,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
+    if (!service?.Name || !service?.Namespace) {
+      return;
+    }
+
     return (
       <Url>
         <Bolded>Internal URI:</Bolded>
@@ -638,8 +653,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   return (
     <>
-      <CloseOverlay onClick={props.closeChart} />
       <StyledExpandedChart>
+        <BackButton onClick={props.closeChart}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+
         <ConfirmOverlay
           show={showDeleteOverlay}
           message={`Are you sure you want to delete ${currentChart.name}?`}
@@ -652,34 +670,30 @@ const ExpandedChart: React.FC<Props> = (props) => {
           </DeleteOverlay>
         )}
         <HeaderWrapper>
-          <TitleSection>
-            <Title>
-              <IconWrapper>{renderIcon()}</IconWrapper>
-              {currentChart.name}
-            </Title>
-            {currentChart.chart.metadata.name != "worker" &&
-              currentChart.chart.metadata.name != "job" &&
-              renderUrl()}
-            <InfoWrapper>
-              <StatusIndicator
-                controllers={controllers}
-                status={currentChart.info.status}
-                margin_left={"0px"}
-              />
-              <LastDeployed>
-                <Dot>•</Dot>Last deployed
-                {" " + getReadableDate(currentChart.info.last_deployed)}
-              </LastDeployed>
-            </InfoWrapper>
-
+          <TitleSection
+            icon={currentChart.chart.metadata.icon}
+            iconWidth="33px"
+          >
+            {currentChart.name}
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
           </TitleSection>
 
-          <CloseButton onClick={props.closeChart}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
+          {currentChart.chart.metadata.name != "worker" &&
+            currentChart.chart.metadata.name != "job" &&
+            renderUrl()}
+          <InfoWrapper>
+            <StatusIndicator
+              controllers={controllers}
+              status={currentChart.info.status}
+              margin_left={"0px"}
+            />
+            <LastDeployed>
+              <Dot>•</Dot>Last deployed
+              {" " + getReadableDate(currentChart.info.last_deployed)}
+            </LastDeployed>
+          </InfoWrapper>
 
           <RevisionSection
             showRevisions={showRevisions}
@@ -739,6 +753,39 @@ export default ExpandedChart;
 
 const TextWrap = styled.div``;
 
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const Header = styled.div`
   font-weight: 500;
   color: #aaaabb;
@@ -765,12 +812,6 @@ const Spinner = styled.img`
   margin-bottom: -2px;
 `;
 
-const BodyWrapper = styled.div`
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-`;
-
 const DeleteOverlay = styled.div`
   position: absolute;
   top: 0px;
@@ -855,26 +896,6 @@ const TabButton = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: #202227;
-  animation: fadeIn 0.2s 0s;
-  opacity: 0;
-  animation-fill-mode: forwards;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
 const HeaderWrapper = styled.div``;
 
 const Dot = styled.div`
@@ -898,13 +919,13 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  bottom: 0px;
-  right: 0px;
   height: 20px;
   font-size: 12px;
   display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
   align-items: center;
+  font-weight: 400;
   justify-content: center;
   color: #ffffff44;
   border: 1px solid #ffffff44;
@@ -949,66 +970,25 @@ const IconWrapper = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 18px;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-  user-select: text;
-`;
-
-const TitleSection = styled.div`
-  width: 100%;
-  position: relative;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
 const StyledExpandedChart = styled.div`
-  width: calc(100% - 50px);
-  height: calc(100% - 50px);
+  width: 100%;
   z-index: 0;
-  position: absolute;
-  top: 25px;
-  left: 25px;
-  border-radius: 10px;
-  background: #26272f;
-  box-shadow: 0 5px 12px 4px #00000033;
-  animation: floatIn 0.3s;
+  position: relative;
+  animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  padding: 25px;
   display: flex;
-  overflow: hidden;
+  overflow-y: auto;
+  padding-bottom: 120px;
   flex-direction: column;
+  overflow: visible;
 
-  @keyframes floatIn {
+  @keyframes fadeIn {
     from {
       opacity: 0;
-      transform: translateY(30px);
     }
     to {
       opacity: 1;
-      transform: translateY(0px);
     }
   }
 `;

+ 57 - 101
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -1,7 +1,8 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
-import close from "assets/close.png";
+
+import backArrow from "assets/back_arrow.png";
 import _ from "lodash";
 import loading from "assets/loading.gif";
 
@@ -12,6 +13,7 @@ import api from "shared/api";
 import SaveButton from "components/SaveButton";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
+import TitleSection from "components/TitleSection";
 import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/form-refactor/PorterFormWrapper";
@@ -498,19 +500,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     });
   }
 
-  renderIcon = () => {
-    let { currentChart } = this.state;
-
-    if (
-      currentChart.chart.metadata.icon &&
-      currentChart.chart.metadata.icon !== ""
-    ) {
-      return <Icon src={currentChart.chart.metadata.icon} />;
-    } else {
-      return <i className="material-icons">tonality</i>;
-    }
-  };
-
   readableDate = (s: string) => {
     let ts = new Date(s);
     let date = ts.toLocaleDateString();
@@ -574,8 +563,11 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
     return (
       <>
-        <CloseOverlay onClick={closeChart} />
         <StyledExpandedChart>
+          <BackButton onClick={closeChart}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
             message={`Are you sure you want to delete ${currentChart.name}?`}
@@ -585,27 +577,23 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
           {this.renderDeleteOverlay()}
 
           <HeaderWrapper>
-            <TitleSection>
-              <Title>
-                <IconWrapper>{this.renderIcon()}</IconWrapper>
-                {chart.name}
-              </Title>
-              <InfoWrapper>
-                <LastDeployed>
-                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template
-                  update at
-                  {" " + this.readableDate(chart.info.last_deployed)}
-                </LastDeployed>
-              </InfoWrapper>
-
+            <TitleSection
+              icon={currentChart.chart.metadata.icon}
+              iconWidth="33px"
+            >
+              {chart.name}
               <TagWrapper>
                 Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
               </TagWrapper>
             </TitleSection>
 
-            <CloseButton onClick={closeChart}>
-              <CloseButtonImg src={close} />
-            </CloseButton>
+            <InfoWrapper>
+              <LastDeployed>
+                Run {this.state.jobs.length} times <Dot>•</Dot>Last template
+                update at
+                {" " + this.readableDate(chart.info.last_deployed)}
+              </LastDeployed>
+            </InfoWrapper>
           </HeaderWrapper>
 
           <BodyWrapper>
@@ -637,6 +625,33 @@ ExpandedJobChart.contextType = Context;
 
 export default withAuth(ExpandedJobChart);
 
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const TextWrap = styled.div``;
 
 const Header = styled.div`
@@ -674,6 +689,7 @@ const BodyWrapper = styled.div`
 const TabWrapper = styled.div`
   height: 100%;
   width: 100%;
+  padding-bottom: 47px;
   overflow: hidden;
 `;
 
@@ -709,26 +725,6 @@ const DeleteOverlay = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: #202227;
-  animation: fadeIn 0.2s 0s;
-  opacity: 0;
-  animation-fill-mode: forwards;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
 const HeaderWrapper = styled.div``;
 
 const Dot = styled.div`
@@ -753,13 +749,13 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  right: 0px;
-  bottom: 0px;
   height: 20px;
   font-size: 12px;
   display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
   align-items: center;
+  font-weight: 400;
   justify-content: center;
   color: #ffffff44;
   border: 1px solid #ffffff44;
@@ -804,65 +800,25 @@ const IconWrapper = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 18px;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-`;
-
-const TitleSection = styled.div`
-  width: 100%;
-  position: relative;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
 const StyledExpandedChart = styled.div`
-  width: calc(100% - 50px);
-  height: calc(100% - 50px);
+  width: 100%;
   z-index: 0;
-  position: absolute;
-  top: 25px;
-  left: 25px;
-  border-radius: 10px;
-  background: #26272f;
-  box-shadow: 0 5px 12px 4px #00000033;
-  animation: floatIn 0.3s;
+  position: relative;
+  animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  padding: 25px;
   display: flex;
-  overflow: hidden;
+  overflow-y: auto;
+  padding-bottom: 120px;
   flex-direction: column;
+  overflow: visible;
 
-  @keyframes floatIn {
+  @keyframes fadeIn {
     from {
       opacity: 0;
-      transform: translateY(30px);
     }
     to {
       opacity: 1;
-      transform: translateY(0px);
     }
   }
 `;

+ 17 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx

@@ -48,9 +48,23 @@ GraphSection.contextType = Context;
 
 const StyledGraphSection = styled.div`
   width: 100%;
-  height: 100%;
-  background: #ffffff11;
+  min-height: 450px;
+  height: 50vh;
   font-size: 13px;
-  border-radius: 5px;
   overflow: hidden;
+  border-radius: 10px;
+  border: 1px solid #ffffff33;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;

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

@@ -120,13 +120,13 @@ ListSection.contextType = Context;
 const YamlWrapper = styled.div`
   width: 100%;
   height: 100%;
+  overflow: visible;
 `;
 
 const TabWrapper = styled.div`
   min-width: 200px;
   width: 35%;
   margin-right: 10px;
-  border-radius: 5px;
   overflow: hidden;
   overflow-y: auto;
 `;
@@ -135,14 +135,29 @@ const FlexWrapper = styled.div`
   display: flex;
   flex: 1;
   height: 100%;
+  overflow: visible;
 `;
 
 const StyledListSection = styled.div`
-  width: 100%;
-  height: 100%;
   display: flex;
-  position: relative;
   font-size: 13px;
-  border-radius: 5px;
+  width: 100%;
+  min-height: 450px;
+  height: 50vh;
+  font-size: 13px;
   overflow: hidden;
+  border-radius: 10px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -371,6 +371,7 @@ const A = styled.a`
 
 const Wrapper = styled.div`
   width: 100%;
+  padding-bottom: 65px;
   height: 100%;
 `;
 

+ 20 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -111,13 +111,30 @@ ValuesYaml.contextType = Context;
 const Wrapper = styled.div`
   overflow: auto;
   height: calc(100% - 60px);
-  border-radius: 5px;
-  border: 1px solid #ffffff22;
+  border-radius: 10px;
+  border: 1px solid #ffffff33;
 `;
 
 const StyledValuesYaml = styled.div`
   display: flex;
   flex-direction: column;
   width: 100%;
-  height: 100%;
+  min-height: 450px;
+  height: 50vh;
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 10px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;

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

@@ -634,11 +634,13 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
             </Checkbox>
             Show Type
           </ToggleLabel>
+          {/*
           <ExpandButton onClick={this.toggleExpanded}>
             <i className="material-icons">
               {this.state.isExpanded ? "close_fullscreen" : "open_in_full"}
             </i>
           </ExpandButton>
+          */}
         </ButtonSection>
         <InfoPanel
           setSuppressDisplay={(x: boolean) =>

+ 325 - 235
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,48 +1,30 @@
-import React, { useMemo, useCallback } from "react";
-import { AreaClosed, Line, Bar } from "@visx/shape";
+import React, { useMemo, useCallback, useRef } from "react";
+import { AreaClosed, Line, Bar, LinePath } from "@visx/shape";
 import { curveMonotoneX } from "@visx/curve";
 import { scaleTime, scaleLinear } from "@visx/scale";
 import { AxisLeft, AxisBottom } from "@visx/axis";
 
 import {
-  withTooltip,
   Tooltip,
   TooltipWithBounds,
   defaultStyles,
+  useTooltip,
 } from "@visx/tooltip";
 
 import { GridRows, GridColumns } from "@visx/grid";
 
-import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
 import { localPoint } from "@visx/event";
 import { LinearGradient } from "@visx/gradient";
 import { max, extent, bisector } from "d3-array";
 import { timeFormat } from "d3-time-format";
+import { NormalizedMetricsData } from "./types";
 
-/*
-export const accentColor = '#f5cb42';
-export const accentColorDark = '#949eff';
-*/
-
-export type MetricsData = {
-  date: number; // unix timestamp
-  value: number; // value
-};
-
-type TooltipData = MetricsData;
-
-var globalData: MetricsData[];
+var globalData: NormalizedMetricsData[];
 
 export const background = "#3b697800";
 export const background2 = "#20405100";
 export const accentColor = "#949eff";
 export const accentColorDark = "#949eff";
-const tooltipStyles = {
-  ...defaultStyles,
-  background,
-  border: "1px solid white",
-  color: "white",
-};
 
 // util
 const formatDate = timeFormat("%H:%M:%S %b %d, '%y");
@@ -59,243 +41,351 @@ const formats: { [range: string]: (date: Date) => string } = {
 };
 
 // accessors
-const getDate = (d: MetricsData) => new Date(d.date * 1000);
-const getValue = (d: MetricsData) => d.value;
+const getDate = (d: NormalizedMetricsData) => new Date(d.date * 1000);
+const getValue = (d: NormalizedMetricsData) =>
+  d?.value && Number(d.value?.toFixed(4));
 
-const bisectDate = bisector<MetricsData, Date>((d) => new Date(d.date * 1000))
-  .left;
+const bisectDate = bisector<NormalizedMetricsData, Date>(
+  (d) => new Date(d.date * 1000)
+).left;
 
 export type AreaProps = {
-  data: MetricsData[];
+  data: NormalizedMetricsData[];
+  dataKey: string;
+  hpaEnabled?: boolean;
+  hpaData?: NormalizedMetricsData[];
   resolution: string;
   width: number;
   height: number;
   margin?: { top: number; right: number; bottom: number; left: number };
 };
 
-export default withTooltip<AreaProps, TooltipData>(
-  ({
-    data,
-    resolution,
-    width,
-    height,
-    margin = { top: 0, right: 0, bottom: 0, left: 0 },
+const AreaChart: React.FunctionComponent<AreaProps> = ({
+  data,
+  dataKey,
+  hpaEnabled = false,
+  hpaData = [],
+  resolution,
+  width,
+  height,
+  margin = { top: 0, right: 0, bottom: 0, left: 0 },
+}) => {
+  globalData = data;
+
+  const {
     showTooltip,
     hideTooltip,
     tooltipData,
-    tooltipTop = 0,
-    tooltipLeft = 0,
-  }: AreaProps & WithTooltipProvidedProps<TooltipData>) => {
-    globalData = data;
+    tooltipTop,
+    tooltipLeft,
+  } = useTooltip<{
+    data: NormalizedMetricsData;
+    tooltipHpaData: NormalizedMetricsData;
+  }>();
 
-    if (width == 0 || height == 0 || width < 10) {
-      return null;
-    }
+  const svgContainer = useRef();
+  // bounds
+  const innerWidth = width - margin.left - margin.right - 40;
+  const innerHeight = height - margin.top - margin.bottom - 20;
+  const isHpaEnabled = hpaEnabled && !!hpaData.length;
 
-    // bounds
-    const innerWidth = width - margin.left - margin.right - 40;
-    const innerHeight = height - margin.top - margin.bottom - 20;
+  // scales
+  const dateScale = useMemo(
+    () =>
+      scaleTime({
+        range: [margin.left, innerWidth + margin.left],
+        domain: extent(
+          [...globalData, ...(isHpaEnabled ? hpaData : [])],
+          getDate
+        ) as [Date, Date],
+      }),
+    [margin.left, width, height, data, hpaData, isHpaEnabled]
+  );
+  const valueScale = useMemo(
+    () =>
+      scaleLinear({
+        range: [innerHeight + margin.top, margin.top],
+        domain: [
+          0,
+          1.25 *
+            max([...globalData, ...(isHpaEnabled ? hpaData : [])], getValue),
+        ],
+        nice: true,
+      }),
+    [margin.top, width, height, data, hpaData, isHpaEnabled]
+  );
 
-    // scales
-    const dateScale = useMemo(
-      () =>
-        scaleTime({
-          range: [margin.left, innerWidth + margin.left],
-          domain: extent(globalData, getDate) as [Date, Date],
-        }),
-      [innerWidth, margin.left, width, height, data]
-    );
-    const valueScale = useMemo(
-      () =>
-        scaleLinear({
-          range: [innerHeight + margin.top, margin.top],
-          domain: [0, 1.25 * max(globalData, getValue)],
-          nice: true,
-        }),
-      [margin.top, innerHeight, width, height, data]
-    );
+  // tooltip handler
+  const handleTooltip = useCallback(
+    (
+      event: React.TouchEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>
+    ) => {
+      const isHpaEnabled = hpaEnabled && !!hpaData.length;
 
-    // tooltip handler
-    const handleTooltip = useCallback(
-      (
-        event:
-          | React.TouchEvent<SVGRectElement>
-          | React.MouseEvent<SVGRectElement>
-      ) => {
-        const { x } = localPoint(event) || { x: 0 };
-        const x0 = dateScale.invert(x);
-        const index = bisectDate(globalData, x0, 1);
-        const d0 = globalData[index - 1];
-        const d1 = globalData[index];
-        let d = d0;
+      const { x } = localPoint(event) || { x: 0 };
+      const x0 = dateScale.invert(x);
 
-        if (d1 && getDate(d1)) {
-          d =
-            x0.valueOf() - getDate(d0).valueOf() >
-            getDate(d1).valueOf() - x0.valueOf()
-              ? d1
-              : d0;
-        }
+      const index = bisectDate(globalData, x0, 1);
+      const d0 = globalData[index - 1];
+      const d1 = globalData[index];
+      let d = d0;
 
+      if (d1 && getDate(d1)) {
+        d =
+          x0.valueOf() - getDate(d0).valueOf() >
+          getDate(d1).valueOf() - x0.valueOf()
+            ? d1
+            : d0;
+      }
+
+      if (!isHpaEnabled) {
         showTooltip({
-          tooltipData: d,
+          tooltipData: { data: d, tooltipHpaData: undefined },
           tooltipLeft: x || 0,
           tooltipTop: valueScale(getValue(d)) || 0,
         });
-      },
-      [showTooltip, valueScale, dateScale, width, height, data]
-    );
+        return;
+      }
 
-    return (
-      <div>
-        <svg width={width} height={height}>
-          <rect
-            x={0}
-            y={0}
-            width={width}
-            height={height}
-            fill="url(#area-background-gradient)"
-            rx={14}
-          />
-          <LinearGradient
-            id="area-background-gradient"
-            from={background}
-            to={background2}
-          />
-          <LinearGradient
-            id="area-gradient"
-            from={accentColor}
-            to={accentColor}
-            toOpacity={0}
-          />
-          <GridRows
-            left={margin.left}
-            scale={valueScale}
-            width={innerWidth}
-            strokeDasharray="1,3"
-            stroke="white"
-            strokeOpacity={0.2}
-            pointerEvents="none"
-          />
-          <GridColumns
-            top={margin.top}
-            scale={dateScale}
-            height={innerHeight}
-            strokeDasharray="1,3"
-            stroke="white"
-            strokeOpacity={0.2}
-            pointerEvents="none"
-          />
-          <AreaClosed<MetricsData>
-            data={data}
+      const tooltipHpaData0 = hpaData[index - 1];
+      const tooltipHpaData1 = hpaData[index];
+      let tooltipHpaData = tooltipHpaData0;
+
+      if (tooltipHpaData1 && getDate(tooltipHpaData1)) {
+        tooltipHpaData =
+          x0.valueOf() - getDate(tooltipHpaData0).valueOf() >
+          getDate(tooltipHpaData1).valueOf() - x0.valueOf()
+            ? tooltipHpaData1
+            : tooltipHpaData0;
+      }
+
+      const container: SVGSVGElement = svgContainer.current;
+
+      let point = container.createSVGPoint();
+      // @ts-ignore
+      point.x = (event as any)?.clientX || 0;
+      // @ts-ignore
+      point.y = (event as any)?.clientY || 0;
+      point = point?.matrixTransform(container.getScreenCTM().inverse());
+
+      showTooltip({
+        tooltipData: { data: d, tooltipHpaData },
+        tooltipLeft: x || 0,
+        tooltipTop: point.y || 0,
+      });
+    },
+    [
+      showTooltip,
+      valueScale,
+      dateScale,
+      width,
+      height,
+      data,
+      hpaData,
+      svgContainer,
+      hpaEnabled,
+    ]
+  );
+
+  if (width == 0 || height == 0 || width < 10) {
+    return null;
+  }
+  const hpaGraphTooltipGlyphPosition =
+    (hpaEnabled &&
+      tooltipData?.tooltipHpaData &&
+      valueScale(getValue(tooltipData?.tooltipHpaData))) ||
+    0;
+
+  const dataGraphTooltipGlyphPosition =
+    (tooltipData?.data && valueScale(getValue(tooltipData.data))) || 0;
+
+  return (
+    <div>
+      <svg width={width} height={height} ref={svgContainer}>
+        <rect
+          x={0}
+          y={0}
+          width={width}
+          height={height}
+          fill="url(#area-background-gradient)"
+          rx={14}
+        />
+
+        <LinearGradient
+          id="area-background-gradient"
+          from={background}
+          to={background2}
+        />
+        <LinearGradient
+          id="area-gradient"
+          from={accentColor}
+          to={accentColor}
+          toOpacity={0}
+        />
+        <GridRows
+          left={margin.left}
+          scale={valueScale}
+          width={innerWidth}
+          strokeDasharray="1,3"
+          stroke="white"
+          strokeOpacity={0.2}
+          pointerEvents="none"
+        />
+        <GridColumns
+          top={margin.top}
+          scale={dateScale}
+          height={innerHeight}
+          strokeDasharray="1,3"
+          stroke="white"
+          strokeOpacity={0.2}
+          pointerEvents="none"
+        />
+        <AreaClosed<NormalizedMetricsData>
+          data={data}
+          x={(d) => dateScale(getDate(d)) ?? 0}
+          y={(d) => valueScale(getValue(d)) ?? 0}
+          height={innerHeight}
+          yScale={valueScale}
+          strokeWidth={1}
+          stroke="url(#area-gradient)"
+          fill="url(#area-gradient)"
+          curve={curveMonotoneX}
+        />
+        {isHpaEnabled && (
+          <LinePath<NormalizedMetricsData>
+            stroke="#ffffff"
+            strokeWidth={2}
+            data={hpaData}
             x={(d) => dateScale(getDate(d)) ?? 0}
             y={(d) => valueScale(getValue(d)) ?? 0}
-            height={innerHeight}
-            yScale={valueScale}
-            strokeWidth={1}
-            stroke="url(#area-gradient)"
-            fill="url(#area-gradient)"
-            curve={curveMonotoneX}
-          />
-          <AxisLeft
-            left={10}
-            scale={valueScale}
-            hideAxisLine={true}
-            hideTicks={true}
-            tickLabelProps={() => ({
-              fill: "white",
-              fontSize: 11,
-              textAnchor: "start",
-              fillOpacity: 0.4,
-              dy: 0,
-            })}
-          />
-          <AxisBottom
-            top={height - 20}
-            scale={dateScale}
-            tickFormat={formats[resolution]}
-            hideAxisLine={true}
-            hideTicks={true}
-            tickLabelProps={() => ({
-              fill: "white",
-              fontSize: 11,
-              textAnchor: "middle",
-              fillOpacity: 0.4,
-            })}
-          />
-          <Bar
-            x={margin.left}
-            y={margin.top}
-            width={innerWidth}
-            height={innerHeight}
-            fill="transparent"
-            rx={14}
-            onTouchStart={handleTooltip}
-            onTouchMove={handleTooltip}
-            onMouseMove={handleTooltip}
-            onMouseLeave={() => hideTooltip()}
+            strokeDasharray="6,4"
+            strokeOpacity={1}
+            pointerEvents="none"
           />
-          {tooltipData && (
-            <g>
-              <Line
-                from={{ x: tooltipLeft, y: margin.top }}
-                to={{ x: tooltipLeft, y: innerHeight + margin.top }}
-                stroke={accentColorDark}
-                strokeWidth={2}
-                pointerEvents="none"
-                strokeDasharray="5,2"
-              />
-              <circle
-                cx={tooltipLeft}
-                cy={tooltipTop + 1}
-                r={4}
-                fill="black"
-                fillOpacity={0.1}
-                stroke="black"
-                strokeOpacity={0.1}
-                strokeWidth={2}
-                pointerEvents="none"
-              />
-              <circle
-                cx={tooltipLeft}
-                cy={tooltipTop}
-                r={4}
-                fill={accentColorDark}
-                stroke="white"
-                strokeWidth={2}
-                pointerEvents="none"
-              />
-            </g>
-          )}
-        </svg>
+        )}
+
+        <AxisLeft
+          left={10}
+          scale={valueScale}
+          hideAxisLine={true}
+          hideTicks={true}
+          tickLabelProps={() => ({
+            fill: "white",
+            fontSize: 11,
+            textAnchor: "start",
+            fillOpacity: 0.4,
+            dy: 0,
+          })}
+        />
+        <AxisBottom
+          top={height - 20}
+          scale={dateScale}
+          tickFormat={formats[resolution]}
+          hideAxisLine={true}
+          hideTicks={true}
+          tickLabelProps={() => ({
+            fill: "white",
+            fontSize: 11,
+            textAnchor: "middle",
+            fillOpacity: 0.4,
+          })}
+        />
+        <Bar
+          x={margin.left}
+          y={margin.top}
+          width={innerWidth}
+          height={innerHeight}
+          fill="transparent"
+          rx={14}
+          onTouchStart={handleTooltip}
+          onTouchMove={handleTooltip}
+          onMouseMove={handleTooltip}
+          onMouseLeave={() => hideTooltip()}
+        />
         {tooltipData && (
-          <div>
-            <TooltipWithBounds
-              key={Math.random()}
-              top={tooltipTop - 12}
-              left={tooltipLeft + 12}
-              style={tooltipStyles}
-            >
-              {getValue(tooltipData)}
-            </TooltipWithBounds>
-            <Tooltip
-              top={-10}
-              left={tooltipLeft}
-              style={{
-                ...defaultStyles,
-                background: "#26272f",
-                color: "#aaaabb",
-                width: 100,
-                paddingTop: 35,
-                textAlign: "center",
-                transform: "translateX(-60px)",
-              }}
-            >
-              {formatDate(getDate(tooltipData))}
-            </Tooltip>
-          </div>
+          <g>
+            <Line
+              from={{ x: tooltipLeft, y: margin.top }}
+              to={{ x: tooltipLeft, y: innerHeight + margin.top }}
+              stroke={accentColorDark}
+              strokeWidth={2}
+              pointerEvents="none"
+              strokeDasharray="5,2"
+            />
+            <circle
+              cx={tooltipLeft}
+              cy={dataGraphTooltipGlyphPosition + 1}
+              r={4}
+              fill="black"
+              fillOpacity={0.1}
+              stroke="black"
+              strokeOpacity={0.1}
+              strokeWidth={2}
+              pointerEvents="none"
+            />
+            <circle
+              cx={tooltipLeft}
+              cy={dataGraphTooltipGlyphPosition}
+              r={4}
+              fill={accentColorDark}
+              stroke="white"
+              strokeWidth={2}
+              pointerEvents="none"
+            />
+            {isHpaEnabled && (
+              <>
+                <circle
+                  cx={tooltipLeft}
+                  cy={hpaGraphTooltipGlyphPosition + 1}
+                  r={4}
+                  fill="black"
+                  fillOpacity={0.1}
+                  stroke="black"
+                  strokeOpacity={0.1}
+                  strokeWidth={2}
+                  pointerEvents="none"
+                />
+                <circle
+                  cx={tooltipLeft}
+                  cy={hpaGraphTooltipGlyphPosition}
+                  r={4}
+                  fill={accentColorDark}
+                  stroke="white"
+                  strokeWidth={2}
+                  pointerEvents="none"
+                />
+              </>
+            )}
+          </g>
         )}
-      </div>
-    );
-  }
-);
+      </svg>
+      {tooltipData && (
+        <div>
+          <TooltipWithBounds
+            key={Math.random()}
+            top={tooltipTop - 12}
+            left={tooltipLeft + 12}
+            style={{
+              ...defaultStyles,
+              background: "#26272f",
+              color: "#aaaabb",
+              textAlign: "center",
+            }}
+          >
+            {formatDate(getDate(tooltipData.data))}
+            <div style={{ color: accentColor }}>
+              {dataKey}: {getValue(tooltipData.data)}
+            </div>
+            {isHpaEnabled && (
+              <div style={{ color: "#FFF" }}>
+                HPA Threshold: {getValue(tooltipData.tooltipHpaData)}
+              </div>
+            )}
+          </TooltipWithBounds>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default AreaChart;

+ 96 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts

@@ -0,0 +1,96 @@
+import {
+  GenericMetricResponse,
+  NormalizedMetricsData,
+  MetricsMemoryDataResponse,
+  MetricsCPUDataResponse,
+  MetricsNetworkDataResponse,
+  MetricsNGINXErrorsDataResponse,
+  AvailableMetrics,
+  MetricsHpaReplicasDataResponse,
+} from "./types";
+
+/**
+ * Normalize values from the API to be readable by the AreaChart component.
+ * This class was created to reduce the amount of parsing inside the MetricsSection component
+ * and improve readability
+ */
+export class MetricNormalizer {
+  metric_results: GenericMetricResponse["results"];
+  kind: AvailableMetrics;
+
+  constructor(data: GenericMetricResponse[], kind: AvailableMetrics) {
+    if (!Array.isArray(data) || !data[0]?.results) {
+      throw new Error("Failed parsing response" + JSON.stringify(data));
+    }
+    this.metric_results = data[0].results;
+    this.kind = kind;
+  }
+
+  getParsedData(): NormalizedMetricsData[] {
+    if (this.kind.includes("cpu")) {
+      return this.parseCPUMetrics(this.metric_results);
+    }
+    if (this.kind.includes("memory")) {
+      return this.parseMemoryMetrics(this.metric_results);
+    }
+    if (this.kind.includes("network")) {
+      return this.parseNetworkMetrics(this.metric_results);
+    }
+    if (this.kind.includes("nginx:errors")) {
+      return this.parseNGINXErrorsMetrics(this.metric_results);
+    }
+    if (this.kind.includes("hpa_replicas")) {
+      return this.parseHpaReplicaMetrics(this.metric_results);
+    }
+    return [];
+  }
+
+  private parseCPUMetrics(arr: MetricsCPUDataResponse["results"]) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: parseFloat(d.cpu),
+      };
+    });
+  }
+
+  private parseMemoryMetrics(arr: MetricsMemoryDataResponse["results"]) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
+      };
+    });
+  }
+
+  private parseNetworkMetrics(arr: MetricsNetworkDataResponse["results"]) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: parseFloat(d.bytes) / 1024, // put units in Ki
+      };
+    });
+  }
+
+  private parseNGINXErrorsMetrics(
+    arr: MetricsNGINXErrorsDataResponse["results"]
+  ) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: parseFloat(d.error_pct),
+      };
+    });
+  }
+
+  private parseHpaReplicaMetrics(
+    arr: MetricsHpaReplicasDataResponse["results"]
+  ) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: parseInt(d.replicas),
+      };
+    });
+  }
+}

+ 376 - 468
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -1,75 +1,22 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 
 import settings from "assets/settings.svg";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType, StorageType } from "shared/types";
+import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import Loading from "components/Loading";
 import SelectRow from "components/values-form/SelectRow";
-import AreaChart, { MetricsData } from "./AreaChart";
+import AreaChart from "./AreaChart";
+import { MetricNormalizer } from "./MetricNormalizer";
+import { AvailableMetrics, NormalizedMetricsData } from "./types";
+import CheckboxRow from "components/values-form/CheckboxRow";
 
 type PropsType = {
-  currentChart: ChartType;
-};
-
-type StateType = {
-  controllerOptions: any[];
-  ingressOptions: any[];
-  selectedController: any;
-  selectedIngress: any;
-  pods: any[];
-  selectedPod: string;
-  selectedRange: string;
-  selectedMetric: string;
-  selectedMetricLabel: string;
-  controllerDropdownExpanded: boolean;
-  podDropdownExpanded: boolean;
-  dropdownExpanded: boolean;
-  data: MetricsData[];
-  showMetricsSettings: boolean;
-  metricsOptions: MetricsOption[];
-  isLoading: number;
-};
-
-type MetricsCPUDataResponse = {
-  pod?: string;
-  results: {
-    date: number;
-    cpu: string;
-  }[];
-}[];
-
-type MetricsMemoryDataResponse = {
-  pod?: string;
-  results: {
-    date: number;
-    memory: string;
-  }[];
-}[];
-
-type MetricsNetworkDataResponse = {
-  pod?: string;
-  results: {
-    date: number;
-    bytes: string;
-  }[];
-}[];
-
-type MetricsNGINXErrorsDataResponse = {
-  pod?: string;
-  results: {
-    date: number;
-    error_pct: string;
-  }[];
-}[];
-
-type MetricsOption = {
-  value: string;
-  label: string;
+  currentChart: ChartTypeWithExtendedConfig;
 };
 
 const resolutions: { [range: string]: string } = {
@@ -86,39 +33,63 @@ const secondsBeforeNow: { [range: string]: number } = {
   "1M": 60 * 60 * 24 * 30,
 };
 
-export default class MetricsSection extends Component<PropsType, StateType> {
-  state = {
-    pods: [] as any[],
-    selectedPod: "",
-    controllerOptions: [] as any[],
-    selectedController: null as any,
-    ingressOptions: [] as any[],
-    selectedIngress: null as any,
-    selectedRange: "1H",
-    selectedMetric: "cpu",
-    selectedMetricLabel: "CPU Utilization (vCPUs)",
-    dropdownExpanded: false,
-    podDropdownExpanded: false,
-    controllerDropdownExpanded: false,
-    data: [] as MetricsData[],
-    showMetricsSettings: false,
-    metricsOptions: [
-      { value: "cpu", label: "CPU Utilization (vCPUs)" },
-      { value: "memory", label: "RAM Utilization (Mi)" },
-      { value: "network", label: "Network Received Bytes (Ki)" },
-    ],
-    isLoading: 0,
-  };
+const MetricsSection: React.FunctionComponent<PropsType> = ({
+  currentChart,
+}) => {
+  const [pods, setPods] = useState([]);
+  const [selectedPod, setSelectedPod] = useState("");
+  const [controllerOptions, setControllerOptions] = useState([]);
+  const [selectedController, setSelectedController] = useState(null);
+  const [ingressOptions, setIngressOptions] = useState([]);
+  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("cpu");
+  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
+    "CPU Utilization (vCPUs)"
+  );
+  const [dropdownExpanded, setDropdownExpanded] = useState(false);
+  const [data, setData] = useState<NormalizedMetricsData[]>([]);
+  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
+  const [metricsOptions, setMetricsOptions] = useState([
+    { value: "cpu", label: "CPU Utilization (vCPUs)" },
+    { value: "memory", label: "RAM Utilization (Mi)" },
+    { value: "network", label: "Network Received Bytes (Ki)" },
+  ]);
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState([]);
+  const [hpaEnabled, setHpaEnabled] = useState(
+    currentChart?.config?.autoscaling?.enabled
+  );
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  // Add or remove hpa replicas chart option when current chart is updated
+  useEffect(() => {
+    if (currentChart?.config?.autoscaling?.enabled) {
+      setMetricsOptions((prev) => {
+        if (prev.find((option) => option.value === "hpa_replicas")) {
+          return [...prev];
+        }
+        return [...prev, { value: "hpa_replicas", label: "HPA Replicas" }];
+      });
+    } else {
+      setMetricsOptions((prev) => {
+        const hpaReplicasOptionIndex = prev.findIndex(
+          (option) => option.value === "hpa_replicas"
+        );
+        const options = [...prev];
+        options.splice(hpaReplicasOptionIndex, 1);
+        return [...options];
+      });
+    }
+  }, [currentChart]);
 
-  componentDidMount() {
-    // get all controllers and read in a list of pods
-    let { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+  useEffect(() => {
+    if (currentChart?.chart?.metadata?.name == "ingress-nginx") {
+      setIsLoading((prev) => prev + 1);
 
-    if (currentChart.chart?.metadata?.name == "ingress-nginx") {
-      this.setState(({ isLoading }) => {
-        return { isLoading: isLoading + 1 };
-      });
       api
         .getNGINXIngresses(
           "<token>",
@@ -130,37 +101,35 @@ export default class MetricsSection extends Component<PropsType, StateType> {
           }
         )
         .then((res) => {
-          let metricsOptions = this.state.metricsOptions;
-          metricsOptions.push({
-            value: "nginx:errors",
-            label: "5XX Error Percentage",
-          });
-
-          let ingressOptions = [] as any[];
-          res.data.map((ingress: any) => {
-            ingressOptions.push({ value: ingress, label: ingress.name });
+          setMetricsOptions((prev) => {
+            return [
+              ...prev,
+              {
+                value: "nginx:errors",
+                label: "5XX Error Percentage",
+              },
+            ];
           });
 
+          const ingressOptions = res.data.map((ingress: any) => ({
+            value: ingress,
+            label: ingress.name,
+          }));
+          setIngressOptions(ingressOptions);
+          setSelectedIngress(ingressOptions[0]?.value);
           // iterate through the controllers to get the list of pods
-          this.setState({
-            metricsOptions,
-            ingressOptions,
-            selectedIngress: ingressOptions[0].value,
-          });
         })
         .catch((err) => {
           setCurrentError(JSON.stringify(err));
-          this.setState({ controllerOptions: [] as any[] });
+          setControllerOptions([]);
         })
         .finally(() => {
-          this.setState(({ isLoading }) => {
-            return { isLoading: isLoading - 1 };
-          });
+          setIsLoading((prev) => prev - 1);
         });
     }
-    this.setState(({ isLoading }) => {
-      return { isLoading: isLoading + 1 };
-    });
+
+    setIsLoading((prev) => prev + 1);
+
     api
       .getChartControllers(
         "<token>",
@@ -176,208 +145,28 @@ export default class MetricsSection extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        // TODO -- check at least one controller returned
-        let controllerOptions = [] as any[];
-        res.data.map((controller: any) => {
+        const controllerOptions = res.data.map((controller: any) => {
           let name = controller?.metadata?.name;
-          controllerOptions.push({ value: controller, label: name });
+          return { value: controller, label: name };
         });
 
-        // iterate through the controllers to get the list of pods
-        this.setState({
-          controllerOptions,
-          selectedController: controllerOptions[0].value,
-        });
-
-        this.getPods();
+        setControllerOptions(controllerOptions);
+        setSelectedController(controllerOptions[0]?.value);
       })
       .catch((err) => {
         setCurrentError(JSON.stringify(err));
-        this.setState({ controllerOptions: [] as any[] });
+        setControllerOptions([]);
       })
       .finally(() => {
-        this.setState(({ isLoading }) => {
-          return { isLoading: isLoading - 1 };
-        });
+        setIsLoading((prev) => prev - 1);
       });
-  }
-
-  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
-    // if resolution, data kind, controllers, or pods have changed, update data
-    if (this.state.selectedMetric != prevState.selectedMetric) {
-      this.getMetrics();
-    }
+  }, [currentChart, currentCluster, currentProject]);
 
-    if (this.state.selectedRange != prevState.selectedRange) {
-      this.getMetrics();
-    }
-
-    if (this.state.selectedPod != prevState.selectedPod) {
-      this.getMetrics();
-    }
-
-    if (
-      this.state.selectedController?.metadata?.name !=
-      prevState.selectedController?.metadata?.name
-    ) {
-      this.getMetrics();
-    }
-
-    if (this.state.selectedIngress?.name != prevState.selectedIngress?.name) {
-      this.getMetrics();
-    }
-  }
-
-  getMetrics = () => {
-    if (this.state.pods.length == 0) {
-      return;
-    }
-
-    let { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let kind = this.state.selectedMetric;
-    let shouldsum = true;
-    let namespace = currentChart.namespace;
-
-    // calculate start and end range
-    var d = new Date();
-    var end = Math.round(d.getTime() / 1000);
-    var start = end - secondsBeforeNow[this.state.selectedRange];
-
-    let pods = this.state.pods.map((pod: any) => {
-      return pod.value;
-    });
-
-    if (this.state.selectedPod != "All") {
-      pods = [this.state.selectedPod];
-    }
-
-    if (this.state.selectedMetric == "nginx:errors") {
-      pods = [this.state.selectedIngress?.name];
-      namespace = this.state.selectedIngress?.namespace || "default";
-      shouldsum = false;
-    }
-
-    this.setState(({ isLoading }) => {
-      return { isLoading: isLoading + 1 };
-    });
-
-    api
-      .getMetrics(
-        "<token>",
-        {
-          cluster_id: currentCluster.id,
-          metric: kind,
-          shouldsum: shouldsum,
-          pods,
-          namespace: namespace,
-          startrange: start,
-          endrange: end,
-          resolution: resolutions[this.state.selectedRange],
-        },
-        {
-          id: currentProject.id,
-        }
-      )
-      .then((res) => {
-        if (!Array.isArray(res.data) || !res.data[0]?.results) {
-          return;
-        }
-        // transform the metrics to expected form
-        if (kind == "cpu") {
-          let data = res.data as MetricsCPUDataResponse;
-
-          // if summed, just look at the first data
-          let tData = data[0].results.map(
-            (
-              d: {
-                date: number;
-                cpu: string;
-              },
-              i: number
-            ) => {
-              return {
-                date: d.date,
-                value: parseFloat(d.cpu),
-              };
-            }
-          );
-
-          this.setState({ data: tData });
-        } else if (kind == "memory") {
-          let data = res.data as MetricsMemoryDataResponse;
-
-          let tData = data[0].results.map(
-            (
-              d: {
-                date: number;
-                memory: string;
-              },
-              i: number
-            ) => {
-              return {
-                date: d.date,
-                value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
-              };
-            }
-          );
-
-          this.setState({ data: tData });
-        } else if (kind == "network") {
-          let data = res.data as MetricsNetworkDataResponse;
-
-          let tData = data[0].results.map(
-            (
-              d: {
-                date: number;
-                bytes: string;
-              },
-              i: number
-            ) => {
-              return {
-                date: d.date,
-                value: parseFloat(d.bytes) / 1024, // put units in Ki
-              };
-            }
-          );
-
-          this.setState({ data: tData });
-        } else if (kind == "nginx:errors") {
-          let data = res.data as MetricsNGINXErrorsDataResponse;
-
-          let tData = data[0].results.map(
-            (
-              d: {
-                date: number;
-                error_pct: string;
-              },
-              i: number
-            ) => {
-              return {
-                date: d.date,
-                value: parseFloat(d.error_pct), // put units in Ki
-              };
-            }
-          );
-
-          this.setState({ data: tData });
-        }
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        // this.setState({ controllers: [], loading: false });
-      })
-      .finally(() => {
-        this.setState(({ isLoading }) => {
-          return { isLoading: isLoading - 1 };
-        });
-      });
-  };
-
-  getPods = () => {
-    let { selectedController } = this.state;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+  useEffect(() => {
+    getPods();
+  }, [selectedController]);
 
+  const getPods = () => {
     let selectors = [] as string[];
     let ml =
       selectedController?.spec?.selector?.matchLabels ||
@@ -393,9 +182,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
     }
     selectors.push(selector);
 
-    this.setState(({ isLoading }) => {
-      return { isLoading: isLoading + 1 };
-    });
+    setIsLoading((prev) => prev + 1);
 
     api
       .getMatchingPods(
@@ -415,80 +202,170 @@ export default class MetricsSection extends Component<PropsType, StateType> {
           let name = pod?.metadata?.name;
           pods.push({ value: name, label: name });
         });
+        setPods(pods);
+        setSelectedPod("All");
 
-        this.setState({ pods, selectedPod: "All" });
-
-        this.getMetrics();
+        getMetrics();
       })
       .catch((err) => {
         setCurrentError(JSON.stringify(err));
         return;
       })
       .finally(() => {
-        this.setState(({ isLoading }) => {
-          return { isLoading: isLoading - 1 };
-        });
+        setIsLoading((prev) => prev - 1);
       });
   };
 
-  renderDropdown = () => {
-    if (this.state.dropdownExpanded) {
-      return (
-        <>
-          <DropdownOverlay
-            onClick={() => this.setState({ dropdownExpanded: false })}
-          />
-          <Dropdown
-            dropdownWidth="230px"
-            dropdownMaxHeight="200px"
-            onClick={() => this.setState({ dropdownExpanded: false })}
-          >
-            {this.renderOptionList()}
-          </Dropdown>
-        </>
+  const getAutoscalingThreshold = async (
+    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
+    shouldsum: boolean,
+    namespace: string,
+    start: number,
+    end: number
+  ) => {
+    setIsLoading((prev) => prev + 1);
+    setHpaData([]);
+    try {
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: metricType,
+          shouldsum: shouldsum,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: [],
+        },
+        {
+          id: currentProject.id,
+        }
       );
+
+      if (!Array.isArray(res.data) || !res.data[0]?.results) {
+        return;
+      }
+      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
+      setHpaData(autoscalingMetrics.getParsedData());
+      return;
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setIsLoading((prev) => prev - 1);
     }
   };
 
-  renderOptionList = () => {
-    return this.state.metricsOptions.map(
-      (option: { value: string; label: string }, i: number) => {
-        return (
-          <Option
-            key={i}
-            selected={option.value === this.state.selectedMetric}
-            onClick={() =>
-              this.setState({
-                selectedMetric: option.value,
-                selectedMetricLabel: option.label,
-              })
-            }
-            lastItem={i === this.state.metricsOptions.length - 1}
-          >
-            {option.label}
-          </Option>
-        );
+  const getMetrics = async () => {
+    if (pods?.length == 0) {
+      return;
+    }
+    try {
+      let shouldsum = selectedPod === "All";
+      let namespace = currentChart.namespace;
+
+      // calculate start and end range
+      const d = new Date();
+      const end = Math.round(d.getTime() / 1000);
+      const start = end - secondsBeforeNow[selectedRange];
+
+      let podNames = [] as string[];
+
+      if (!shouldsum) {
+        podNames = [selectedPod];
       }
-    );
+
+      if (selectedMetric == "nginx:errors") {
+        podNames = [selectedIngress?.name];
+        namespace = selectedIngress?.namespace || "default";
+        shouldsum = false;
+      }
+
+      setIsLoading((prev) => prev + 1);
+      setData([]);
+
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: selectedMetric,
+          shouldsum: shouldsum,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: podNames,
+        },
+        {
+          id: currentProject.id,
+        }
+      );
+
+      setHpaData([]);
+      const isHpaEnabled = currentChart.config.autoscaling.enabled;
+      if (shouldsum && isHpaEnabled) {
+        if (selectedMetric === "cpu") {
+          await getAutoscalingThreshold(
+            "cpu_hpa_threshold",
+            shouldsum,
+            namespace,
+            start,
+            end
+          );
+        } else if (selectedMetric === "memory") {
+          await getAutoscalingThreshold(
+            "memory_hpa_threshold",
+            shouldsum,
+            namespace,
+            start,
+            end
+          );
+        }
+      }
+
+      const metrics = new MetricNormalizer(
+        res.data,
+        selectedMetric as AvailableMetrics
+      );
+
+      // transform the metrics to expected form
+      setData(metrics.getParsedData());
+    } catch (error) {
+      setCurrentError(JSON.stringify(error));
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
   };
 
-  renderMetricsSettings = () => {
-    if (this.state.showMetricsSettings && true) {
-      if (this.state.selectedMetric == "nginx:errors") {
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedPod && selectedController) {
+      getMetrics();
+    }
+  }, [
+    selectedMetric,
+    selectedRange,
+    selectedPod,
+    selectedController,
+    selectedIngress,
+  ]);
+
+  const renderMetricsSettings = () => {
+    if (showMetricsSettings && true) {
+      if (selectedMetric == "nginx:errors") {
         return (
           <>
-            <DropdownOverlay
-              onClick={() => this.setState({ showMetricsSettings: false })}
-            />
+            <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
             <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
               <Label>Additional Settings</Label>
               <SelectRow
                 label="Target Ingress"
-                value={this.state.selectedIngress}
-                setActiveValue={(x: any) =>
-                  this.setState({ selectedIngress: x })
-                }
-                options={this.state.ingressOptions}
+                value={selectedIngress}
+                setActiveValue={(x: any) => setSelectedIngress(x)}
+                options={ingressOptions}
                 width="100%"
               />
             </DropdownAlt>
@@ -498,25 +375,21 @@ export default class MetricsSection extends Component<PropsType, StateType> {
 
       return (
         <>
-          <DropdownOverlay
-            onClick={() => this.setState({ showMetricsSettings: false })}
-          />
+          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
           <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
             <Label>Additional Settings</Label>
             <SelectRow
               label="Target Controller"
-              value={this.state.selectedController}
-              setActiveValue={(x: any) =>
-                this.setState({ selectedController: x })
-              }
-              options={this.state.controllerOptions}
+              value={selectedController}
+              setActiveValue={(x: any) => setSelectedController(x)}
+              options={controllerOptions}
               width="100%"
             />
             <SelectRow
               label="Target Pod"
-              value={this.state.selectedPod}
-              setActiveValue={(x: any) => this.setState({ selectedPod: x })}
-              options={this.state.pods}
+              value={selectedPod}
+              setActiveValue={(x: any) => setSelectedPod(x)}
+              options={pods}
               width="100%"
             />
           </DropdownAlt>
@@ -525,116 +398,137 @@ export default class MetricsSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    return (
-      <StyledMetricsSection>
-        <MetricsHeader>
-          <Flex>
-            <MetricSelector
-              onClick={() =>
-                this.setState({
-                  dropdownExpanded: !this.state.dropdownExpanded,
-                })
-              }
-            >
-              <MetricsLabel>{this.state.selectedMetricLabel}</MetricsLabel>
-              <i className="material-icons">arrow_drop_down</i>
-              {this.renderDropdown()}
-            </MetricSelector>
-            <Relative>
-              <IconWrapper
-                onClick={() => this.setState({ showMetricsSettings: true })}
-              >
-                <SettingsIcon src={settings} />
-              </IconWrapper>
-              {this.renderMetricsSettings()}
-            </Relative>
-            {/* <RefreshMetrics
-              className="material-icons-outlined"
-              onClick={() => this.getMetrics()}
-            >
-              refresh
-            </RefreshMetrics> */}
-
-          <Highlight color={"#7d7d81"} onClick={this.getMetrics}>
+  const renderDropdown = () => {
+    if (dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return metricsOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.value === selectedMetric}
+            onClick={() => {
+              setSelectedMetric(option.value);
+              setSelectedMetricLabel(option.label);
+            }}
+            lastItem={i === metricsOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
+  return (
+    <StyledMetricsSection>
+      <MetricsHeader>
+        <Flex>
+          <MetricSelector
+            onClick={() => setDropdownExpanded(!dropdownExpanded)}
+          >
+            <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderDropdown()}
+          </MetricSelector>
+          <Relative>
+            <IconWrapper onClick={() => setShowMetricsSettings(true)}>
+              <SettingsIcon src={settings} />
+            </IconWrapper>
+            {renderMetricsSettings()}
+          </Relative>
+
+          <Highlight color={"#7d7d81"} onClick={getMetrics}>
             <i className="material-icons">autorenew</i>
           </Highlight>
-
-          </Flex>
-          <RangeWrapper>
-            <TabSelector
-              noBuffer={true}
-              options={[
-                { value: "1H", label: "1H" },
-                { value: "6H", label: "6H" },
-                { value: "1D", label: "1D" },
-                { value: "1M", label: "1M" },
-              ]}
-              currentTab={this.state.selectedRange}
-              setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
-            />
-          </RangeWrapper>
-        </MetricsHeader>
-        {this.state.isLoading > 0 && <Loading />}
-        {this.state.data.length === 0 && this.state.isLoading === 0 && (
-            <Message>
-              No data available yet.
-              <Highlight color={"#8590ff"} onClick={this.getMetrics}>
-                <i className="material-icons">autorenew</i>
-                Refresh
-              </Highlight>
-            </Message>
-        )}
-
-        {this.state.data.length > 0 && this.state.isLoading === 0 && (
+        </Flex>
+        <RangeWrapper>
+          <TabSelector
+            noBuffer={true}
+            options={[
+              { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
+              { value: "1D", label: "1D" },
+              { value: "1M", label: "1M" },
+            ]}
+            currentTab={selectedRange}
+            setCurrentTab={(x: string) => setSelectedRange(x)}
+          />
+        </RangeWrapper>
+      </MetricsHeader>
+      {isLoading > 0 && <Loading />}
+      {data.length === 0 && isLoading === 0 && (
+        <Message>
+          No data available yet.
+          <Highlight color={"#8590ff"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Highlight>
+        </Message>
+      )}
+      {data.length > 0 && isLoading === 0 && (
+        <>
+          {currentChart?.config?.autoscaling?.enabled &&
+            ["cpu", "memory"].includes(selectedMetric) && (
+              <CheckboxRow
+                toggle={() => setHpaEnabled((prev) => !prev)}
+                checked={hpaEnabled}
+                label="Enable HPA Metrics"
+              />
+            )}
           <ParentSize>
             {({ width, height }) => (
               <AreaChart
-                data={this.state.data}
+                dataKey={selectedMetricLabel}
+                data={data}
+                hpaData={hpaData}
+                hpaEnabled={
+                  hpaEnabled && ["cpu", "memory"].includes(selectedMetric)
+                }
                 width={width}
                 height={height - 10}
-                resolution={this.state.selectedRange}
+                resolution={selectedRange}
                 margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
               />
             )}
           </ParentSize>
-        )}
-      </StyledMetricsSection>
-    );
-  }
-}
+        </>
+      )}
+    </StyledMetricsSection>
+  );
+};
 
-MetricsSection.contextType = Context;
+export default MetricsSection;
 
 const Highlight = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
   margin-left: 8px;
-  color: ${(props: {color: string}) => props.color};
+  color: ${(props: { color: string }) => props.color};
   cursor: pointer;
 
-
   > i {
     font-size: 20px;
     margin-right: 3px;
   }
 `;
 
-const RefreshMetrics = styled.span`
-  :hover {
-    cursor: pointer;
-  }
-`;
-
-const NoDataPlaceholder = styled.div`
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-`;
-
 const Label = styled.div`
   font-weight: bold;
 `;
@@ -644,16 +538,16 @@ const Relative = styled.div`
 `;
 
 const Message = styled.div`
-display: flex;
-height: 100%;
-width: calc(100% - 150px);
-align-items: center;
-justify-content: center;
-margin-left: 75px;
-text-align: center;
-color: #ffffff44;
-font-size: 13px;
-`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
 
 const IconWrapper = styled.div`
   display: flex;
@@ -748,9 +642,7 @@ const DropdownAlt = styled(Dropdown)`
 `;
 
 const RangeWrapper = styled.div`
-  position: absolute;
-  top: 0;
-  right: 0;
+  float: right;
   font-weight: bold;
   width: 156px;
   margin-top: -8px;
@@ -787,11 +679,27 @@ const MetricsLabel = styled.div`
 
 const StyledMetricsSection = styled.div`
   width: 100%;
-  height: 100%;
+  min-height: 450px;
+  height: 50vh;
+  overflow: hidden;
   display: flex;
   flex-direction: column;
   position: relative;
   font-size: 13px;
-  border-radius: 5px;
-  overflow: hidden;
+  border-radius: 10px;
+  border: 1px solid #ffffff33;
+  padding: 18px 22px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;

+ 65 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts

@@ -0,0 +1,65 @@
+export type MetricsCPUDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    cpu: string;
+  }[];
+};
+
+export type MetricsMemoryDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    memory: string;
+  }[];
+};
+
+export type MetricsNetworkDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    bytes: string;
+  }[];
+};
+
+export type MetricsNGINXErrorsDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    error_pct: string;
+  }[];
+};
+
+export type MetricsHpaReplicasDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    replicas: string;
+  }[];
+};
+
+export type GenericMetricResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    cpu: string;
+    memory: string;
+    bytes: string;
+    error_pct: string;
+    replicas: string;
+  }[];
+};
+
+export type NormalizedMetricsData = {
+  date: number; // unix timestamp
+  value: number; // value
+};
+
+export type AvailableMetrics =
+  | "cpu"
+  | "memory"
+  | "network"
+  | "nginx:errors"
+  | "cpu_hpa_threshold"
+  | "memory_hpa_threshold"
+  | "hpa_replicas";

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -382,7 +382,7 @@ const Refresh = styled.div`
 const LogTabs = styled.div`
   width: 100%;
   height: 25px;
-  background: #202227;
+  background: #121318;
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -412,7 +412,7 @@ const LogStream = styled.div`
   flex: 1;
   float: right;
   height: 100%;
-  background: #202227;
+  background: #121318;
   user-select: text;
   max-width: 65%;
   overflow-y: auto;

+ 19 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -150,14 +150,28 @@ const TabWrapper = styled.div`
 `;
 
 const StyledStatusSection = styled.div`
-  width: 100%;
-  height: 100%;
-  position: relative;
-  font-size: 13px;
   padding: 0px;
   user-select: text;
-  border-radius: 5px;
   overflow: hidden;
+  width: 100%;
+  min-height: 450px;
+  height: 50vh;
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 10px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;
 
 const Wrapper = styled.div`

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

@@ -322,8 +322,8 @@ const TemplateList = styled.div`
 `;
 
 const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
+  font-size: 20px;
+  font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
   white-space: nowrap;

+ 9 - 35
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -12,6 +12,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/values-form/FormDebugger";
+import TitleSection from "components/TitleSection";
 
 import { pushQueryParams, pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -157,7 +158,7 @@ class Dashboard extends Component<PropsType, StateType> {
                       {currentProject && currentProject.name[0].toUpperCase()}
                     </Overlay>
                   </DashboardIcon>
-                  <Title>{currentProject && currentProject.name}</Title>
+                  {currentProject && currentProject.name}
                   {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
                   })[0].kind === "admin" || (
@@ -169,6 +170,7 @@ class Dashboard extends Component<PropsType, StateType> {
                     </i>
                   )}
                 </TitleSection>
+                <Br />
 
                 <InfoSection>
                   <TopRow>
@@ -201,6 +203,11 @@ Dashboard.contextType = Context;
 
 export default withRouter(withAuth(Dashboard));
 
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
+
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
@@ -288,6 +295,7 @@ const DashboardImage = styled.img`
 const DashboardIcon = styled.div`
   position: relative;
   height: 45px;
+  margin-right: 17px;
   width: 45px;
   border-radius: 5px;
   display: flex;
@@ -298,37 +306,3 @@ const DashboardIcon = styled.div`
     font-size: 22px;
   }
 `;
-
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 18px;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  height: 80px;
-  margin-top: 10px;
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding-left: 0px;
-
-  > i {
-    margin-left: 10px;
-    cursor: pointer;
-    font-size: 18px;
-    color: #858faaaa;
-    padding: 5px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-    margin-bottom: -3px;
-  }
-`;

+ 29 - 61
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -8,6 +8,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import api from "shared/api";
 import { pushFiltered } from "shared/routing";
+import TitleSection from "components/TitleSection";
 
 type PropsType = RouteComponentProps & {
   category: string;
@@ -125,20 +126,16 @@ class IntegrationCategories extends Component<PropsType, StateType> {
       integrationList[currentCategory].buttonText;
     if (currentCategory !== "repo") {
       return (
-        <div>
-          <TitleSectionAlt>
-            <Flex>
-              <i
-                className="material-icons"
-                onClick={() =>
-                  pushFiltered(this.props, "/integrations", ["project_id"])
-                }
-              >
-                keyboard_backspace
-              </i>
-              <Icon src={icon && icon} />
-              <Title>{label}</Title>
-            </Flex>
+        <>
+          <Flex>
+            <TitleSection
+              handleNavBack={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+              icon={icon}
+            >
+              {label}
+            </TitleSection>
             <Button
               onClick={() =>
                 this.context.setCurrentModal("IntegrationsModal", {
@@ -155,9 +152,7 @@ class IntegrationCategories extends Component<PropsType, StateType> {
               <i className="material-icons">add</i>
               {buttonText}
             </Button>
-          </TitleSectionAlt>
-
-          <LineBreak />
+          </Flex>
 
           <IntegrationList
             currentCategory={currentCategory}
@@ -168,24 +163,20 @@ class IntegrationCategories extends Component<PropsType, StateType> {
               this.getIntegrationsForCategory(this.props.category)
             }
           />
-        </div>
+        </>
       );
     } else {
       return (
-        <div>
-          <TitleSectionAlt>
-            <Flex>
-              <i
-                className="material-icons"
-                onClick={() =>
-                  pushFiltered(this.props, "/integrations", ["project_id"])
-                }
-              >
-                keyboard_backspace
-              </i>
-              <Icon src={icon && icon} />
-              <Title>{label}</Title>
-            </Flex>
+        <>
+          <Flex>
+            <TitleSection
+              handleNavBack={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+              icon={icon}
+            >
+              {label}
+            </TitleSection>
             <Button
               onClick={() =>
                 window.open(
@@ -196,9 +187,7 @@ class IntegrationCategories extends Component<PropsType, StateType> {
               <GHIcon />
               {buttonText}
             </Button>
-          </TitleSectionAlt>
-
-          <LineBreak />
+          </Flex>
 
           <IntegrationList
             currentCategory={currentCategory}
@@ -209,7 +198,7 @@ class IntegrationCategories extends Component<PropsType, StateType> {
               this.getIntegrationsForCategory(this.props.category)
             }
           />
-        </div>
+        </>
       );
     }
   };
@@ -228,6 +217,8 @@ const Icon = styled.img`
 const Flex = styled.div`
   display: flex;
   align-items: center;
+  margin-bottom: -20px;
+  justify-content: space-between;
 
   > i {
     cursor: pointer;
@@ -244,6 +235,7 @@ const Flex = styled.div`
 
 const Button = styled.div`
   height: 100%;
+  margin-top: -12px;
   background: #616feecc;
   :hover {
     background: #505edddd;
@@ -271,33 +263,9 @@ const Button = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  height: 40px;
-`;
-
-const TitleSectionAlt = styled(TitleSection)`
-  margin-left: -42px;
-  width: calc(100% + 42px);
-`;
-
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;
   background: #ffffff20;
-  margin: 32px 0px 24px;
+  margin: 18px 0px 24px;
 `;

+ 19 - 40
dashboard/src/main/home/integrations/Integrations.tsx

@@ -8,6 +8,7 @@ import { pushFiltered } from "shared/routing";
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";
 import IntegrationList from "./IntegrationList";
+import TitleSection from "components/TitleSection";
 
 type PropsType = RouteComponentProps;
 
@@ -36,22 +37,17 @@ class Integrations extends Component<PropsType, StateType> {
               integrationList[integration] && integrationList[integration].icon;
             return (
               <div>
-                <TitleSectionAlt>
-                  <Flex>
-                    <i
-                      className="material-icons"
-                      onClick={() =>
-                        pushFiltered(this.props, `/integrations/${category}`, [
-                          "project_id",
-                        ])
-                      }
-                    >
-                      keyboard_backspace
-                    </i>
-                    <Icon src={icon && icon} />
-                    <Title>{integrationList[integration].label}</Title>
-                  </Flex>
-                </TitleSectionAlt>
+                <TitleSection
+                  icon={icon}
+                  handleNavBack={() =>
+                    pushFiltered(this.props, `/integrations/${category}`, [
+                      "project_id",
+                    ])
+                  }
+                >
+                  {integrationList[integration].label}
+                </TitleSection>
+                <Buffer />
                 <CreateIntegrationForm
                   integrationName={integration}
                   closeForm={() => {
@@ -77,9 +73,7 @@ class Integrations extends Component<PropsType, StateType> {
         />
         <Route>
           <div>
-            <TitleSection>
-              <Title>Integrations</Title>
-            </TitleSection>
+            <TitleSection>Integrations</TitleSection>
 
             <IntegrationList
               currentCategory={""}
@@ -99,6 +93,11 @@ class Integrations extends Component<PropsType, StateType> {
 
 export default withRouter(Integrations);
 
+const Buffer = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 150px;
@@ -127,32 +126,12 @@ const Flex = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  height: 40px;
-`;
-
 const TitleSectionAlt = styled(TitleSection)`
   margin-left: -42px;
   width: calc(100% + 42px);
 `;
 
 const StyledIntegrations = styled.div`
-  width: calc(90% - 150px);
+  width: calc(83% + 50px);
   min-width: 300px;
-  padding-top: 75px;
 `;

+ 3 - 34
dashboard/src/main/home/launch/Launch.tsx

@@ -10,6 +10,7 @@ import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
+import TitleSection from "components/TitleSection";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
@@ -235,7 +236,7 @@ export default class Templates extends Component<PropsType, StateType> {
       return (
         <TemplatesWrapper>
           <TitleSection>
-            <Title>Launch</Title>
+            Launch
             <a href="https://docs.getporter.dev/docs/add-ons" target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
@@ -399,39 +400,7 @@ const TemplateList = styled.div`
   grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > a {
-    > i {
-      display: flex;
-      align-items: center;
-      margin-bottom: -2px;
-      font-size: 18px;
-      margin-left: 15px;
-      color: #858faaaa;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
 const TemplatesWrapper = styled.div`
-  width: calc(90% - 130px);
+  width: calc(83% + 50px);
   min-width: 300px;
-  padding-top: 75px;
 `;

+ 2 - 2
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -318,8 +318,8 @@ const Polymer = styled.div`
 `;
 
 const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
+  font-size: 20px;
+  font-weight: 500;
   font-family: "Work Sans", sans-serif;
   margin-left: 10px;
   border-radius: 2px;

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

@@ -11,6 +11,7 @@ import { pushFiltered } from "shared/routing";
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
 import SettingsPage from "./SettingsPage";
+import TitleSection from "components/TitleSection";
 
 import {
   PorterTemplate,
@@ -455,14 +456,9 @@ class LaunchFlow extends Component<PropsType, StateType> {
 
     return (
       <StyledLaunchFlow>
-        <TitleSection>
-          <i className="material-icons" onClick={this.props.hideLaunchFlow}>
-            keyboard_backspace
-          </i>
+        <TitleSection handleNavBack={this.props.hideLaunchFlow}>
           {this.renderIcon()}
-          <Title>
-            New {name} {currentTab === "porter" ? null : "Instance"}
-          </Title>
+          New {name} {currentTab === "porter" ? null : "Instance"}
         </TitleSection>
         {this.renderCurrentPage()}
         <Br />
@@ -509,54 +505,8 @@ const Polymer = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > i {
-    cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
-    margin-right: 10px;
-    padding: 3px;
-    margin-left: 0px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-
-  > a {
-    > i {
-      display: flex;
-      align-items: center;
-      margin-bottom: -2px;
-      font-size: 18px;
-      margin-left: 18px;
-      color: #858faaaa;
-      cursor: pointer;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
 const StyledLaunchFlow = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
-  padding-top: 20px;
-  margin-top: calc(50vh - 340px);
+  margin-top: calc(50vh - 380px);
 `;

+ 4 - 28
dashboard/src/main/home/new-project/NewProject.tsx

@@ -8,6 +8,7 @@ import { isAlphanumeric } from "shared/common";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
+import TitleSection from "components/TitleSection";
 
 type PropsType = {};
 
@@ -27,9 +28,7 @@ export default class NewProject extends Component<PropsType, StateType> {
     let { projectName } = this.state;
     return (
       <StyledNewProject>
-        <TitleSection>
-          <Title>New Project</Title>
-        </TitleSection>
+        <TitleSection>New Project</TitleSection>
         <Helper>
           Project name
           <Warning
@@ -130,8 +129,8 @@ const Warning = styled.span`
 `;
 
 const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
+  font-size: 20px;
+  font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
   white-space: nowrap;
@@ -139,32 +138,9 @@ const Title = styled.div`
   text-overflow: ellipsis;
 `;
 
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > a {
-    > i {
-      display: flex;
-      align-items: center;
-      margin-bottom: -2px;
-      font-size: 18px;
-      margin-left: 18px;
-      color: #858faaaa;
-      cursor: pointer;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
 const StyledNewProject = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
   position: relative;
-  padding-top: 50px;
   margin-top: calc(50vh - 340px);
 `;

+ 3 - 23
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -7,6 +7,7 @@ import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = WithAuthProps & {};
@@ -91,9 +92,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledProjectSettings>
-        <TitleSection>
-          <Title>Project Settings</Title>
-        </TitleSection>
+        <TitleSection>Project Settings</TitleSection>
         <TabRegion
           currentTab={this.state.currentTab}
           setCurrentTab={(x: string) => this.setState({ currentTab: x })}
@@ -117,28 +116,9 @@ const Warning = styled.div`
   margin-bottom: 20px;
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 13px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  height: 40px;
-`;
-
 const StyledProjectSettings = styled.div`
-  width: calc(90% - 130px);
+  width: calc(83% + 50px);
   min-width: 300px;
-  padding-top: 70px;
   height: 100vh;
 `;
 

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

@@ -558,7 +558,9 @@ const getMetrics = baseApi<
     cluster_id: number;
     metric: string;
     shouldsum: boolean;
-    pods: string[];
+    pods?: string[];
+    kind?: string; // the controller kind
+    name: string;
     namespace: string;
     startrange: number;
     endrange: number;
@@ -864,6 +866,18 @@ const updateConfigMap = baseApi<
   return `/api/projects/${id}/k8s/configmap/update?cluster_id=${cluster_id}`;
 });
 
+const renameConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    new_name: string;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/rename?cluster_id=${cluster_id}`;
+});
+
 const deleteConfigMap = baseApi<
   {
     name: string;
@@ -1052,6 +1066,7 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
+  renameConfigMap,
   updateConfigMap,
   upgradeChartValues,
   deleteJob,

+ 2 - 4
dashboard/src/shared/hardcodedNameDict.tsx

@@ -28,12 +28,10 @@ const hardcodedIcons: { [key: string]: string } = {
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
   mongodb:
     "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
-  datadog:
-    "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  datadog: "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
   wallarm:
     "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
-  agones:
-    "https://avatars.githubusercontent.com/u/36940055?v=4",
+  agones: "https://avatars.githubusercontent.com/u/36940055?v=4",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
     "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",

+ 57 - 0
dashboard/src/shared/types.tsx

@@ -48,6 +48,63 @@ export interface ChartType {
   latest_version: string;
 }
 
+export interface ChartTypeWithExtendedConfig extends ChartType {
+  config: {
+    auto_deploy: boolean;
+    autoscaling: {
+      enabled: boolean;
+      maxReplicas: number;
+      minReplicas: number;
+      targetCPUUtilizationPercentage: number;
+      targetMemoryUtilizationPercentage: number;
+    };
+    cloudsql: {
+      connectionName: string;
+      dbPort: number;
+      enabled: boolean;
+      serviceAccountJSON: string;
+    };
+    container: {
+      command: string;
+      env: any;
+      lifecycle: { postStart: string; preStop: string };
+      port: number;
+    };
+    currentCluster: {
+      service: { is_aws: boolean; is_do: boolean; is_gcp: boolean };
+    };
+    health: {
+      enabled: boolean;
+      failureThreshold: number;
+      path: string;
+      periodSeconds: number;
+    };
+    image: {
+      pullPolicy: string;
+      repository: string;
+      tag: string;
+    };
+    ingress: {
+      annotations: any;
+      custom_domain: boolean;
+      custom_paths: any[];
+      enabled: boolean;
+      hosts: any[];
+      porter_hosts: string[];
+      provider: string;
+      wildcard: boolean;
+    };
+    pvc: { enabled: boolean; mountPath: string; storage: string };
+    replicaCount: number;
+    resources: { requests: { cpu: string; memory: string } };
+    service: { port: number };
+    serviceAccount: { annotations: any; create: boolean; name: string };
+    showStartCommand: boolean;
+    statefulset: { enabled: boolean };
+    terminationGracePeriodSeconds: number;
+  };
+}
+
 export interface ResourceType {
   ID: number;
   Kind: string;

+ 67 - 0
docs/guides/update-instance-type-eks.md

@@ -0,0 +1,67 @@
+# Guide: Updating Instance Type on AWS EKS
+![image](https://user-images.githubusercontent.com/22849518/127391733-c081642f-dc58-4113-b55e-e12aa4477ec7.png)
+
+## Motivation
+There are multiple scenarios where it makes sense to update the instance type of some or all nodes on a Kubernetes cluster. You may want to use larger machines to grant more computing resources to individual pods or attach GPUs to certain nodes to support ML workloads.
+
+Regardless, changing the instance type of an EKS node group follows the same general process. In this guide, we'll walk through a basic example and highlight a few things to look out for when updating an existing cluster. 
+
+## Prerequisites
+This guide assumes you have an EKS cluster with autoscaling enabled. The node group for your cluster should also be self-managed, meaning you can view and update the EC2 instances and auto scaling group(s) attached to your cluster. Note that if you provisioned your cluster through [Porter](https://github.com/porter-dev/porter) this is automatically the case.
+
+## Step 1: Create a new launch configuration
+In this example, we'll upgrade a group of EKS worker nodes from t3.medium to t3.xlarge instances. To start, we can see from the Porter dashboard (or [AWS console](https://console.aws.amazon.com/ec2#Instances:)) that our EKS cluster has three t3.medium instances for user workloads and two t2.medium instances for Kubernetes system components:
+
+![image](https://user-images.githubusercontent.com/22849518/127392594-74d50e08-9394-4e86-b091-23cad6fbd60c.png)
+
+Note that you might have one (or more than two) node groups depending on how you initially configured your cluster. If you created an EKS cluster using Porter, your cluster will also have two node groups by default.
+
+The first step is to update the launch template/configuration of our auto scaling group. When the EKS cluster scales out, the auto scaling group uses a launch config to decide what kind of instance to add and how it should be configured. We can view the current launch config of our auto scaling group under **EC2 -> Auto Scaling groups**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392659-3b973671-c900-44c9-8942-4f5e6945e0b3.png)
+
+We'll select the launch config of the worker group with three instances and proceed by selecting **Actions -> Copy launch configuration**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392681-7af18061-6537-4f19-b05d-b9ea6934044a.png)
+
+From this view, we can name our new launch config and specify an updated instance type. For this example I've opted to use t3.xlarge instances for the updated auto scaling group:
+
+![image](https://user-images.githubusercontent.com/22849518/127392701-48adcad9-0e40-4ce0-b531-808350f5f2eb.png)
+
+Next, we need to specify a key pair for accessing instances generated by our launch config (for example through SSH). After choosing an existing key pair (or creating a new one if needed), select **Create launch configuration**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392735-89f12e56-a76d-4824-a825-e473ebde478a.png)
+
+We now have an updated launch config that we can use for spinning up new instances through an autoscaling group.
+
+## Step 2: Update the autoscaling group and refresh existing instances
+Now that we've created a new launch configuration, we need to attach it to our existing autoscaling group. Returning to **EC2 -> Auto Scaling groups**, find the auto scaling group to update and select **Edit**. From the edit screen, we can select our new launch config and choose **Update**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392827-21a7ae1a-c218-423f-b0e4-41b1692a44e1.png)
+
+At this point, when new worker nodes are added to our cluster, they will use the updated launch config. Unfortunately, we're not finished just yet since the existing nodes still haven't been upgraded.
+
+[EC2 instance refresh](https://aws.amazon.com/blogs/compute/introducing-instance-refresh-for-ec2-auto-scaling/) allows us to trigger a rolling update across an auto scaling group while ensuring a minimum number of instances remain available throughout the update process. After selecting the same auto scaling group from before, navigate to the **Instance refresh** tab, and choose **Start instance refresh**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392938-3942f29b-5486-4b4d-ba54-4215340a91af.png)
+
+Here, we're given the option to set the minimum healthy percentage (how much capacity should remain available during the refresh) as well as the warmup time for new instances:
+
+![image](https://user-images.githubusercontent.com/22849518/127392949-a29fb2d1-daed-4463-a6b1-9b14a2fabeda.png)
+
+Note that at least one instance will be refreshed at a time. As the instances are being refreshed, Kubernetes will automatically reschedule workloads to the new nodes. For auto scaling groups with only one instance, it may be desirable to temporarily [increase the minimum limit](https://docs.aws.amazon.com/autoscaling/ec2/userguide/asg-capacity-limits.html) to ensure that workloads can be immediately scheduled. 
+
+**Important note:** if you need to avoid service downtime, you will have to ensure that multiple replicas of each application are running across more than one node. Even if your application has multiple replicas, Kubernetes doesn't prevent all replicas from being collocated on the same node by default. This means that if all replicas for your service are running on a single node, the service will become temporarily unavailable when the node is spun down. For more details on tolerating node failure, you can configure pod anti-affinity following [this example](https://kubernetes.io/docs/tutorials/stateful-application/zookeeper/#tolerating-node-failure). If your Kubernetes version is 1.19 or later, you can also use [pod topology spread constraints](https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/) to ensure high availability across nodes.
+
+Once you are ready to trigger the instance refresh, choose **Start**. Bear in mind that instance refresh can take 5-10 minutes per node, and the total time to complete the refresh also depends on the size of the cluster. 
+
+## Step 3: Verify instance update and existing workloads
+After the instance refresh is completed, we can check our cluster to verify that the worker group has updated:
+
+![image](https://user-images.githubusercontent.com/22849518/127393233-f832bf90-01bf-4cea-b939-9b0aa38bc5cc.png)
+
+As we can see, the three worker nodes are now running as t3.xlarge instances. Finally, to confirm that our workloads were successfully rescheduled, we can check the status of our existing applications from the Porter dashboard:
+
+![image](https://user-images.githubusercontent.com/22849518/127393251-2cf34a75-a3d1-484a-a9ce-dba38a5090dd.png)
+
+It looks like everything is running successfully on our updated nodes! If you have multiple auto scaling groups that you would like to update, you can simply repeat this process as needed.

+ 32 - 1
docs/reference/cli.md

@@ -19,6 +19,21 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 
+To download a specific version of the CLI:
+
+```sh
+{
+# NOTE: replace this line with the version
+version=v0.6.1
+name=porter-$version.zip
+curl -L https://github.com/porter-dev/porter/releases/download/${version}/porter_${version}_Darwin_x86_64.zip --output $name
+unzip -a $name
+rm $name
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+}
+```
+
 ## Linux
 
 Run the following command to grab the latest binary:
@@ -40,6 +55,22 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 
+
+To download a specific version of the CLI:
+
+```sh
+{
+# NOTE: replace this line with the version
+version=v0.6.1
+name=porter-$version.zip
+curl -L https://github.com/porter-dev/porter/releases/download/${version}/porter_${version}_Linux_x86_64.zip --output $name
+unzip -a $name
+rm $name
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+}
+```
+
 ## Windows
 
 Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip) to download the Windows executable and add the binary to your `PATH`.
@@ -118,4 +149,4 @@ Here's a reference table for the CLI documentation:
 | `porter config set-project [PROJECT_ID]` | Sets the current project in config. |
 | `porter connect [INTEGRATION]` | Connects Porter with the given infrastructure. Accepts `kubeconfig` and `ecr` as arguments. |
 | `porter docker configure` | Grants the `docker` CLI access to a provisioned image registry. |
-| `porter run [RELEASE] -- [COMMAND] [args...]` | Executes a command on a remote container, specified by the release name. |
+| `porter run [RELEASE] -- [COMMAND] [args...]` | Executes a command on a remote container, specified by the release name. |

+ 2 - 2
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/aws/aws-sdk-go v1.35.4
-	github.com/bradleyfalzon/ghinstallation v1.1.1 // indirect
+	github.com/bradleyfalzon/ghinstallation v1.1.1
 	github.com/buildpacks/pack v0.19.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
@@ -28,7 +28,7 @@ require (
 	github.com/google/go-github/v29 v29.0.3 // indirect
 	github.com/google/go-github/v33 v33.0.0
 	github.com/google/go-querystring v1.1.0 // indirect
-  github.com/gorilla/mux v1.8.0 // indirect
+	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1

+ 1 - 0
internal/analytics/track_events.go

@@ -5,4 +5,5 @@ type SegmentEvent string
 const (
 	NewUser            SegmentEvent = "New User"
 	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
+	NewClusterEvent    SegmentEvent = "New Cluster Event"
 )

+ 44 - 3
internal/analytics/tracks.go

@@ -18,8 +18,8 @@ type segmentNewUserTrack struct {
 	userEmail string
 }
 
-// Constructor for track of type "New User"
-// Tracks when a user has registered
+// CreateSegmentNewUserTrack creates a track of type "New User", which
+// tracks when a user has registered
 func CreateSegmentNewUserTrack(user *models.User) *segmentNewUserTrack {
 	userId := fmt.Sprintf("%v", user.ID)
 
@@ -46,7 +46,7 @@ type segmentRedeployViaWebhookTrack struct {
 	repository string
 }
 
-// Constructor for track of type "Triggered Re-deploy via Webhook"
+// CreateSegmentRedeployViaWebhookTrack creates a track of type "Triggered Re-deploy via Webhook", which
 // tracks whenever a repository is redeployed via webhook call
 func CreateSegmentRedeployViaWebhookTrack(userId string, repository string) *segmentRedeployViaWebhookTrack {
 	return &segmentRedeployViaWebhookTrack{
@@ -66,3 +66,44 @@ func (t *segmentRedeployViaWebhookTrack) getEvent() SegmentEvent {
 func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
 	return segment.NewProperties().Set("repository", t.repository)
 }
+
+type segmentNewClusterEventTrack struct {
+	userId      string
+	projId      string
+	clusterName string
+	clusterType string // EKS, DOKS, or GKE
+	eventType   string // connected, provisioned, or destroyed
+}
+
+// NewClusterEventOpts are the parameters for creating a "New Cluster Event" track
+type NewClusterEventOpts struct {
+	UserId      string
+	ProjId      string
+	ClusterName string
+	ClusterType string // EKS, DOKS, or GKE
+	EventType   string // connected, provisioned, or destroyed
+}
+
+// CreateSegmentNewClusterEvent creates a track of type "New Cluster Event", which
+// tracks whenever a cluster is newly provisioned, connected, or destroyed.
+func CreateSegmentNewClusterEvent(opts *NewClusterEventOpts) *segmentNewClusterEventTrack {
+	return &segmentNewClusterEventTrack{
+		userId:      opts.UserId,
+		projId:      opts.ProjId,
+		clusterName: opts.ClusterName,
+		clusterType: opts.ClusterType,
+		eventType:   opts.EventType,
+	}
+}
+
+func (t *segmentNewClusterEventTrack) getUserId() string {
+	return t.userId
+}
+
+func (t *segmentNewClusterEventTrack) getEvent() SegmentEvent {
+	return NewClusterEvent
+}
+
+func (t *segmentNewClusterEventTrack) getProperties() segment.Properties {
+	return segment.NewProperties().Set("Project ID", t.projId).Set("Cluster Name", t.clusterName).Set("Cluster Type", t.clusterType).Set("Event Type", t.eventType)
+}

+ 6 - 0
internal/forms/k8s.go

@@ -45,6 +45,12 @@ type ConfigMapForm struct {
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 }
 
+type RenameConfigMapForm struct {
+	Name      string `json:"name" form:"required"`
+	Namespace string `json:"namespace" form:"required"`
+	NewName   string `json:"new_name" form:"required"`
+}
+
 type NamespaceForm struct {
 	Name string `json:"name" form:"required"`
 }

+ 11 - 15
internal/integrations/ci/actions/actions.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"encoding/base64"
 	"fmt"
+	"net/http"
+
 	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/google/go-github/v33/github"
 	"github.com/porter-dev/porter/internal/models"
@@ -11,7 +13,6 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/oauth2"
-	"net/http"
 
 	"strings"
 
@@ -34,6 +35,7 @@ type GithubActions struct {
 	PorterToken  string
 	BuildEnv     map[string]string
 	ProjectID    uint
+	ClusterID    uint
 	ReleaseName  string
 
 	GitBranch      string
@@ -71,7 +73,7 @@ func (g *GithubActions) Setup() (string, error) {
 		return "", err
 	}
 
-	// create a new secret with a porter token
+	// create new secrets porter token, project id, and cluster id
 	err = g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken)
 
 	if err != nil {
@@ -132,10 +134,12 @@ func (g *GithubActions) Cleanup() error {
 }
 
 type GithubActionYAMLStep struct {
-	Name string `yaml:"name,omitempty"`
-	ID   string `yaml:"id,omitempty"`
-	Uses string `yaml:"uses,omitempty"`
-	Run  string `yaml:"run,omitempty"`
+	Name    string            `yaml:"name,omitempty"`
+	ID      string            `yaml:"id,omitempty"`
+	Timeout uint64            `yaml:"timeout-minutes,omitempty"`
+	Uses    string            `yaml:"uses,omitempty"`
+	Run     string            `yaml:"run,omitempty"`
+	Env     map[string]string `yaml:"env,omitempty"`
 }
 
 type GithubActionYAMLOnPushBranches struct {
@@ -163,17 +167,9 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getDownloadPorterStep(),
-		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName()),
+		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName),
 	}
 
-	if g.DockerFilePath == "" {
-		gaSteps = append(gaSteps, getBuildPackPushStep(g.getBuildEnvSecretName(), g.FolderPath, g.ImageRepoURL))
-	} else {
-		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
-	}
-
-	gaSteps = append(gaSteps, deployPorterWebhookStep(g.ServerURL, g.getWebhookSecretName()))
-
 	branch := g.GitBranch
 
 	if branch == "" {

+ 13 - 57
internal/integrations/ci/actions/steps.go

@@ -2,7 +2,6 @@ package actions
 
 import (
 	"fmt"
-	"path/filepath"
 )
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
@@ -12,8 +11,7 @@ func getCheckoutCodeStep() GithubActionYAMLStep {
 	}
 }
 
-const download string = `
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+const download string = `name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name
@@ -30,61 +28,19 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 	}
 }
 
-const configure string = `
-sudo porter config set-host %s
-sudo porter auth login --token ${{secrets.%s}}
-sudo porter docker configure
-`
-
-func getConfigurePorterStep(serverURL, porterTokenSecretName string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Configure Porter",
-		ID:   "configure_porter",
-		Run:  fmt.Sprintf(configure, serverURL, porterTokenSecretName),
-	}
-}
-
-const dockerBuildPush string = `
-export $(echo "${{secrets.%s}}" | xargs)
-echo "${{secrets.%s}}" > ./env_porter
-sudo docker build %s $(cat ./env_porter | awk 'NF' | sed 's@^@--build-arg @g' | paste -s -d " " -) --file %s -t %s:$(git rev-parse --short HEAD)
-sudo docker push %s:$(git rev-parse --short HEAD)
-`
-
-func getDockerBuildPushStep(envSecretName, dockerFilePath, repoURL string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Docker build, push",
-		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, envSecretName, envSecretName, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
-	}
-}
-
-const buildPackPush string = `
-export $(echo "${{secrets.%s}}" | xargs)
-echo "${{secrets.%s}}" > ./env_porter
-sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
-sudo apt-get update
-sudo apt-get install pack-cli
-sudo pack build %s:$(git rev-parse --short HEAD) --path %s --builder heroku/buildpacks:18 --env-file ./env_porter
-sudo docker push %s:$(git rev-parse --short HEAD)
-`
-
-func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Docker build, push",
-		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(buildPackPush, envSecretName, envSecretName, repoURL, folderPath, repoURL),
-	}
-}
-
-const deployPorter string = `
-curl -X POST "%s/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)"
-`
+const configure string = `porter update --app %s`
 
-func deployPorterWebhookStep(serverURL, webhookTokenSecretName string) GithubActionYAMLStep {
+func getConfigurePorterStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
-		Name: "Deploy on Porter",
-		ID:   "deploy_porter",
-		Run:  fmt.Sprintf(deployPorter, serverURL, webhookTokenSecretName),
+		Name:    "Update Porter App",
+		ID:      "update_porter",
+		Run:     fmt.Sprintf(configure, appName),
+		Timeout: 20,
+		Env: map[string]string{
+			"PORTER_TOKEN":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"PORTER_HOST":    serverURL,
+			"PORTER_PROJECT": fmt.Sprintf("%d", projectID),
+			"PORTER_CLUSTER": fmt.Sprintf("%d", clusterID),
+		},
 	}
 }

+ 9 - 0
internal/kubernetes/agent.go

@@ -210,6 +210,15 @@ func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, erro
 	)
 }
 
+// GetSecret retrieves the secret given its name and namespace
+func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) {
+	return a.Clientset.CoreV1().Secrets(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
 // ListConfigMaps simply lists namespaces
 func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).List(

+ 229 - 4
internal/kubernetes/prometheus/metrics.go

@@ -29,6 +29,23 @@ func GetPrometheusService(clientset kubernetes.Interface) (*v1.Service, bool, er
 	return &services.Items[0], true, nil
 }
 
+// returns the prometheus service name
+func getKubeStateMetricsService(clientset kubernetes.Interface) (*v1.Service, bool, error) {
+	services, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app.kubernetes.io/name=kube-state-metrics",
+	})
+
+	if err != nil {
+		return nil, false, err
+	}
+
+	if len(services.Items) == 0 {
+		return nil, false, nil
+	}
+
+	return &services.Items[0], true, nil
+}
+
 type SimpleIngress struct {
 	Name      string `json:"name"`
 	Namespace string `json:"namespace"`
@@ -62,7 +79,9 @@ func GetIngressesWithNGINXAnnotation(clientset kubernetes.Interface) ([]SimpleIn
 type QueryOpts struct {
 	Metric     string   `schema:"metric"`
 	ShouldSum  bool     `schema:"shouldsum"`
+	Kind       string   `schema:"kind"`
 	PodList    []string `schema:"pods"`
+	Name       string   `schema:"name"`
 	Namespace  string   `schema:"namespace"`
 	StartRange uint     `schema:"startrange"`
 	EndRange   uint     `schema:"endrange"`
@@ -78,7 +97,20 @@ func QueryPrometheus(
 		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
 	}
 
-	podSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
+	podSelectionRegex, err := getPodSelectionRegex(opts.Kind, opts.Name)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var podSelector string
+
+	if len(opts.PodList) > 0 {
+		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
+	} else {
+		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, podSelectionRegex)
+	}
+
 	query := ""
 
 	if opts.Metric == "cpu" {
@@ -86,12 +118,43 @@ func QueryPrometheus(
 	} else if opts.Metric == "memory" {
 		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
 	} else if opts.Metric == "network" {
-		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, strings.Join(opts.PodList, "|"))
+		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, podSelectionRegex)
 		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	} else if opts.Metric == "nginx:errors" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, strings.Join(opts.PodList, "|"))
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, strings.Join(opts.PodList, "|"))
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, podSelectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, podSelectionRegex)
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
+	} else if opts.Metric == "cpu_hpa_threshold" {
+		// get the name of the kube hpa metric
+		metricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
+		appLabel := ""
+
+		if found {
+			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
+		}
+
+		query = createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel)
+	} else if opts.Metric == "memory_hpa_threshold" {
+		metricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
+		appLabel := ""
+
+		if found {
+			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
+		}
+
+		query = createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel)
+	} else if opts.Metric == "hpa_replicas" {
+		metricName := getKubeHPAMetricName(clientset, service, opts, "status_current_replicas")
+		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
+		appLabel := ""
+
+		if found {
+			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
+		}
+
+		query = createHPACurrentReplicasQuery(metricName, opts.Name, opts.Namespace, appLabel)
 	}
 
 	if opts.ShouldSum {
@@ -137,6 +200,7 @@ type promRawQuery struct {
 type promParsedSingletonQueryResult struct {
 	Date     interface{} `json:"date,omitempty"`
 	CPU      interface{} `json:"cpu,omitempty"`
+	Replicas interface{} `json:"replicas,omitempty"`
 	Memory   interface{} `json:"memory,omitempty"`
 	Bytes    interface{} `json:"bytes,omitempty"`
 	ErrorPct interface{} `json:"error_pct,omitempty"`
@@ -174,6 +238,12 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 				singletonResult.Bytes = values[1]
 			} else if metric == "nginx:errors" {
 				singletonResult.ErrorPct = values[1]
+			} else if metric == "cpu_hpa_threshold" {
+				singletonResult.CPU = values[1]
+			} else if metric == "memory_hpa_threshold" {
+				singletonResult.Memory = values[1]
+			} else if metric == "hpa_replicas" {
+				singletonResult.Replicas = values[1]
 			}
 
 			singletonResults = append(singletonResults, *singletonResult)
@@ -186,3 +256,158 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 
 	return json.Marshal(res)
 }
+
+func getPodSelectionRegex(kind, name string) (string, error) {
+	var suffix string
+
+	switch strings.ToLower(kind) {
+	case "deployment":
+		suffix = "[a-z0-9]+-[a-z0-9]+"
+	case "statefulset":
+		suffix = "[0-9]+"
+	case "job":
+		suffix = "[a-z0-9]+"
+	case "cronjob":
+		suffix = "[a-z0-9]+-[a-z0-9]+"
+	default:
+		return "", fmt.Errorf("not a supported controller to query for metrics")
+	}
+
+	return fmt.Sprintf("%s-%s", name, suffix), nil
+}
+
+func createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, hpaName, namespace, appLabel string) string {
+	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
+
+	kubeMetricsHPASelector := fmt.Sprintf(
+		`hpa="%s",namespace="%s",metric_name="cpu",metric_target_type="utilization"`,
+		hpaName,
+		namespace,
+	)
+
+	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
+	// as well
+	if appLabel != "" {
+		kubeMetricsPodSelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+	}
+
+	requestCPU := fmt.Sprintf(
+		`sum by (hpa) (label_replace(kube_pod_container_resource_requests_cpu_cores{%s},"hpa", "%s", "", ""))`,
+		kubeMetricsPodSelector,
+		hpaName,
+	)
+
+	targetCPUUtilThreshold := fmt.Sprintf(
+		`%s{%s} / 100`,
+		metricName,
+		kubeMetricsHPASelector,
+	)
+
+	return fmt.Sprintf(`%s * on(hpa) %s`, requestCPU, targetCPUUtilThreshold)
+}
+
+func createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, hpaName, namespace, appLabel string) string {
+	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
+
+	kubeMetricsHPASelector := fmt.Sprintf(
+		`hpa="%s",namespace="%s",metric_name="memory",metric_target_type="utilization"`,
+		hpaName,
+		namespace,
+	)
+
+	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
+	// as well
+	if appLabel != "" {
+		kubeMetricsPodSelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+	}
+
+	requestMem := fmt.Sprintf(
+		`sum by (hpa) (label_replace(kube_pod_container_resource_requests_memory_bytes{%s},"hpa", "%s", "", ""))`,
+		kubeMetricsPodSelector,
+		hpaName,
+	)
+
+	targetMemUtilThreshold := fmt.Sprintf(
+		`%s{%s} / 100`,
+		metricName,
+		kubeMetricsHPASelector,
+	)
+
+	return fmt.Sprintf(`%s * on(hpa) %s`, requestMem, targetMemUtilThreshold)
+}
+
+func getKubeMetricsPodSelector(podSelectionRegex, namespace string) string {
+	return fmt.Sprintf(
+		`pod=~"%s",namespace="%s",container!="POD",container!=""`,
+		podSelectionRegex,
+		namespace,
+	)
+}
+
+func createHPACurrentReplicasQuery(metricName, hpaName, namespace, appLabel string) string {
+	kubeMetricsHPASelector := fmt.Sprintf(
+		`hpa="%s",namespace="%s"`,
+		hpaName,
+		namespace,
+	)
+
+	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
+	// as well
+	if appLabel != "" {
+		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+	}
+
+	return fmt.Sprintf(
+		`%s{%s}`,
+		metricName,
+		kubeMetricsHPASelector,
+	)
+}
+
+type promRawValuesQuery struct {
+	Status string   `json:"status"`
+	Data   []string `json:"data"`
+}
+
+// getKubeHPAMetricName performs a "best guess" for the name of the kube HPA metric,
+// which was renamed to kube_horizontal_pod_autoscaler... in later versions of kube-state-metrics.
+// we query Prometheus for a list of metric names to see if any match the new query
+// value, otherwise we return the deprecated name.
+func getKubeHPAMetricName(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
+	suffix string,
+) string {
+	queryParams := map[string]string{
+		"match[]": fmt.Sprintf("kube_horizontal_pod_autoscaler_%s", suffix),
+		"start":   fmt.Sprintf("%d", opts.StartRange),
+		"end":     fmt.Sprintf("%d", opts.EndRange),
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/label/__name__/values",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return fmt.Sprintf("kube_hpa_%s", suffix)
+	}
+
+	rawQueryObj := &promRawValuesQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	if rawQueryObj.Status == "success" && len(rawQueryObj.Data) == 1 {
+		return fmt.Sprintf("kube_horizontal_pod_autoscaler_%s", suffix)
+	}
+
+	return fmt.Sprintf("kube_hpa_%s", suffix)
+}

+ 0 - 2
internal/oauth/config.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
-	"fmt"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
 	"time"
@@ -148,7 +147,6 @@ func GetAccessToken(
 	}
 
 	if token.AccessToken != string(prevToken.AccessToken) {
-		fmt.Println("access happening...")
 		err := updateToken([]byte(token.AccessToken), []byte(token.RefreshToken), token.Expiry)
 
 		if err != nil {

+ 21 - 0
server/api/cluster_handler.go

@@ -2,10 +2,12 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"strconv"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/domain"
@@ -15,6 +17,7 @@ import (
 // HandleCreateProjectCluster creates a new cluster
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -54,6 +57,15 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", projID),
+			ClusterName: cluster.Name,
+			ClusterType: "EKS",
+			EventType:   "connected",
+		},
+	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -435,6 +447,15 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", projID),
+			ClusterName: cluster.Name,
+			ClusterType: "",
+			EventType:   "connected",
+		},
+	))
 
 	clusterExt := cluster.Externalize()
 

+ 1 - 0
server/api/deploy_handler.go

@@ -388,6 +388,7 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 
 				err = gaRunner.Cleanup()

+ 1 - 1
server/api/dns_record_handler.go

@@ -11,7 +11,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/domain"
 )
 
-// HandleCreateProjectCluster creates a new cluster
+// HandleCreateDNSRecord creates a new DNS record
 func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 

+ 1 - 0
server/api/git_action_handler.go

@@ -173,6 +173,7 @@ func (app *App) createGitActionFromForm(
 		ImageRepoURL:           gitAction.ImageRepoURI,
 		PorterToken:            encoded,
 		BuildEnv:               form.BuildEnv,
+		ClusterID:              release.ClusterID,
 	}
 
 	_, err = gaRunner.Setup()

+ 0 - 31
server/api/git_repo_handler.go

@@ -509,34 +509,3 @@ func (app *App) githubAppClientFromRequest(r *http.Request) (*github.Client, err
 
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
-
-// finds the github token given the git repo id and the project id
-func (app *App) githubTokenFromRequest(
-	r *http.Request,
-) (*oauth2.Token, error) {
-	grID, err := strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
-
-	if err != nil || grID == 0 {
-		return nil, fmt.Errorf("could not read git repo id")
-	}
-
-	// query for the git repo
-	gr, err := app.Repo.GitRepo.ReadGitRepo(uint(grID))
-
-	if err != nil {
-		return nil, err
-	}
-
-	// get the oauth integration
-	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(gr.OAuthIntegrationID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return &oauth2.Token{
-		AccessToken:  string(oauthInt.AccessToken),
-		RefreshToken: string(oauthInt.RefreshToken),
-		TokenType:    "Bearer",
-	}, nil
-}

+ 14 - 1
server/api/integration_handler.go

@@ -591,7 +591,20 @@ func (app *App) getGithubAppOauthTokenFromRequest(r *http.Request) (*oauth2.Toke
 		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, *app.Repo))
 
 	if err != nil {
-		return nil, err
+		// try again, in case the token got updated
+		oauthInt2, err := app.Repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		if oauthInt2.Expiry == oauthInt.Expiry {
+			return nil, err
+		} else {
+			oauthInt.AccessToken = oauthInt2.AccessToken
+			oauthInt.RefreshToken = oauthInt2.RefreshToken
+			oauthInt.Expiry = oauthInt2.Expiry
+		}
 	}
 
 	return &oauth2.Token{

+ 140 - 46
server/api/k8s_handler.go

@@ -238,7 +238,34 @@ func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleCreateConfigMap deletes the pod given the name and namespace.
+func createConfigMap(agent *kubernetes.Agent, configMap *forms.ConfigMapForm) (*v1.ConfigMap, error) {
+	secretData := make(map[string][]byte)
+
+	for key, rawValue := range configMap.SecretEnvVariables {
+		// encodedValue := base64.StdEncoding.EncodeToString([]byte(rawValue))
+
+		// if err != nil {
+		// 	app.handleErrorInternal(err, w)
+		// 	return
+		// }
+
+		secretData[key] = []byte(rawValue)
+	}
+
+	// create secret first
+	if _, err := agent.CreateLinkedSecret(configMap.Name, configMap.Namespace, configMap.Name, secretData); err != nil {
+		return nil, err
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key, _ := range configMap.SecretEnvVariables {
+		configMap.EnvVariables[key] = fmt.Sprintf("PORTERSECRET_%s", configMap.Name)
+	}
+
+	return agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+}
+
+// HandleCreateConfigMap creates a configmap (and secret) given the name, namespace and variables.
 func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	if err != nil {
@@ -271,47 +298,19 @@ func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
-	configMap := &forms.ConfigMapForm{}
+	configMapForm := &forms.ConfigMapForm{}
 
-	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+	if err := json.NewDecoder(r.Body).Decode(configMapForm); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	secretData := make(map[string][]byte)
-
-	for key, rawValue := range configMap.SecretEnvVariables {
-		// encodedValue := base64.StdEncoding.EncodeToString([]byte(rawValue))
-
-		// if err != nil {
-		// 	app.handleErrorInternal(err, w)
-		// 	return
-		// }
-
-		secretData[key] = []byte(rawValue)
-	}
-
-	// create secret first
-	_, err = agent.CreateLinkedSecret(configMap.Name, configMap.Namespace, configMap.Name, secretData)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
-	for key, _ := range configMap.SecretEnvVariables {
-		configMap.EnvVariables[key] = fmt.Sprintf("PORTERSECRET_%s", configMap.Name)
-	}
-
-	_, err = agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
-
-	if err != nil {
+	if _, err := createConfigMap(agent, configMapForm); err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
 
-	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+	if err := json.NewEncoder(w).Encode(configMapForm); err != nil {
 		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
 		return
 	}
@@ -420,7 +419,19 @@ func (app *App) HandleGetConfigMap(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
-// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func deleteConfigMap(agent *kubernetes.Agent, name string, namespace string) error {
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// HandleDeleteConfigMap deletes the configmap (and secret) given the name and namespace.
 func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	if err != nil {
@@ -453,16 +464,7 @@ func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
-	err = agent.DeleteLinkedSecret(vals["name"][0], vals["namespace"][0])
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	err = agent.DeleteConfigMap(vals["name"][0], vals["namespace"][0])
-
-	if err != nil {
+	if err := deleteConfigMap(agent, vals["name"][0], vals["namespace"][0]); err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
@@ -471,7 +473,7 @@ func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
-// HandleUpdateConfigMap deletes the pod given the name and namespace.
+// HandleUpdateConfigMap updates the configmap (and secret) given the name, namespace and variables.
 func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
@@ -559,6 +561,98 @@ func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
+// HandleRenameConfigMap renames the configmap name given the current name, namespace and new name.
+func (app *App) HandleRenameConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	renameConfigMapForm := &forms.RenameConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(renameConfigMapForm); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	configMap, err := agent.GetConfigMap(renameConfigMapForm.Name, renameConfigMapForm.Namespace)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	secret, err := agent.GetSecret(configMap.Name, configMap.Namespace)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	var decodedSecretData = make(map[string]string)
+	for k, v := range secret.Data {
+		decodedSecretData[k] = string(v)
+	}
+
+	newConfigMapForm := &forms.ConfigMapForm{
+		Name:               renameConfigMapForm.NewName,
+		Namespace:          configMap.Namespace,
+		EnvVariables:       configMap.Data,
+		SecretEnvVariables: decodedSecretData,
+	}
+
+	if newConfigMapForm.Name == configMap.Name {
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	newConfigMap, err := createConfigMap(agent, newConfigMapForm)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := deleteConfigMap(agent, configMap.Name, configMap.Namespace); err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(newConfigMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
@@ -855,7 +949,7 @@ func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleDeleteConfigMap deletes the pod given the name and namespace.
+// HandleDeleteJob deletes the job given the name and namespace.
 func (app *App) HandleDeleteJob(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	namespace := chi.URLParam(r, "namespace")

+ 63 - 0
server/api/provision_handler.go

@@ -7,6 +7,9 @@ import (
 
 	"github.com/go-chi/chi"
 
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
@@ -302,6 +305,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 // HandleProvisionAWSEKSInfra provisions a new aws EKS instance for a project
 func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -374,6 +378,15 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New aws eks infra created: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.EKSName,
+			ClusterType: "EKS",
+			EventType:   "provisioned",
+		},
+	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -389,6 +402,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -455,6 +469,15 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("AWS EKS infra marked for destruction: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.EKSName,
+			ClusterType: "EKS",
+			EventType:   "destroyed",
+		},
+	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -546,6 +569,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 // HandleProvisionGCPGKEInfra provisions a new GKE instance for a project
 func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -617,6 +641,15 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.GKEName,
+			ClusterType: "GKE",
+			EventType:   "provisioned",
+		},
+	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -632,6 +665,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -697,6 +731,15 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.GKEName,
+			ClusterType: "GKE",
+			EventType:   "destroyed",
+		},
+	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -910,6 +953,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 // HandleProvisionDODOKSInfra provisions a new DO DOKS instance for a project
 func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -983,6 +1027,15 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.DOKSName,
+			ClusterType: "DOKS",
+			EventType:   "provisioned",
+		},
+	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -998,6 +1051,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -1065,6 +1119,15 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
+	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
+		&analytics.NewClusterEventOpts{
+			UserId:      fmt.Sprintf("%d", userID),
+			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
+			ClusterName: form.DOKSName,
+			ClusterType: "DOKS",
+			EventType:   "destroyed",
+		},
+	))
 
 	w.WriteHeader(http.StatusOK)
 }

+ 2 - 0
server/api/release_handler.go

@@ -1057,6 +1057,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 
 				err = gaRunner.CreateEnvSecret()
@@ -1444,6 +1445,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 
 				err = gaRunner.CreateEnvSecret()

+ 14 - 0
server/router/router.go

@@ -1543,6 +1543,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/k8s/configmap/rename",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleRenameConfigMap, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"DELETE",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}",