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

Merge pull request #1001 from porter-dev/staging

Release 0.7.0 -> production
abelanger5 4 лет назад
Родитель
Сommit
0ba747fc31
100 измененных файлов с 6259 добавлено и 4547 удалено
  1. 2 0
      .gitignore
  2. 10 5
      cli/cmd/api/api.go
  3. 59 52
      cli/cmd/auth.go
  4. 19 3
      cli/cmd/deploy/deploy.go
  5. 15 3
      cli/cmd/login/server.go
  6. 106 0
      cli/cmd/logs.go
  7. 14 0
      cli/cmd/project.go
  8. 204 3
      cli/cmd/run.go
  9. 10 1
      cli/cmd/utils/browser.go
  10. 29 0
      cli/cmd/utils/wsl.go
  11. 1 32
      cmd/app/main.go
  12. 4 35
      cmd/migrate/main.go
  13. 5 0
      dashboard/package-lock.json
  14. BIN
      dashboard/src/assets/back_arrow.png
  15. BIN
      dashboard/src/assets/node.png
  16. 1 1
      dashboard/src/components/ExpandableResource.tsx
  17. 1 1
      dashboard/src/components/PageNotFound.tsx
  18. 1 5
      dashboard/src/components/ResourceTab.tsx
  19. 28 6
      dashboard/src/components/SaveButton.tsx
  20. 28 40
      dashboard/src/components/TabRegion.tsx
  21. 2 2
      dashboard/src/components/Table.tsx
  22. 96 0
      dashboard/src/components/TitleSection.tsx
  23. 4 3
      dashboard/src/components/YamlEditor.tsx
  24. 0 0
      dashboard/src/components/form-components/CheckboxList.tsx
  25. 0 0
      dashboard/src/components/form-components/CheckboxRow.tsx
  26. 0 0
      dashboard/src/components/form-components/Heading.tsx
  27. 0 0
      dashboard/src/components/form-components/Helper.tsx
  28. 0 0
      dashboard/src/components/form-components/InputRow.tsx
  29. 2 2
      dashboard/src/components/form-components/KeyValueArray.tsx
  30. 0 0
      dashboard/src/components/form-components/SelectRow.tsx
  31. 0 0
      dashboard/src/components/form-components/TextArea.tsx
  32. 0 0
      dashboard/src/components/form-components/UploadArea.tsx
  33. 537 0
      dashboard/src/components/porter-form/FormDebugger.tsx
  34. 233 0
      dashboard/src/components/porter-form/PorterForm.tsx
  35. 442 0
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  36. 96 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  37. 187 0
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  38. 81 0
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  39. 111 0
      dashboard/src/components/porter-form/field-components/Input.tsx
  40. 519 0
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  41. 0 0
      dashboard/src/components/porter-form/field-components/MultiSelect.tsx
  42. 32 0
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  43. 101 0
      dashboard/src/components/porter-form/field-components/Select.tsx
  44. 23 0
      dashboard/src/components/porter-form/field-components/ServiceIPList.tsx
  45. 0 0
      dashboard/src/components/porter-form/field-components/ServiceRow.tsx
  46. 3 3
      dashboard/src/components/porter-form/field-components/VeleroForm.tsx
  47. 85 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  48. 248 0
      dashboard/src/components/porter-form/types.ts
  49. 1 1
      dashboard/src/components/repo-selector/ActionDetails.tsx
  50. 1 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  51. 1 1
      dashboard/src/components/repo-selector/NewGHAction.tsx
  52. 1 1
      dashboard/src/components/repo-selector/RepoList.tsx
  53. 0 99
      dashboard/src/components/values-form/Base64InputRow.tsx
  54. 0 323
      dashboard/src/components/values-form/FormDebugger.tsx
  55. 0 509
      dashboard/src/components/values-form/FormWrapper.tsx
  56. 0 166
      dashboard/src/components/values-form/InputArray.tsx
  57. 0 69
      dashboard/src/components/values-form/RangeSlider.tsx
  58. 0 412
      dashboard/src/components/values-form/ValuesForm.tsx
  59. 4 5
      dashboard/src/index.html
  60. 4 4
      dashboard/src/main/auth/VerifyEmail.tsx
  61. 18 6
      dashboard/src/main/home/Home.tsx
  62. 11 59
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  63. 9 38
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  64. 4 4
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  65. 282 282
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  66. 11 5
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  67. 3 35
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  68. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  69. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  70. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  71. 64 120
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx
  72. 17 8
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx
  73. 4 4
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  74. 4 4
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  75. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  76. 273 219
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  77. 303 223
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  78. 7 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  79. 150 215
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  80. 17 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  81. 20 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  82. 35 19
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  83. 5 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  84. 20 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  85. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  86. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  87. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  88. 335 246
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  89. 96 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts
  90. 386 470
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  91. 65 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts
  92. 327 405
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  93. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  94. 221 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx
  95. 91 86
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  96. 5 42
      dashboard/src/main/home/dashboard/ClusterList.tsx
  97. 10 36
      dashboard/src/main/home/dashboard/Dashboard.tsx
  98. 96 197
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  99. 3 3
      dashboard/src/main/home/integrations/IntegrationList.tsx
  100. 2 2
      dashboard/src/main/home/integrations/IntegrationRow.tsx

+ 2 - 0
.gitignore

@@ -11,6 +11,8 @@ internal/local_templates
 gon*.hcl
 *prod.Dockerfile
 staging.sh
+*.crt
+*.key
 
 # Local .terraform directories
 **/.terraform/*

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

+ 15 - 3
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]
+		}
 	}
 }
 
@@ -49,7 +55,13 @@ func Login(
 	}()
 
 	// open browser for host login
-	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	var redirectHost string
+	if utils.CheckIfWsl() {
+		redirectHost = fmt.Sprintf("http://%s:%d", utils.GetWslHostName(), port)
+	} else {
+		redirectHost = fmt.Sprintf("http://localhost:%d", port)
+	}
+
 	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
 
 	err = utils.OpenBrowser(loginURL)

+ 106 - 0
cli/cmd/logs.go

@@ -0,0 +1,106 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+// logsCmd represents the "porter logs" base command when called
+// without any subcommands
+var logsCmd = &cobra.Command{
+	Use:   "logs [release]",
+	Short: "Logs the output from a given application.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logs)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var follow bool
+
+func init() {
+	rootCmd.AddCommand(logsCmd)
+
+	logsCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of release to connect to",
+	)
+
+	logsCmd.PersistentFlags().BoolVarP(
+		&follow,
+		"follow",
+		"f",
+		false,
+		"specify if the logs should be streamed",
+	)
+}
+
+func logs(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	podsSimple, err := getPods(client, namespace, args[0])
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
+	}
+
+	// if length of pods is 0, throw error
+	var selectedPod podSimple
+
+	if len(podsSimple) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
+	} else {
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
+
+		if err != nil {
+			return err
+		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
+	}
+
+	restConf, err := getRESTConfig(client)
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	return pipePodLogsToStdout(restConf, namespace, selectedPod.Name, selectedContainerName, follow)
+}

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

+ 204 - 3
cli/cmd/run.go

@@ -3,19 +3,25 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubectl/pkg/util/term"
+
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
-	"k8s.io/kubectl/pkg/util/term"
 )
 
 var namespace string
@@ -35,6 +41,8 @@ var runCmd = &cobra.Command{
 	},
 }
 
+var existingPod bool
+
 func init() {
 	rootCmd.AddCommand(runCmd)
 
@@ -44,6 +52,14 @@ func init() {
 		"default",
 		"namespace of release to connect to",
 	)
+
+	runCmd.PersistentFlags().BoolVarP(
+		&existingPod,
+		"existing_pod",
+		"e",
+		false,
+		"whether to connect to an existing pod",
+	)
 }
 
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
@@ -106,7 +122,11 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 
-	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	if existingPod {
+		return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	}
+
+	return executeRunEphemeral(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -189,7 +209,6 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 		Namespace(namespace).
 		SubResource("exec")
 
-	// req.Param("container", "web")
 	for _, arg := range args {
 		req.Param("command", arg)
 	}
@@ -225,3 +244,185 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 
 	return err
 }
+
+func executeRunEphemeral(config *rest.Config, namespace, name, container string, args []string) error {
+	existing, err := getExistingPod(config, name, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	newPod, err := createPodFromExisting(config, existing, args)
+
+	if err != nil {
+		return err
+	}
+
+	podName := newPod.ObjectMeta.Name
+
+	t := term.TTY{
+		In:  os.Stdin,
+		Out: os.Stdout,
+		Raw: true,
+	}
+
+	fn := func() error {
+		restClient, err := rest.RESTClientFor(config)
+
+		if err != nil {
+			return err
+		}
+
+		req := restClient.Post().
+			Resource("pods").
+			Name(podName).
+			Namespace("default").
+			SubResource("attach")
+
+		req.Param("stdin", "true")
+		req.Param("stdout", "true")
+		req.Param("tty", "true")
+		req.Param("container", container)
+
+		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+
+		if err != nil {
+			return err
+		}
+
+		return exec.Stream(remotecommand.StreamOptions{
+			Stdin:  os.Stdin,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+			Tty:    true,
+		})
+	}
+
+	color.New(color.FgYellow).Println("Attempting connection to the container, this may take up to 10 seconds. If you don't see a command prompt, try pressing enter.")
+
+	for i := 0; i < 5; i++ {
+		err = t.Safe(fn)
+
+		if err == nil {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+
+		// ugly way to catch non-TTY errors, such as when running command "echo \"hello\""
+		if i == 4 && err != nil && strings.Contains(err.Error(), "not found in pod") {
+			fmt.Printf("Could not open a shell to this container. Container logs:\n")
+
+			err = pipePodLogsToStdout(config, namespace, podName, container, false)
+		}
+	}
+
+	// delete the ephemeral pod
+	deletePod(config, podName, namespace)
+
+	return err
+}
+
+func pipePodLogsToStdout(config *rest.Config, namespace, name, container string, follow bool) error {
+	podLogOpts := v1.PodLogOptions{
+		Container: container,
+		Follow:    follow,
+	}
+
+	// creates the clientset
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	req := clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(
+		context.Background(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defer podLogs.Close()
+
+	_, err = io.Copy(os.Stdout, podLogs)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func getExistingPod(config *rest.Config, name, namespace string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+func deletePod(config *rest.Config, name, namespace string) error {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Delete(
+		context.Background(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+func createPodFromExisting(config *rest.Config, existing *v1.Pod, args []string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	newPod := existing.DeepCopy()
+
+	// only copy the pod spec, overwrite metadata
+	newPod.ObjectMeta = metav1.ObjectMeta{
+		Name:      strings.ToLower(fmt.Sprintf("%s-copy-%s", existing.ObjectMeta.Name, utils.String(4))),
+		Namespace: existing.ObjectMeta.Namespace,
+	}
+
+	newPod.Status = v1.PodStatus{}
+
+	// set restart policy to never
+	newPod.Spec.RestartPolicy = v1.RestartPolicyNever
+
+	// change the command in the pod to the passed in pod command
+	cmdRoot := args[0]
+	cmdArgs := make([]string, 0)
+
+	if len(args) > 1 {
+		cmdArgs = args[1:]
+	}
+
+	newPod.Spec.Containers[0].Command = []string{cmdRoot}
+	newPod.Spec.Containers[0].Args = cmdArgs
+	newPod.Spec.Containers[0].TTY = true
+	newPod.Spec.Containers[0].Stdin = true
+	newPod.Spec.Containers[0].StdinOnce = true
+
+	// create the pod and return it
+	return clientset.CoreV1().Pods(existing.ObjectMeta.Namespace).Create(
+		context.Background(),
+		newPod,
+		metav1.CreateOptions{},
+	)
+}

+ 10 - 1
cli/cmd/utils/browser.go

@@ -1,6 +1,7 @@
 package utils
 
 import (
+	"fmt"
 	"os/exec"
 	"runtime"
 )
@@ -10,6 +11,8 @@ func OpenBrowser(url string) error {
 	var cmd string
 	var args []string
 
+	fmt.Printf("Attempting to open your browser. If this does not work, please navigate to: %s", url)
+
 	switch runtime.GOOS {
 	case "windows":
 		cmd = "cmd"
@@ -17,8 +20,14 @@ func OpenBrowser(url string) error {
 	case "darwin":
 		cmd = "open"
 	default: // "linux", "freebsd", "openbsd", "netbsd"
-		cmd = "xdg-open"
+		if CheckIfWsl() {
+			cmd = "cmd.exe"
+			args = []string{"/c", "start"}
+		} else {
+			cmd = "xdg-open"
+		}
 	}
+
 	args = append(args, url)
 	return exec.Command(cmd, args...).Start()
 }

+ 29 - 0
cli/cmd/utils/wsl.go

@@ -0,0 +1,29 @@
+package utils
+
+import (
+	"os/exec"
+	"regexp"
+	"strings"
+)
+
+// Checks based on uname if the linux environment is under wsl or not
+func CheckIfWsl() bool {
+	out, err := exec.Command("uname", "-a").Output()
+	if err != nil {
+		return false
+	}
+	// On some cases, uname on wsl outputs microsoft capitalized
+	matched, _ := regexp.Match(`microsoft|Microsoft`, out)
+	return matched
+}
+
+// Gets the subsystem host ip
+// If the CLI is running under WSL the localhost url will not work so
+// this function should return the real ip that we should redirect to
+func GetWslHostName() string {
+	out, err := exec.Command("wsl.exe", "hostname", "-I").Output()
+	if err != nil {
+		return "localhost"
+	}
+	return strings.TrimSpace(string(out))
+}

+ 1 - 32
cmd/app/main.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
@@ -18,7 +17,6 @@ import (
 	"github.com/porter-dev/porter/server/router"
 
 	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 // Version will be linked by an ldflag during build
@@ -45,36 +43,7 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-		&ints.GithubAppInstallation{},
-		&ints.GithubAppOAuthIntegration{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 4 - 35
cmd/migrate/main.go

@@ -9,9 +9,7 @@ import (
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
-	"github.com/porter-dev/porter/internal/models"
-
-	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/joeshaw/envdecode"
 )
@@ -29,40 +27,11 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Release{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-		&ints.GithubAppInstallation{},
-		&ints.GithubAppOAuthIntegration{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
-		panic(err)
+		logger.Fatal().Err(err).Msg("")
+		return
 	}
 
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {

+ 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


BIN
dashboard/src/assets/node.png


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

@@ -73,7 +73,7 @@ const Status = styled.div`
 `;
 
 const StatusSection = styled.div`
-  border-radius: 5px;
+  border-radius: 8px;
   background: #ffffff11;
   font-size: 13px;
   padding: 20px 20px 25px;

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

@@ -147,7 +147,7 @@ const StyledPageNotFound = styled.div`
   color: #6f6f6f;
   font-size: 16px;
   user-select: none;
-  padding-bottom: 20px;
+  margin-top: -80px;
   width: 100%;
   height: 100%;
   display: flex;

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

@@ -146,11 +146,7 @@ const StyledResourceTab = styled.div`
   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`

+ 28 - 6
dashboard/src/components/SaveButton.tsx

@@ -3,11 +3,12 @@ import styled from "styled-components";
 import loading from "assets/loading.gif";
 
 type PropsType = {
-  text: string;
+  text?: string;
   onClick: () => void;
   disabled?: boolean;
   status?: string | null;
   color?: string;
+  rounded?: boolean;
   helper?: string | null;
 
   // Makes flush with corner if not within a modal
@@ -78,11 +79,12 @@ export default class SaveButton extends Component<PropsType, StateType> {
           <div>{this.renderStatus()}</div>
         )}
         <Button
+          rounded={this.props.rounded}
           disabled={this.props.disabled}
           onClick={this.props.onClick}
           color={this.props.color || "#616FEEcc"}
         >
-          {this.props.text}
+          {this.props.children || this.props.text}
         </Button>
         {this.props.statusPosition === "right" && (
           <div>{this.renderStatus()}</div>
@@ -108,6 +110,8 @@ const StatusTextWrapper = styled.p`
   margin: 0;
 `;
 
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
 const StatusWrapper = styled.div<{
   successful: boolean;
   position: "right" | "left";
@@ -134,7 +138,6 @@ const StatusWrapper = styled.div<{
     color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
-  animation: statusFloatIn 0.5s;
   animation-fill-mode: forwards;
 
   @keyframes statusFloatIn {
@@ -180,17 +183,22 @@ const ButtonWrapper = styled.div`
   }}
 `;
 
-const Button = styled.button`
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
   height: 35px;
   font-size: 13px;
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: white;
-  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
   padding: 6px 20px 7px 20px;
   text-align: left;
   border: 0;
-  border-radius: 5px;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
   box-shadow: ${(props) =>
     !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
@@ -202,4 +210,18 @@ const Button = styled.button`
   :hover {
     filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    margin-left: -5px;
+    justify-content: center;
+  }
 `;

+ 28 - 40
dashboard/src/components/TabRegion.tsx

@@ -4,13 +4,19 @@ import styled from "styled-components";
 import TabSelector from "./TabSelector";
 import Loading from "./Loading";
 
+export interface TabOption {
+  label: string;
+  value: string;
+}
+
 type PropsType = {
-  options: { label: string; value: string }[];
+  options: TabOption[];
   currentTab: string;
   setCurrentTab: (x: string) => void;
   defaultTab?: string;
   addendum?: any;
   color?: string | null;
+  suppressAnimation?: boolean;
 };
 
 type StateType = {};
@@ -33,49 +39,29 @@ export default class TabRegion extends Component<PropsType, StateType> {
     }
   }
 
-  renderContents = () => {
-    if (!this.props.currentTab) {
-      return <Loading />;
-    }
-
+  render() {
     return (
-      <Div>
-        <TabSelector
-          options={this.props.options}
-          color={this.props.color}
-          currentTab={this.props.currentTab}
-          setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
-          addendum={this.props.addendum}
-        />
-        <Gap />
-        <TabContents>{this.props.children}</TabContents>
-      </Div>
+      <StyledTabRegion suppressAnimation={this.props.suppressAnimation}>
+        {!this.props.currentTab ? (
+          <Loading />
+        ) : (
+          <>
+            <TabSelector
+              options={this.props.options}
+              color={this.props.color}
+              currentTab={this.props.currentTab}
+              setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
+              addendum={this.props.addendum}
+            />
+            <Gap />
+            <TabContents>{this.props.children}</TabContents>
+          </>
+        )}
+      </StyledTabRegion>
     );
-  };
-
-  render() {
-    return <StyledTabRegion>{this.renderContents()}</StyledTabRegion>;
   }
 }
 
-const Placeholder = styled.div`
-  width: 100%;
-  height: 200px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #ffffff11;
-  border-radius: 5px;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
-const Div = styled.div`
-  width: 100%;
-  height: 100%;
-  animation: fadeIn 0.25s 0s;
-`;
-
 const TabContents = styled.div`
   height: calc(100% - 65px);
 `;
@@ -86,9 +72,11 @@ const Gap = styled.div`
   height: 30px;
 `;
 
-const StyledTabRegion = styled.div`
+const StyledTabRegion = styled.div<{ suppressAnimation: boolean }>`
   width: 100%;
   height: 100%;
+  animation: ${(props) => (props.suppressAnimation ? "" : "fadeIn 0.25s 0s")};
   position: relative;
   overflow-y: auto;
+  overflow: visible;
 `;

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

@@ -1,7 +1,7 @@
 import React from "react";
 import styled from "styled-components";
 import { Column, Row, useGlobalFilter, useTable } from "react-table";
-import InputRow from "./values-form/InputRow";
+import InputRow from "./form-components/InputRow";
 import Loading from "components/Loading";
 
 const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
@@ -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`

+ 0 - 0
dashboard/src/components/values-form/CheckboxList.tsx → dashboard/src/components/form-components/CheckboxList.tsx


+ 0 - 0
dashboard/src/components/values-form/CheckboxRow.tsx → dashboard/src/components/form-components/CheckboxRow.tsx


+ 0 - 0
dashboard/src/components/values-form/Heading.tsx → dashboard/src/components/form-components/Heading.tsx


+ 0 - 0
dashboard/src/components/values-form/Helper.tsx → dashboard/src/components/form-components/Helper.tsx


+ 0 - 0
dashboard/src/components/values-form/InputRow.tsx → dashboard/src/components/form-components/InputRow.tsx


+ 2 - 2
dashboard/src/components/values-form/KeyValueArray.tsx → dashboard/src/components/form-components/KeyValueArray.tsx

@@ -75,7 +75,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
   };
 
   objectToValues = (obj: Record<string, string>): KeyValue[] => {
-    return Object.entries(obj).map(([key, value]) => ({ key, value }));
+    return Object.entries(obj)?.map(([key, value]) => ({ key, value }));
   };
 
   renderDeleteButton = (i: number) => {
@@ -109,7 +109,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
   renderInputList = () => {
     return (
       <>
-        {this.state.values.map((entry: any, i: number) => {
+        {this.state.values?.map((entry: any, i: number) => {
           // Preprocess non-string env values set via raw Helm values
           let { value } = entry;
           if (typeof value === "object") {

+ 0 - 0
dashboard/src/components/values-form/SelectRow.tsx → dashboard/src/components/form-components/SelectRow.tsx


+ 0 - 0
dashboard/src/components/values-form/TextArea.tsx → dashboard/src/components/form-components/TextArea.tsx


+ 0 - 0
dashboard/src/components/values-form/UploadArea.tsx → dashboard/src/components/form-components/UploadArea.tsx


+ 537 - 0
dashboard/src/components/porter-form/FormDebugger.tsx

@@ -0,0 +1,537 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import AceEditor from "react-ace";
+import PorterFormWrapper from "./PorterFormWrapper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import InputRow from "components/form-components/InputRow";
+import yaml from "js-yaml";
+
+import "shared/ace-porter-theme";
+import "ace-builds/src-noconflict/mode-text";
+
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+
+type PropsType = {
+  goBack: () => void;
+};
+
+type StateType = {
+  rawYaml: string;
+  showBonusTabs: boolean;
+  showStateDebugger: boolean;
+  valuesToOverride: any;
+  checkbox_a: boolean;
+  input_a: string;
+  isReadOnly: boolean;
+};
+
+const tabOptions = [
+  { value: "a", label: "Bonus Tab A" },
+  { value: "b", label: "Bonus Tab B" },
+];
+
+export default class FormDebugger extends Component<PropsType, StateType> {
+  state = {
+    rawYaml: initYaml,
+    showBonusTabs: false,
+    showStateDebugger: true,
+    valuesToOverride: {
+      checkbox_a: {
+        value: true,
+      },
+    } as any,
+    checkbox_a: true,
+    input_a: "",
+    isReadOnly: false,
+  };
+
+  renderTabContents = (currentTab: string) => {
+    return (
+      <TabWrapper>
+        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
+      </TabWrapper>
+    );
+  };
+
+  aceEditorRef = React.createRef<AceEditor>();
+  render() {
+    let formData = {};
+    try {
+      formData = yaml.load(this.state.rawYaml);
+    } catch (err: any) {
+      console.log("YAML parsing error.");
+    }
+    return (
+      <StyledFormDebugger>
+        <Button onClick={this.props.goBack}>
+          <i className="material-icons">keyboard_backspace</i>
+          Back
+        </Button>
+        <Heading isAtTop={true}>✨ Form.yaml Editor</Heading>
+        <Helper>Write and test form.yaml free of consequence.</Helper>
+
+        <EditorWrapper>
+          <AceEditor
+            ref={this.aceEditorRef}
+            mode="yaml"
+            value={this.state.rawYaml}
+            theme="porter"
+            onChange={(e: string) => this.setState({ rawYaml: e })}
+            name="codeEditor"
+            editorProps={{ $blockScrolling: true }}
+            height="450px"
+            width="100%"
+            style={{
+              borderRadius: "5px",
+              border: "1px solid #ffffff22",
+              marginTop: "27px",
+              marginBottom: "27px",
+            }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+          />
+        </EditorWrapper>
+
+        <CheckboxRow
+          label="Show form state debugger"
+          checked={this.state.showStateDebugger}
+          toggle={() =>
+            this.setState({ showStateDebugger: !this.state.showStateDebugger })
+          }
+        />
+        <CheckboxRow
+          label="Read-only"
+          checked={this.state.isReadOnly}
+          toggle={() =>
+            this.setState({
+              isReadOnly: !this.state.isReadOnly,
+            })
+          }
+        />
+        <CheckboxRow
+          label="Include non-form dummy tabs"
+          checked={this.state.showBonusTabs}
+          toggle={() =>
+            this.setState({ showBonusTabs: !this.state.showBonusTabs })
+          }
+        />
+        <CheckboxRow
+          label="checkbox_a"
+          checked={this.state.checkbox_a}
+          toggle={() =>
+            this.setState({
+              checkbox_a: !this.state.checkbox_a,
+
+              // Override the form value for checkbox_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                checkbox_a: {
+                  value: !this.state.checkbox_a,
+                },
+              },
+            })
+          }
+        />
+        <InputRow
+          type="string"
+          value={this.state.input_a}
+          setValue={(x: string) =>
+            this.setState({
+              input_a: x,
+
+              // Override the form value for input_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                input_a: {
+                  value: x,
+                },
+              },
+            })
+          }
+          label={"input_a"}
+          placeholder="ex: override text"
+        />
+
+        <Heading>🎨 Rendered Form</Heading>
+        <Br />
+        <PorterFormWrapper
+          showStateDebugger={this.state.showStateDebugger}
+          formData={formData}
+          valuesToOverride={{
+            input_a: this.state.valuesToOverride?.input_a?.value,
+          }}
+          isReadOnly={this.state.isReadOnly}
+          onSubmit={(vars) => {
+            alert("check console output");
+            console.log(vars);
+          }}
+          rightTabOptions={this.state.showBonusTabs ? tabOptions : []}
+          renderTabContents={this.renderTabContents}
+          saveButtonText={"Test Submit"}
+        />
+      </StyledFormDebugger>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 12px;
+`;
+
+const TabWrapper = styled.div`
+  background: #ffffff11;
+  height: 200px;
+  width: 100%;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  overflow: auto;
+  padding: 50px;
+`;
+
+const EditorWrapper = styled.div`
+  .ace_editor,
+  .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const StyledFormDebugger = styled.div`
+  position: relative;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  width: 85px;
+  float: right;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const initYaml = `name: Web
+hasSource: true
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Main
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: Container Settings
+    - type: variable
+      variable: showStartCommand
+      settings:
+        default: true
+  - name: command
+    show_if: showStartCommand
+    contents:
+    - type: subtitle
+      name: command_description
+      label: (Optional) Set a start command for this service.
+    - type: string-input
+      label: Start Command
+      placeholder: "ex: sh ./script.sh"
+      variable: container.command
+  - name: section_one_cont
+    contents:
+    - type: subtitle
+      label: Specify the port your application is running on.
+    - type: number-input
+      variable: container.port
+      label: Container Port
+      placeholder: "ex: 80"
+      settings:
+        default: 80
+    - type: heading
+      label: Deploy Webhook
+    - type: checkbox
+      variable: auto_deploy
+      label: Auto-deploy when webhook is called.
+      settings:
+        default: true
+  - name: network
+    contents:
+    - type: heading
+      label: Network Settings
+    - type: subtitle
+      label: For containers that you do not want to expose to external traffic (e.g. databases and add-ons), you may make them accessible only to other internal services running within the same cluster. 
+    - type: checkbox
+      variable: ingress.enabled
+      label: Expose to external traffic
+      settings:
+        default: true
+  - name: domain_toggle
+    show_if: ingress.enabled
+    contents:
+    - type: subtitle
+      label: Assign custom domain to your deployment. You must first create an A record in your domain provider that points to your cluster load balancer's IP address for this.
+    - type: checkbox
+      variable: ingress.custom_domain
+      label: Configure Custom Domain
+      settings:
+        default: false 
+  - name: domain_name
+    show_if: ingress.custom_domain
+    contents:
+    - type: array-input
+      variable: ingress.hosts
+      label: Domain Name
+  - name: do_wildcard
+    show_if: 
+      and:
+      - ingress.custom_domain
+      - currentCluster.service.is_do
+    contents:
+    - type: subtitle
+      label: If you're hosting on Digital Ocean and have enabled the wildcard domains from the 'HTTPS Issuer', you can use a wildcard certificate.
+    - type: checkbox
+      variable: ingress.wildcard
+      label: Use Wildcard Certificate
+- name: resources
+  label: Resources
+  sections:
+  - name: main_section
+    contents:
+    - type: heading
+      label: Resources
+    - type: subtitle
+      label: Configure resources assigned to this container.
+    - type: number-input
+      label: RAM
+      variable: resources.requests.memory
+      placeholder: "ex: 256"
+      settings:
+        unit: Mi
+        default: 256
+    - type: number-input
+      label: CPU
+      variable: resources.requests.cpu
+      placeholder: "ex: 100"
+      settings:
+        unit: m
+        default: 100
+    - type: number-input
+      label: Replicas
+      variable: replicaCount
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: checkbox
+      variable: autoscaling.enabled
+      label: Enable autoscaling
+      settings:
+        default: false
+  - name: autoscaler
+    show_if: autoscaling.enabled
+    contents:
+    - type: number-input
+      label: Minimum Replicas
+      variable: autoscaling.minReplicas
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: number-input
+      label: Maximum Replicas
+      variable: autoscaling.maxReplicas
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+    - type: number-input
+      label: Target CPU Utilization
+      variable: autoscaling.targetCPUUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+    - type: number-input
+      label: Target RAM Utilization
+      variable: autoscaling.targetMemoryUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+- name: env
+  label: Environment
+  sections:
+  - name: env_vars
+    contents:
+    - type: heading
+      label: Environment Variables
+    - type: subtitle
+      label: Set environment variables for your secrets and environment-specific configuration.
+    - type: env-key-value-array
+      label: 
+      variable: container.env.normal
+- name: advanced
+  label: Advanced
+  sections:
+  - name: ingress_annotations
+    contents:
+    - type: heading
+      label: Ingress Custom Annotations
+    - type: subtitle
+      label: Assign custom annotations to Ingress. These annotations will overwrite the annotations Porter assigns by default.
+    - type: key-value-array
+      variable: ingress.annotations
+      settings:
+        default: {}
+  - name: health_check
+    contents:
+    - type: heading
+      label: Custom Health Checks
+    - type: subtitle
+      label: Define custom health check endpoints to ensure zero down-time deployments.
+    - type: checkbox
+      variable: health.enabled
+      label: Enable Custom Health Checks
+      settings:
+        default: false
+  - name: health_check_endpoint
+    show_if: health.enabled
+    contents:
+    - type: string-input
+      label: Health Check Endpoint
+      variable: health.path
+      placeholder: "ex: /healthz"
+      settings:
+        default: /healthz
+    - type: heading
+      label: Custom Health Check Rules
+    - type: subtitle
+      label: Configure how many times a health check will be performed before deeming the container as failed. 
+    - type: number-input
+      label: Failure Threshold
+      variable: health.failureThreshold
+      placeholder: "ex: 3"
+    - type: subtitle
+      label: Configure the interval at which health check is repeated in the case of failure.
+    - type: number-input
+      label: Repeat Interval
+      variable: health.periodSeconds
+      placeholder: "ex: 30"
+  - name: persistence_toggle
+    contents:
+    - type: heading
+      label: Persistent Disks
+    - type: subtitle
+      label: Attach persistent disks to your deployment to retain data across releases.
+    - type: checkbox
+      label: Enable Persistence
+      variable: pvc.enabled
+  - name: persistent_storage
+    show_if: pvc.enabled
+    contents:
+    - type: number-input
+      label: Persistent Storage
+      variable: pvc.storage
+      placeholder: "ex: 20"
+      settings:
+        unit: Gi
+        default: 20
+    - type: string-input
+      label: Mount Path
+      variable: pvc.mountPath
+      placeholder: "ex: /mypath"
+      settings:
+        default: /mypath
+  - name: termination_grace_period
+    contents:
+    - type: heading
+      label: Termination Grace Period
+    - type: subtitle
+      label: Specify how much time app processes have to gracefully shut down on SIGTERM.
+    - type: number-input
+      label: Termination Grace Period (seconds)
+      variable: terminationGracePeriodSeconds
+      placeholder: "ex: 30"
+      settings:
+        default: 30
+  - name: container_hooks
+    contents:
+    - type: heading
+      label: Container hooks
+    - type: subtitle
+      label: (Optional) Set post start or pre stop commands for this service.
+    - type: string-input
+      label: Post start command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.postStart
+    - type: string-input
+      label: Pre stop command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.preStop
+  - name: cloud_sql_toggle
+    show_if: currentCluster.service.is_gcp
+    contents:
+    - type: heading
+      label: Google Cloud SQL
+    - type: subtitle
+      label: Securely connect to Google Cloud SQL (GKE only).
+    - type: checkbox
+      variable: cloudsql.enabled
+      label: Enable Google Cloud SQL Proxy
+      settings:
+        default: false
+  - name: cloud_sql_contents
+    show_if: cloudsql.enabled
+    contents:
+    - type: string-input
+      label: Instance Connection Name
+      variable: cloudsql.connectionName
+      placeholder: "ex: project-123:us-east1:pachyderm"
+    - type: number-input
+      label: DB Port
+      variable: cloudsql.dbPort
+      placeholder: "ex: 5432"
+      settings:
+        default: 5432
+    - type: string-input
+      label: Service Account JSON
+      variable: cloudsql.serviceAccountJSON
+      placeholder: "ex: { <SERVICE_ACCOUNT_JSON> }"`;

+ 233 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -0,0 +1,233 @@
+import React, { useContext, useState } from "react";
+import {
+  Section,
+  FormField,
+  InputField,
+  CheckboxField,
+  KeyValueArrayField,
+  ArrayInputField,
+  SelectField,
+  ServiceIPListField,
+  ResourceListField,
+} from "./types";
+import TabRegion, { TabOption } from "../TabRegion";
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+import Input from "./field-components/Input";
+import { PorterFormContext } from "./PorterFormContextProvider";
+import Checkbox from "./field-components/Checkbox";
+import KeyValueArray from "./field-components/KeyValueArray";
+import styled from "styled-components";
+import SaveButton from "../SaveButton";
+import ArrayInput from "./field-components/ArrayInput";
+import Select from "./field-components/Select";
+import ServiceIPList from "./field-components/ServiceIPList";
+import ResourceList from "./field-components/ResourceList";
+import VeleroForm from "./field-components/VeleroForm";
+
+interface Props {
+  leftTabOptions?: TabOption[];
+  rightTabOptions?: TabOption[];
+  renderTabContents?: (
+    currentTab: string,
+    submitValues?: any
+  ) => React.ReactElement;
+  saveButtonText?: string;
+  isReadOnly?: boolean;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+  currentTab: string;
+  setCurrentTab: (nt: string) => void;
+  isLaunch?: boolean;
+}
+
+const PorterForm: React.FC<Props> = (props) => {
+  const {
+    formData,
+    isReadOnly,
+    validationInfo,
+    onSubmit,
+    formState,
+  } = useContext(PorterFormContext);
+
+  const { currentTab, setCurrentTab } = props;
+
+  const renderSectionField = (field: FormField): JSX.Element => {
+    const bundledProps = {
+      ...field,
+      isReadOnly,
+    };
+    switch (field.type) {
+      case "heading":
+        return <Heading>{field.label}</Heading>;
+      case "subtitle":
+        return <Helper>{field.label}</Helper>;
+      case "input":
+        return <Input {...(bundledProps as InputField)} />;
+      case "checkbox":
+        return <Checkbox {...(bundledProps as CheckboxField)} />;
+      case "key-value-array":
+        return <KeyValueArray {...(bundledProps as KeyValueArrayField)} />;
+      case "array-input":
+        return <ArrayInput {...(bundledProps as ArrayInputField)} />;
+      case "select":
+        return <Select {...(bundledProps as SelectField)} />;
+      case "service-ip-list":
+        return <ServiceIPList {...(bundledProps as ServiceIPListField)} />;
+      case "resource-list":
+        return <ResourceList {...(bundledProps as ResourceListField)} />;
+      case "velero-create-backup":
+        return <VeleroForm />;
+    }
+    return <p>Not Implemented: {(field as any).type}</p>;
+  };
+
+  const renderSection = (section: Section): JSX.Element => {
+    return (
+      <>
+        {section.contents?.map((field, i) => {
+          return (
+            <React.Fragment key={field.id}>
+              {renderSectionField(field)}
+            </React.Fragment>
+          );
+        })}
+      </>
+    );
+  };
+
+  const getTabOptions = (): TabOption[] => {
+    let options = (props.leftTabOptions || [])
+      .concat(
+        formData?.tabs?.map((tab) => {
+          if (props.isLaunch && tab?.settings?.omitFromLaunch) {
+            return undefined;
+          }
+          return { label: tab.label, value: tab.name };
+        })
+      )
+      .concat(props.rightTabOptions || []);
+    return options.filter((x) => !!x);
+  };
+
+  const showSaveButton = (): boolean => {
+    if (props.isReadOnly) {
+      return false;
+    }
+
+    let returnVal = true;
+    props.leftTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+    props.rightTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+
+    return returnVal;
+  };
+
+  const renderTab = (): JSX.Element => {
+    if (!formData) {
+      return props.renderTabContents(currentTab);
+    }
+
+    const tab = formData.tabs?.filter((tab) => tab.name == currentTab)[0];
+
+    // Handle external tab
+    if (!tab) {
+      return props.renderTabContents ? (
+        props.renderTabContents(currentTab)
+      ) : (
+        <></>
+      );
+    }
+
+    return (
+      <StyledPorterForm showSave={showSaveButton()}>
+        {tab.sections?.map((section) => {
+          return (
+            <React.Fragment key={section.name}>
+              {renderSection(section)}
+            </React.Fragment>
+          );
+        })}
+      </StyledPorterForm>
+    );
+  };
+
+  const isDisabled = () => {
+    if (props.saveValuesStatus == "loading") {
+      return true;
+    }
+
+    return isReadOnly || !validationInfo.validated;
+  };
+
+  const renderSaveStatus = (): string => {
+    if (isDisabled() && props.saveValuesStatus !== "loading") {
+      return "Missing required fields";
+    }
+    return props.saveValuesStatus;
+  };
+
+  return (
+    <>
+      <TabRegion
+        addendum={props.addendum}
+        color={props.color}
+        options={getTabOptions()}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+        suppressAnimation={true}
+      >
+        {renderTab()}
+      </TabRegion>
+      <br />
+      {showSaveButton() && (
+        <SaveButton
+          text={props.saveButtonText || "Deploy"}
+          onClick={onSubmit}
+          makeFlush={!props.isInModal}
+          status={
+            validationInfo.validated ? renderSaveStatus() : validationInfo.error
+          }
+          disabled={isDisabled()}
+        />
+      )}
+      {props.showStateDebugger && (
+        <Pre>{JSON.stringify(formState, undefined, 2)}</Pre>
+      )}
+      <Spacer />
+    </>
+  );
+};
+
+export default PorterForm;
+
+const Pre = styled.pre`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const Spacer = styled.div`
+  height: 50px;
+`;
+
+const StyledPorterForm = styled.div<{ showSave?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 0px 35px 25px;
+  position: relative;
+  border-radius: 8px;
+  font-size: 13px;
+  overflow: auto;
+`;

+ 442 - 0
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -0,0 +1,442 @@
+import React, { createContext, useContext, useReducer } from "react";
+import {
+  PorterFormData,
+  PorterFormState,
+  PorterFormAction,
+  PorterFormVariableList,
+  PorterFormValidationInfo,
+  GetFinalVariablesFunction,
+} from "./types";
+import { ShowIf, ShowIfAnd, ShowIfNot, ShowIfOr } from "../../shared/types";
+import { getFinalVariablesForStringInput } from "./field-components/Input";
+import { getFinalVariablesForKeyValueArray } from "./field-components/KeyValueArray";
+import { Context } from "../../shared/Context";
+import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
+import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
+import { getFinalVariablesForSelect } from "./field-components/Select";
+
+interface Props {
+  rawFormData: PorterFormData;
+  onSubmit: (vars: PorterFormVariableList) => void;
+  initialVariables?: PorterFormVariableList;
+  overrideVariables?: PorterFormVariableList;
+  isReadOnly?: boolean;
+  doDebug?: boolean;
+}
+
+interface ContextProps {
+  formData: PorterFormData;
+  formState: PorterFormState;
+  onSubmit: () => void;
+  dispatchAction: (event: PorterFormAction) => void;
+  validationInfo: PorterFormValidationInfo;
+  isReadOnly?: boolean;
+}
+
+export const PorterFormContext = createContext<ContextProps | undefined>(
+  undefined!
+);
+const { Provider } = PorterFormContext;
+
+export const PorterFormContextProvider: React.FC<Props> = (props) => {
+  const context = useContext(Context);
+
+  const handleAction = (
+    state: PorterFormState,
+    action: PorterFormAction
+  ): PorterFormState => {
+    switch (action?.type) {
+      case "init-field":
+        if (!(action.id in state.components)) {
+          return {
+            ...state,
+            variables: {
+              ...state.variables,
+              ...action.initVars,
+            },
+            components: {
+              ...state.components,
+              [action.id]: {
+                state: action.initValue,
+              },
+            },
+            validation: {
+              ...state.validation,
+              [action.id]: {
+                ...{
+                  validated: false,
+                },
+                ...action.initValidation,
+              },
+            },
+          };
+        }
+        break;
+      case "update-field":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...props.overrideVariables,
+          },
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+              state: {
+                ...state.components[action.id].state,
+                ...action.updateFunc(state.components[action.id].state),
+              },
+            },
+          },
+        };
+      case "update-validation":
+        return {
+          ...state,
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+            },
+          },
+          validation: {
+            ...state.validation,
+            [action.id]: {
+              ...action.updateFunc(state.validation[action.id]),
+            },
+          },
+        };
+      case "mutate-vars":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...action.mutateFunc(state.variables),
+            ...props.overrideVariables,
+          },
+        };
+    }
+    return state;
+  };
+
+  // get variables initiated by variable field
+  const getInitialVariables = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (field?.type == "variable") {
+            ret[field.variable] = field.settings?.default;
+          }
+        })
+      )
+    );
+    return ret;
+  };
+
+  const getInitialValidation = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab, i) =>
+      tab.sections?.map((section, j) =>
+        section.contents?.map((field, k) => {
+          if (
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
+          )
+            return;
+          if (
+            field.required &&
+            (field.settings?.default || (field.value && field.value[0]))
+          ) {
+            ret[`${i}-${j}-${k}`] = {
+              validated: true,
+            };
+          }
+        })
+      )
+    );
+    return ret;
+  };
+
+  const [state, dispatch] = useReducer(handleAction, {
+    components: {},
+    validation: getInitialValidation(props.rawFormData),
+    variables: {
+      ...props.initialVariables,
+      ...getInitialVariables(props.rawFormData),
+      ...props.overrideVariables,
+    },
+  });
+
+  const evalShowIf = (
+    vals: ShowIf,
+    variables: PorterFormVariableList
+  ): boolean => {
+    if (!vals) {
+      return false;
+    }
+    if (typeof vals == "string") {
+      return !!variables[vals];
+    }
+    if ((vals as ShowIfOr).or) {
+      vals = vals as ShowIfOr;
+      for (let i = 0; i < vals.or?.length; i++) {
+        if (evalShowIf(vals.or[i], variables)) {
+          return true;
+        }
+      }
+      return false;
+    }
+    if ((vals as ShowIfAnd).and) {
+      vals = vals as ShowIfAnd;
+      for (let i = 0; i < vals.and?.length; i++) {
+        if (!evalShowIf(vals.and[i], variables)) {
+          return false;
+        }
+      }
+      return true;
+    }
+    if ((vals as ShowIfNot).not) {
+      vals = vals as ShowIfNot;
+      return !evalShowIf(vals.not, variables);
+    }
+
+    return false;
+  };
+
+  /*
+    Takes in old form data and changes it to use newer fields
+    For example, number-input becomes input with a setting that makes it
+    a number input
+   */
+  const restructureToNewFields = (data: PorterFormData) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab) => {
+        return {
+          ...tab,
+          sections: tab.sections?.map((section) => {
+            return {
+              ...section,
+              contents: section.contents
+                ?.map((field: any) => {
+                  if (field?.type == "number-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "number",
+                      },
+                    };
+                  }
+                  if (field?.type == "string-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "string",
+                      },
+                    };
+                  }
+                  if (field?.type == "string-input-password") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "password",
+                      },
+                    };
+                  }
+                  if (field?.type == "provider-select") {
+                    return {
+                      ...field,
+                      type: "select",
+                      settings: {
+                        ...field.settings,
+                        type: "provider",
+                      },
+                    };
+                  }
+                  if (field?.type == "env-key-value-array") {
+                    return {
+                      ...field,
+                      type: "key-value-array",
+                      secretOption: true,
+                      envLoader: true,
+                      fileUpload: true,
+                      settings: {
+                        type: "env",
+                      },
+                    };
+                  }
+                  if (field?.type == "variable") return null;
+                  return field;
+                })
+                .filter((x) => x != null),
+            };
+          }),
+        };
+      }),
+    };
+  };
+
+  /*
+  We don't want to have the actual <PorterForm> component to do as little form
+  logic as possible, so this structures the form object based on show_if statements
+  and assigns a unique id to each field
+
+  This computed structure also later lets us figure out which fields should be required
+  */
+  const computeFormStructure = (
+    data: PorterFormData,
+    variables: PorterFormVariableList
+  ) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab, i) => {
+        return {
+          ...tab,
+          sections: tab.sections
+            ?.map((section, j) => {
+              return {
+                ...section,
+                contents: section.contents?.map((field, k) => {
+                  return {
+                    ...field,
+                    id: `${i}-${j}-${k}`,
+                  };
+                }),
+              };
+            })
+            .filter((section) => {
+              return !section.show_if || evalShowIf(section.show_if, variables);
+            }),
+        };
+      }),
+    };
+  };
+
+  /*
+    compute a list of field ids who's input is required and a map from a variable value
+    to a list of fields that set it
+  */
+  const computeRequiredVariables = (
+    data: PorterFormData
+  ): [string[], Record<string, string[]>] => {
+    const requiredIds: string[] = [];
+    const mapping: Record<string, string[]> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
+          )
+            return;
+          // fields that have defaults can't be required since we can always
+          // compute their value
+          if (field.required) {
+            requiredIds.push(field.id);
+          }
+          if (!mapping[field.variable]) {
+            mapping[field.variable] = [];
+          }
+          mapping[field.variable].push(field.id);
+        })
+      )
+    );
+    return [requiredIds, mapping];
+  };
+
+  /*
+    Validate the form based on a list of required ids
+   */
+  const doValidation = (requiredIds: string[]) =>
+    requiredIds?.map((id) => state.validation[id]?.validated).every((x) => x);
+
+  const formData = computeFormStructure(
+    restructureToNewFields(props.rawFormData),
+    state.variables
+  );
+  const [requiredIds, varMapping] = computeRequiredVariables(formData);
+  const isValidated = doValidation(requiredIds);
+
+  /*
+  Handle submit
+  This involves going through all the (currently active) fields in the form and
+  using functions for each input to finalize the variables
+  This can take care of things like appending units to strings
+ */
+  const onSubmitWrapper = () => {
+    // we start off with a base list of the current variables for fields
+    // that don't need any processing on top (for example: checkbox)
+    // the assign here is important because that way state.variable isn't mutated
+    const varList: PorterFormVariableList[] = [
+      Object.assign({}, state.variables),
+    ];
+    const finalFunctions: Record<string, GetFinalVariablesFunction> = {
+      input: getFinalVariablesForStringInput,
+      "array-input": getFinalVariablesForArrayInput,
+      checkbox: getFinalVariablesForCheckbox,
+      "key-value-array": getFinalVariablesForKeyValueArray,
+      select: getFinalVariablesForSelect,
+    };
+
+    const data = props.rawFormData.includeHiddenFields
+      ? restructureToNewFields(props.rawFormData)
+      : formData;
+
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (finalFunctions[field?.type])
+            varList.push(
+              finalFunctions[field?.type](
+                state.variables,
+                field,
+                state.components[field.id]?.state,
+                context
+              )
+            );
+        })
+      )
+    );
+    if (props.doDebug) console.log(Object.assign.apply({}, varList));
+    props.onSubmit(Object.assign.apply({}, varList));
+  };
+
+  if (props.doDebug) {
+    console.group("Validation Info:");
+    console.log(requiredIds);
+    console.log(varMapping);
+    console.log(isValidated);
+    console.groupEnd();
+  }
+
+  return (
+    <Provider
+      value={{
+        formData: formData,
+        formState: state,
+        dispatchAction: dispatch,
+        isReadOnly: props.isReadOnly,
+        validationInfo: {
+          validated: isValidated,
+          error: isValidated ? null : "Missing required fields",
+        },
+        onSubmit: onSubmitWrapper,
+      }}
+    >
+      {props.children}
+    </Provider>
+  );
+};

+ 96 - 0
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -0,0 +1,96 @@
+import React, { useState } from "react";
+
+import PorterForm from "./PorterForm";
+import { PorterFormData } from "./types";
+import { PorterFormContextProvider } from "./PorterFormContextProvider";
+
+type PropsType = {
+  formData: any;
+  valuesToOverride?: any;
+  isReadOnly?: boolean;
+  onSubmit?: (values: any) => void;
+  renderTabContents?: (currentTab: string, submitValues?: any) => any;
+  leftTabOptions?: { value: string; label: string }[];
+  rightTabOptions?: { value: string; label: string }[];
+  saveButtonText?: string;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+  isLaunch?: boolean;
+};
+
+const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
+  formData,
+  valuesToOverride,
+  isReadOnly,
+  onSubmit,
+  renderTabContents,
+  leftTabOptions,
+  rightTabOptions,
+  saveButtonText,
+  isInModal,
+  color,
+  addendum,
+  saveValuesStatus,
+  showStateDebugger,
+  isLaunch,
+}) => {
+  const hashCode = (s: string) => {
+    return s?.split("").reduce(function (a, b) {
+      a = (a << 5) - a + b.charCodeAt(0);
+      return a & a;
+    }, 0);
+  };
+
+  const getInitialTab = (): string => {
+    if (leftTabOptions?.length > 0) {
+      return leftTabOptions[0].value;
+    } else if (formData?.tabs?.length > 0) {
+      let includedTabs = formData.tabs;
+      if (isLaunch) {
+        includedTabs = formData.tabs.filter(
+          (tab: any) => !tab?.settings?.omitFromLaunch
+        );
+      }
+      return includedTabs[0].name;
+    } else if (rightTabOptions?.length > 0) {
+      return rightTabOptions[0].value;
+    } else {
+      return "";
+    }
+  };
+
+  // Lifted into PorterFormWrapper to allow tab to be remembered on re-render (e.g., on revision select)
+  const [currentTab, setCurrentTab] = useState(getInitialTab());
+
+  return (
+    <React.Fragment key={hashCode(JSON.stringify(formData))}>
+      <PorterFormContextProvider
+        rawFormData={formData as PorterFormData}
+        overrideVariables={valuesToOverride}
+        isReadOnly={isReadOnly}
+        onSubmit={onSubmit}
+      >
+        <PorterForm
+          showStateDebugger={showStateDebugger}
+          addendum={addendum}
+          isReadOnly={isReadOnly}
+          leftTabOptions={leftTabOptions}
+          rightTabOptions={rightTabOptions}
+          renderTabContents={renderTabContents}
+          saveButtonText={saveButtonText}
+          isInModal={isInModal}
+          color={color}
+          saveValuesStatus={saveValuesStatus}
+          currentTab={currentTab}
+          setCurrentTab={setCurrentTab}
+          isLaunch={isLaunch}
+        />
+      </PorterFormContextProvider>
+    </React.Fragment>
+  );
+};
+
+export default PorterFormWrapper;

+ 187 - 0
dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -0,0 +1,187 @@
+import React from "react";
+import styled from "styled-components";
+import {
+  ArrayInputField,
+  ArrayInputFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
+import useFormField from "../hooks/useFormField";
+
+const ArrayInput: React.FC<ArrayInputField> = (props) => {
+  const { state, variables, setVars } = useFormField<ArrayInputFieldState>(
+    props.id,
+    {
+      initVars: {
+        [props.variable]: props.value && props.value[0] ? props.value[0] : [],
+      },
+    }
+  );
+
+  if (state == undefined) return <></>;
+
+  const renderDeleteButton = (values: string[], i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            setVars((prev) => {
+              return {
+                [props.variable]: prev[props.variable]
+                  .slice(0, i)
+                  .concat(prev[props.variable].slice(i + 1)),
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderInputList = (values: string[]) => {
+    return (
+      <>
+        {values?.map((value: string, i: number) => {
+          return (
+            <InputWrapper>
+              <Input
+                placeholder=""
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  e.persist();
+                  setVars((prev) => {
+                    return {
+                      [props.variable]: prev[props.variable]?.map(
+                        (t: string, j: number) => {
+                          return i == j ? e.target.value : t;
+                        }
+                      ),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly}
+              />
+              {renderDeleteButton(values, i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <StyledInputArray>
+      <Label>{props.label}</Label>
+      {variables[props.variable] === 0 ? (
+        <></>
+      ) : (
+        renderInputList(variables[props.variable])
+      )}
+      <AddRowButton
+        onClick={() => {
+          setVars((prev) => {
+            return {
+              [props.variable]: [...prev[props.variable], ""],
+            };
+          });
+        }}
+      >
+        <i className="material-icons">add</i> Add Row
+      </AddRowButton>
+    </StyledInputArray>
+  );
+};
+
+export default ArrayInput;
+
+export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
+  vars,
+  props: ArrayInputField
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: props.value ? props.value[0] : [],
+      };
+};
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 30px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 81 - 0
dashboard/src/components/porter-form/field-components/Checkbox.tsx

@@ -0,0 +1,81 @@
+import React from "react";
+import {
+  ArrayInputField,
+  CheckboxField,
+  CheckboxFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
+import CheckboxRow from "../../form-components/CheckboxRow";
+import useFormField from "../hooks/useFormField";
+
+interface Props extends CheckboxField {
+  id: string;
+}
+
+const Checkbox: React.FC<Props> = ({
+  id,
+  label,
+  required,
+  variable,
+  isReadOnly,
+  settings,
+  value,
+}) => {
+  const { state, variables, setVars } = useFormField<CheckboxFieldState>(id, {
+    initState: {},
+    initValidation: {
+      validated: !required,
+    },
+    initVars: {
+      [variable]: value ? value[0] : !!settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  return (
+    <CheckboxRow
+      isRequired={required}
+      checked={variables[variable]}
+      toggle={() => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: !vars[variable],
+          };
+        });
+      }}
+      label={label}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export default Checkbox;
+
+export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
+  vars,
+  props: CheckboxField
+) => {
+  // Read from revision values if unrendered (and therefore not in form state)
+  if (vars[props.variable] === null || vars[props.variable] === undefined) {
+    if (props.value[0] === false) {
+      return { [props.variable]: false };
+    } else if (props.value[0] === true) {
+      return { [props.variable]: true };
+    }
+  }
+
+  // Read from form state if set by user
+  if (vars[props.variable] === false) {
+    return { [props.variable]: false };
+  } else if (vars[props.variable] === true) {
+    return { [props.variable]: true };
+  }
+
+  return {
+    [props.variable]: props.value ? props.value[0] : !!props.settings?.default,
+  };
+};

+ 111 - 0
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -0,0 +1,111 @@
+import React from "react";
+import InputRow from "../../form-components/InputRow";
+import useFormField from "../hooks/useFormField";
+import {
+  GenericInputField,
+  GetFinalVariablesFunction,
+  InputField,
+  StringInputFieldState,
+} from "../types";
+
+const clipOffUnit = (unit: string, x: string) => {
+  if (typeof x === "string" && unit) {
+    return unit === x.slice(x.length - unit.length, x.length)
+      ? x.slice(0, x.length - unit.length)
+      : x;
+  }
+  return x;
+};
+
+const Input: React.FC<InputField> = ({
+  id,
+  variable,
+  label,
+  required,
+  placeholder,
+  info,
+  settings,
+  isReadOnly,
+  value,
+}) => {
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<StringInputFieldState>(id, {
+    initValidation: {
+      validated: value
+        ? value[0] !== undefined && value[0] !== "" && value[0] != null
+        : settings?.default != undefined,
+    },
+    initVars: {
+      [variable]: value
+        ? clipOffUnit(settings?.unit, value[0])
+        : settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  console.log(value);
+
+  const curValue =
+    settings?.type == "number"
+      ? !isNaN(parseFloat(variables[variable]))
+        ? parseFloat(variables[variable])
+        : ""
+      : variables[variable] || "";
+
+  return (
+    <InputRow
+      width="100%"
+      type={settings?.type || "text"}
+      value={curValue}
+      unit={settings?.unit}
+      setValue={(x: string | number) => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: x,
+          };
+        });
+        setValidation((prev) => {
+          return {
+            ...prev,
+            validated:
+              settings?.type == "number"
+                ? !isNaN(x as number)
+                : !!(x as string).trim(),
+          };
+        });
+      }}
+      label={label}
+      isRequired={required}
+      placeholder={placeholder}
+      info={info}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
+  vars,
+  props: InputField
+) => {
+  const val =
+    vars[props.variable] ||
+    (props.value
+      ? clipOffUnit(props.settings?.unit, props.value[0])
+      : props.settings?.default);
+  return {
+    [props.variable]:
+      props.settings?.unit && !props.settings.omitUnitFromValue
+        ? val + props.settings.unit
+        : val,
+  };
+};
+
+export default Input;

+ 519 - 0
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -0,0 +1,519 @@
+import React from "react";
+import {
+  GetFinalVariablesFunction,
+  InputField,
+  KeyValueArrayField,
+  KeyValueArrayFieldState,
+} from "../types";
+import sliders from "../../../assets/sliders.svg";
+import upload from "../../../assets/upload.svg";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import Modal from "../../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
+import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
+
+interface Props extends KeyValueArrayField {
+  id: string;
+}
+
+const KeyValueArray: React.FC<Props> = (props) => {
+  const { state, setState, variables } = useFormField<KeyValueArrayFieldState>(
+    props.id,
+    {
+      initState: {
+        values:
+          props.value && props.value[0]
+            ? (Object.entries(props.value[0])?.map(([k, v]) => {
+                return { key: k, value: v };
+              }) as any[])
+            : [],
+        showEnvModal: false,
+        showEditorModal: false,
+      },
+    }
+  );
+
+  if (state == undefined) return <></>;
+
+  const parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  const readFile = (env: string) => {
+    let envObj = parseEnv(env, null);
+    let push = true;
+
+    for (let key in envObj) {
+      for (var i = 0; i < state.values.length; i++) {
+        let existingKey = state.values[i]["key"];
+        if (key === existingKey) {
+          state.values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        setState((prev) => {
+          return {
+            values: [...prev.values, { key, value: envObj[key] }],
+          };
+        });
+      }
+    }
+  };
+
+  const renderEditorModal = () => {
+    if (state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEditorModal: false };
+            })
+          }
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() =>
+              setState(() => {
+                return { showEditorModal: false };
+              })
+            }
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const getProcessedValues = (objectArray: { key: string, value: string }[]): any => {
+    let obj = {} as any;
+    objectArray?.forEach(({ key, value }) => {
+      obj[key] = value;
+    });
+    return obj;
+  };
+
+  const renderEnvModal = () => {
+    if (state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEnvModal: false };
+            })
+          }
+          width="765px"
+          height="542px"
+        >
+          <LoadEnvGroupModal
+            existingValues={getProcessedValues(state.values)}
+            namespace={variables.namespace}
+            clusterId={variables.clusterId}
+            closeModal={() =>
+              setState(() => {
+                return {
+                  showEnvModal: false,
+                };
+              })
+            }
+            setValues={(values) => {
+              setState((prev) => {
+                return {
+                  // might be broken
+                  values: [
+                    ...prev.values,
+                    ...Object.entries(values)?.map(([k, v]) => {
+                      return {
+                        key: k,
+                        value: v,
+                      };
+                    }),
+                  ],
+                };
+              });
+            }}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const renderDeleteButton = (i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            state.values.splice(i, 1);
+            setState((prev) => {
+              return {
+                values: prev.values
+                  .slice(0, i + 1)
+                  .concat(prev.values.slice(i + 1, prev.values.length)),
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderHiddenOption = (hidden: boolean, i: number) => {
+    if (props.secretOption && hidden) {
+      return (
+        <HideButton>
+          <i className="material-icons">lock</i>
+        </HideButton>
+      );
+    }
+  };
+
+  const renderInputList = () => {
+    return (
+      <>
+        {state.values?.map((entry: any, i: number) => {
+          // Preprocess non-string env values set via raw Helm values
+          let { value } = entry;
+          if (typeof value === "object") {
+            value = JSON.stringify(value);
+          } else if (typeof value === "number" || typeof value === "boolean") {
+            value = value.toString();
+          }
+
+          return (
+            <InputWrapper key={i}>
+              <Input
+                placeholder="ex: key"
+                width="270px"
+                value={entry.key}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values?.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            key: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                spellCheck={false}
+              />
+              <Spacer />
+              <Input
+                placeholder="ex: value"
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values?.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            value: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                spellCheck={false}
+              />
+              {renderDeleteButton(i)}
+              {renderHiddenOption(value.includes("PORTERSECRET"), i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{props.label}</Label>
+        {state.values.length === 0 ? <></> : renderInputList()}
+        {props.isReadOnly ? (
+          <></>
+        ) : (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                setState((prev) => {
+                  return {
+                    values: [...prev.values, { key: "", value: "" }],
+                  };
+                });
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer />
+            {variables.namespace && props.envLoader && (
+              <LoadButton
+                onClick={() =>
+                  setState((prev) => {
+                    return {
+                      showEnvModal: !prev.showEnvModal,
+                    };
+                  })
+                }
+              >
+                <img src={sliders} /> Load from Env Group
+              </LoadButton>
+            )}
+            {props.fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setState((prev) => {
+                    return {
+                      showEditorModal: true,
+                    };
+                  });
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
+            )}
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {renderEnvModal()}
+      {renderEditorModal()}
+    </>
+  );
+};
+
+export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
+  vars,
+  props: KeyValueArrayField,
+  state: KeyValueArrayFieldState
+) => {
+  if (!state) {
+    return {
+      [props.variable]: props.value ? props.value[0] : [],
+    };
+  }
+
+  let obj = {} as any;
+  const rg = /(?:^|[^\\])(\\n)/g;
+  const fixNewlines = (s: string) => {
+    while (rg.test(s)) {
+      s = s.replace(rg, (str) => {
+        if (str.length == 2) return "\n";
+        if (str[0] != "\\") return str[0] + "\n";
+        return "\\n";
+      });
+    }
+    return s;
+  };
+  const isNumber = (s: string) => {
+    return !isNaN(!s ? NaN : Number(String(s).trim()));
+  };
+  state.values.forEach((entry: any, i: number) => {
+    if (isNumber(entry.value)) {
+      obj[entry.key] = entry.value;
+    } else {
+      obj[entry.key] = fixNewlines(entry.value);
+    }
+  });
+  return {
+    [props.variable]: obj,
+  };
+};
+
+export default KeyValueArray;
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  margin-left: 10px;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: default;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 0 - 0
dashboard/src/components/values-form/MultiSelect.tsx → dashboard/src/components/porter-form/field-components/MultiSelect.tsx


+ 32 - 0
dashboard/src/components/porter-form/field-components/ResourceList.tsx

@@ -0,0 +1,32 @@
+import React from "react";
+import { ResourceListField } from "../types";
+import ExpandableResource from "../../ExpandableResource";
+import styled from "styled-components";
+
+const ResourceList: React.FC<ResourceListField> = (props) => {
+  return (
+    <ResourceListWrapper>
+      {props.value?.map((resource: any, i: number) => {
+        if (resource.data) {
+          return (
+            <ExpandableResource
+              key={i}
+              resource={resource}
+              isLast={i === props.value.length - 1}
+              roundAllCorners={true}
+            />
+          );
+        }
+      })}
+    </ResourceListWrapper>
+  );
+};
+
+export default ResourceList;
+
+const ResourceListWrapper = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 8px;
+  overflow: hidden;
+`;

+ 101 - 0
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -0,0 +1,101 @@
+import React, { useContext } from "react";
+import {
+  CheckboxField,
+  GetFinalVariablesFunction,
+  SelectField,
+  SelectFieldState,
+} from "../types";
+import Selector from "../../Selector";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import { Context } from "../../../shared/Context";
+
+const Select: React.FC<SelectField> = (props) => {
+  const { currentCluster } = useContext(Context);
+  const { variables, setVars } = useFormField<SelectFieldState>(props.id, {
+    initVars: {
+      [props.variable]: props.value
+        ? props.value[0]
+        : props.settings.default
+        ? props.settings.default
+        : props.settings.type == "provider"
+        ? ({
+            gke: "gcp",
+            eks: "aws",
+            doks: "do",
+          } as Record<string, string>)[currentCluster.service] || "aws"
+        : props.settings.options[0].value,
+    },
+  });
+
+  const providerOptions = [
+    { value: "aws", label: "Amazon Web Services (AWS)" },
+    { value: "gcp", label: "Google Cloud Platform (GCP)" },
+    { value: "do", label: "DigitalOcean" },
+  ];
+
+  return (
+    <StyledSelectRow>
+      <Label>{props.label}</Label>
+      <SelectWrapper>
+        <Selector
+          activeValue={variables[props.variable]}
+          setActiveValue={(val) => {
+            setVars(() => {
+              return {
+                [props.variable]: val,
+              };
+            });
+          }}
+          options={
+            props.settings.type == "provider"
+              ? providerOptions
+              : props.settings.options
+          }
+          dropdownLabel={props.dropdownLabel}
+          width={props.width || "270px"}
+          dropdownWidth={props.width}
+          dropdownMaxHeight={props.dropdownMaxHeight}
+        />
+      </SelectWrapper>
+    </StyledSelectRow>
+  );
+};
+
+export default Select;
+
+export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
+  vars,
+  props: SelectField,
+  state,
+  context
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: props.value
+          ? props.value[0]
+          : props.settings.default
+          ? props.settings.default
+          : props.settings.type == "provider"
+          ? ({
+              gke: "gcp",
+              eks: "aws",
+              doks: "do",
+            } as Record<string, string>)[context.currentCluster.service] ||
+            "aws"
+          : props.settings.options[0].value,
+      };
+};
+
+const SelectWrapper = styled.div``;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledSelectRow = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 23 - 0
dashboard/src/components/porter-form/field-components/ServiceIPList.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { ServiceIPListField } from "../types";
+import ServiceRow from "./ServiceRow";
+import styled from "styled-components";
+
+const ServiceIPList: React.FC<ServiceIPListField> = (props) => {
+  return (
+    <ResourceList>
+      {props.value?.map((service: any, i: number) => {
+        return <ServiceRow service={service} key={i} />;
+      })}
+    </ResourceList>
+  );
+};
+
+export default ServiceIPList;
+
+const ResourceList = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 8px;
+  overflow: hidden;
+`;

+ 0 - 0
dashboard/src/components/values-form/ServiceRow.tsx → dashboard/src/components/porter-form/field-components/ServiceRow.tsx


+ 3 - 3
dashboard/src/components/forms/VeleroForm.tsx → dashboard/src/components/porter-form/field-components/VeleroForm.tsx

@@ -1,8 +1,8 @@
 import React, { Component } from "react";
 
-import Heading from "../values-form/Heading";
-import InputRow from "../values-form/InputRow";
-import MultiSelect from "../values-form/MultiSelect";
+import Heading from "../../form-components/Heading";
+import InputRow from "../../form-components/InputRow";
+import MultiSelect from "./MultiSelect";
 
 type PropsType = {};
 

+ 85 - 0
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -0,0 +1,85 @@
+import { useContext, useEffect } from "react";
+import { PorterFormContext } from "../PorterFormContextProvider";
+import {
+  PorterFormFieldFieldState,
+  PorterFormFieldValidationState,
+  PorterFormVariableList,
+} from "../types";
+
+interface FormFieldData<T> {
+  state: T;
+  variables: PorterFormVariableList;
+  setState: (setFunc: (prev: T) => Partial<T>) => void;
+  setVars: (
+    setFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => void;
+  setValidation: (
+    setFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => void;
+}
+
+interface Options<T> {
+  initState?: T;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+const useFormField = <T extends PorterFormFieldFieldState>(
+  fieldId: string,
+  { initState, initVars, initValidation }: Options<T>
+): FormFieldData<T> => {
+  const { dispatchAction, formState } = useContext(PorterFormContext);
+
+  useEffect(() => {
+    dispatchAction({
+      type: "init-field",
+      id: fieldId,
+      initValue: initState || {},
+      initValidation: initValidation || {
+        validated: false,
+      },
+      initVars: initVars || {},
+    });
+  }, []);
+
+  const setState = (updateFunc: (prev: T) => Partial<T>) => {
+    dispatchAction({
+      type: "update-field",
+      id: fieldId,
+      updateFunc,
+    });
+  };
+
+  const setVars = (
+    mutateFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => {
+    dispatchAction({
+      type: "mutate-vars",
+      mutateFunc,
+    });
+  };
+
+  const setValidation = (
+    updateFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => {
+    dispatchAction({
+      id: fieldId,
+      type: "update-validation",
+      updateFunc,
+    });
+  };
+
+  return {
+    state: formState.components[fieldId]?.state as T,
+    variables: formState.variables,
+    setState,
+    setVars,
+    setValidation,
+  };
+};
+
+export default useFormField;

+ 248 - 0
dashboard/src/components/porter-form/types.ts

@@ -0,0 +1,248 @@
+/*
+  Interfaces for the form YAML
+  Will be merged with shared types later
+*/
+
+// YAML Field interfaces
+
+import { ContextProps } from "../../shared/types";
+
+export interface GenericField {
+  id: string;
+}
+
+export interface GenericInputField extends GenericField {
+  isReadOnly?: boolean;
+  required?: boolean;
+  variable: string;
+  settings?: any;
+
+  // Read in value from Helm for existing revisions
+  value?: any[];
+}
+
+export interface HeadingField extends GenericField {
+  type: "heading";
+  label: string;
+}
+
+export interface SubtitleField extends GenericField {
+  type: "subtitle";
+  label: string;
+}
+
+export interface ServiceIPListField extends GenericField {
+  type: "service-ip-list";
+  value: any[];
+}
+
+export interface ResourceListField extends GenericField {
+  type: "resource-list";
+  value: any[];
+}
+
+export interface VeleroBackupField extends GenericField {
+  type: "velero-create-backup";
+}
+
+export interface InputField extends GenericInputField {
+  type: "input";
+  label?: string;
+  placeholder?: string;
+  info?: string;
+  settings?: {
+    type?: "text" | "password" | "number";
+    unit?: string;
+    omitUnitFromValue?: boolean;
+    default: string | number;
+  };
+}
+
+export interface CheckboxField extends GenericInputField {
+  type: "checkbox";
+  label?: string;
+  settings?: {
+    default: boolean;
+  };
+}
+
+export interface KeyValueArrayField extends GenericInputField {
+  type: "key-value-array";
+  label?: string;
+  secretOption?: boolean;
+  envLoader?: boolean;
+  fileUpload?: boolean;
+  settings?: {
+    type: "env" | "normal";
+  };
+}
+
+export interface ArrayInputField extends GenericInputField {
+  type: "array-input";
+  label?: string;
+}
+
+export interface SelectField extends GenericInputField {
+  type: "select";
+  settings:
+    | {
+        type: "normal";
+        options: { value: string; label: string }[];
+        default?: string;
+      }
+    | {
+        type: "provider";
+        default?: string;
+      };
+  width: string;
+  label?: string;
+  dropdownLabel?: string;
+  dropdownWidth?: number;
+  dropdownMaxHeight?: string;
+}
+
+export interface VariableField extends GenericInputField {
+  type: "variable";
+  settings?: {
+    default: any;
+  };
+}
+
+export type FormField =
+  | HeadingField
+  | SubtitleField
+  | InputField
+  | CheckboxField
+  | KeyValueArrayField
+  | ArrayInputField
+  | SelectField
+  | ServiceIPListField
+  | ResourceListField
+  | VeleroBackupField
+  | VariableField;
+
+export interface ShowIfAnd {
+  and: ShowIf[];
+}
+
+export interface ShowIfOr {
+  or: ShowIf[];
+}
+
+export interface ShowIfNot {
+  not: ShowIf;
+}
+
+export type ShowIf = string | ShowIfAnd | ShowIfOr | ShowIfNot;
+
+export interface Section {
+  name: string;
+  show_if?: ShowIf;
+  contents: FormField[];
+}
+
+export interface Tab {
+  name: string;
+  label: string;
+  sections: Section[];
+  settings?: {
+    omitFromLaunch?: boolean;
+  };
+}
+
+export interface PorterFormData {
+  name: string;
+  hasSource: boolean;
+  includeHiddenFields: boolean;
+  tabs: Tab[];
+}
+
+export interface PorterFormValidationInfo {
+  validated: boolean;
+  error?: string;
+}
+
+// internal field state interfaces
+export interface StringInputFieldState {}
+export interface CheckboxFieldState {}
+export interface KeyValueArrayFieldState {
+  values: {
+    key: string;
+    value: string;
+  }[];
+  showEnvModal: boolean;
+  showEditorModal: boolean;
+}
+export interface ArrayInputFieldState {}
+export interface SelectFieldState {}
+
+export type PorterFormFieldFieldState =
+  | StringInputFieldState
+  | CheckboxFieldState
+  | KeyValueArrayField
+  | ArrayInputFieldState
+  | SelectFieldState;
+
+// reducer interfaces
+
+export interface PorterFormFieldValidationState {
+  validated: boolean;
+}
+
+export interface PorterFormVariableList {
+  [key: string]: any;
+}
+
+export interface PorterFormState {
+  components: {
+    [key: string]: {
+      state: PorterFormFieldFieldState;
+    };
+  };
+  validation: {
+    [key: string]: PorterFormFieldValidationState;
+  };
+  variables: PorterFormVariableList;
+}
+
+export interface PorterFormInitFieldAction {
+  type: "init-field";
+  id: string;
+  initValue: PorterFormFieldFieldState;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+export interface PorterFormUpdateFieldAction {
+  type: "update-field";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldFieldState
+  ) => Partial<PorterFormFieldFieldState>;
+}
+
+export interface PorterFormUpdateValidationAction {
+  type: "update-validation";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldValidationState
+  ) => PorterFormFieldValidationState;
+}
+
+export interface PorterFormMutateVariablesAction {
+  type: "mutate-vars";
+  mutateFunc: (prev: PorterFormVariableList) => PorterFormVariableList;
+}
+
+export type PorterFormAction =
+  | PorterFormInitFieldAction
+  | PorterFormUpdateFieldAction
+  | PorterFormMutateVariablesAction
+  | PorterFormUpdateValidationAction;
+
+export type GetFinalVariablesFunction = (
+  vars: PorterFormVariableList,
+  props: FormField,
+  state: PorterFormFieldFieldState,
+  context: Partial<ContextProps>
+) => PorterFormVariableList;

+ 1 - 1
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -7,7 +7,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
-import InputRow from "../values-form/InputRow";
+import InputRow from "../form-components/InputRow";
 import InfoTooltip from "components/InfoTooltip";
 
 type PropsType = {

+ 1 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -642,7 +642,7 @@ const Banner = styled.div`
   margin: 5px 0 10px;
   font-size: 13px;
   display: flex;
-  border-radius: 5px;
+  border-radius: 8px;
   padding-left: 15px;
   align-items: center;
   background: #ffffff11;

+ 1 - 1
dashboard/src/components/repo-selector/NewGHAction.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { ChartType } from "shared/types";
 import { Context } from "shared/Context";
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 
 import Loading from "../Loading";
 

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

@@ -8,7 +8,7 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 import SearchBar from "../SearchBar";
-import Helper from "../values-form/Helper";
+import Helper from "../form-components/Helper";
 
 interface GithubAppAccessData {
   has_access: boolean;

+ 0 - 99
dashboard/src/components/values-form/Base64InputRow.tsx

@@ -1,99 +0,0 @@
-import React, { ChangeEvent, Component } from "react";
-import styled from "styled-components";
-
-type PropsType = {
-  label?: string;
-  type: string;
-  value: string | number;
-  setValue: (x: string | number) => void;
-  unit?: string;
-  placeholder?: string;
-  width?: string;
-  disabled?: boolean;
-  isRequired?: boolean;
-};
-
-type StateType = {
-  readOnly: boolean;
-};
-
-export default class InputRow extends Component<PropsType, StateType> {
-  state = {
-    readOnly: true,
-  };
-
-  handleChange = (e: ChangeEvent<HTMLInputElement>) => {
-    this.props.setValue(e.target.value);
-  };
-
-  render() {
-    let { label, value, type, unit, placeholder, width } = this.props;
-    value = value.toString();
-    value = atob(value);
-    return (
-      <StyledInputRow>
-        <Label>
-          {label} <Required>{this.props.isRequired ? " *" : null}</Required>
-        </Label>
-        <InputWrapper>
-          <Input
-            readOnly={this.state.readOnly}
-            onFocus={() => this.setState({ readOnly: false })}
-            disabled={this.props.disabled}
-            placeholder={placeholder}
-            width={width}
-            type={type}
-            value={value}
-            onChange={this.handleChange}
-          />
-          {unit ? <Unit>{unit}</Unit> : null}
-        </InputWrapper>
-      </StyledInputRow>
-    );
-  }
-}
-
-const Required = styled.div`
-  margin-left: 8px;
-  color: #fc4976;
-`;
-
-const Unit = styled.div`
-  margin-right: 8px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  margin-bottom: -1px;
-  align-items: center;
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { disabled: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
-  padding: 5px 10px;
-  margin-right: 8px;
-  height: 30px;
-`;
-
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-`;
-
-const StyledInputRow = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;

+ 0 - 323
dashboard/src/components/values-form/FormDebugger.tsx

@@ -1,323 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import AceEditor from "react-ace";
-import FormWrapper from "components/values-form/FormWrapper";
-import CheckboxRow from "components/values-form/CheckboxRow";
-import InputRow from "components/values-form/InputRow";
-import yaml from "js-yaml";
-
-import "shared/ace-porter-theme";
-import "ace-builds/src-noconflict/mode-text";
-
-import Heading from "./Heading";
-import Helper from "./Helper";
-
-type PropsType = {
-  goBack: () => void;
-};
-
-type StateType = {
-  rawYaml: string;
-  showBonusTabs: boolean;
-  showStateDebugger: boolean;
-  valuesToOverride: any;
-  checkbox_a: boolean;
-  input_a: string;
-  isReadOnly: boolean;
-};
-
-const tabOptions = [
-  { value: "a", label: "Bonus Tab A" },
-  { value: "b", label: "Bonus Tab B" },
-];
-
-export default class FormDebugger extends Component<PropsType, StateType> {
-  state = {
-    rawYaml: initYaml,
-    showBonusTabs: false,
-    showStateDebugger: true,
-    valuesToOverride: {
-      checkbox_a: {
-        value: true,
-      },
-    } as any,
-    checkbox_a: true,
-    input_a: "",
-    isReadOnly: false,
-  };
-
-  renderTabContents = (currentTab: string) => {
-    return (
-      <TabWrapper>
-        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
-      </TabWrapper>
-    );
-  };
-
-  aceEditorRef = React.createRef<AceEditor>();
-  render() {
-    let formData = {};
-    try {
-      formData = yaml.load(this.state.rawYaml);
-    } catch (err: any) {
-      console.log("YAML parsing error.");
-    }
-    return (
-      <StyledFormDebugger>
-        <Button onClick={this.props.goBack}>
-          <i className="material-icons">keyboard_backspace</i>
-          Back
-        </Button>
-        <Heading>✨ Form.yaml Editor</Heading>
-        <Helper>Write and test form.yaml free of consequence.</Helper>
-
-        <EditorWrapper>
-          <AceEditor
-            ref={this.aceEditorRef}
-            mode="yaml"
-            value={this.state.rawYaml}
-            theme="porter"
-            onChange={(e: string) => this.setState({ rawYaml: e })}
-            name="codeEditor"
-            editorProps={{ $blockScrolling: true }}
-            height="450px"
-            width="100%"
-            style={{
-              borderRadius: "5px",
-              border: "1px solid #ffffff22",
-              marginTop: "27px",
-              marginBottom: "27px",
-            }}
-            showPrintMargin={false}
-            showGutter={true}
-            highlightActiveLine={true}
-          />
-        </EditorWrapper>
-
-        <CheckboxRow
-          label="Show form state debugger"
-          checked={this.state.showStateDebugger}
-          toggle={() =>
-            this.setState({ showStateDebugger: !this.state.showStateDebugger })
-          }
-        />
-        <CheckboxRow
-          label="Read-only"
-          checked={this.state.isReadOnly}
-          toggle={() =>
-            this.setState({
-              isReadOnly: !this.state.isReadOnly,
-            })
-          }
-        />
-        <CheckboxRow
-          label="Include non-form dummy tabs"
-          checked={this.state.showBonusTabs}
-          toggle={() =>
-            this.setState({ showBonusTabs: !this.state.showBonusTabs })
-          }
-        />
-        <CheckboxRow
-          label="checkbox_a"
-          checked={this.state.checkbox_a}
-          toggle={() =>
-            this.setState({
-              checkbox_a: !this.state.checkbox_a,
-
-              // Override the form value for checkbox_a
-              valuesToOverride: {
-                ...this.state.valuesToOverride,
-                checkbox_a: {
-                  value: !this.state.checkbox_a,
-                },
-              },
-            })
-          }
-        />
-        <InputRow
-          type="string"
-          value={this.state.input_a}
-          setValue={(x: string) =>
-            this.setState({
-              input_a: x,
-
-              // Override the form value for input_a
-              valuesToOverride: {
-                ...this.state.valuesToOverride,
-                input_a: {
-                  value: x,
-                },
-              },
-            })
-          }
-          label={"input_a"}
-          placeholder="ex: override text"
-        />
-
-        <Heading>🎨 Rendered Form</Heading>
-        <Br />
-        <FormWrapper
-          valuesToOverride={this.state.valuesToOverride}
-          clearValuesToOverride={() =>
-            this.setState({ valuesToOverride: null })
-          }
-          showStateDebugger={this.state.showStateDebugger}
-          formData={formData}
-          isReadOnly={this.state.isReadOnly}
-          tabOptions={this.state.showBonusTabs ? tabOptions : []}
-          renderTabContents={
-            this.state.showBonusTabs ? this.renderTabContents : null
-          }
-          onSubmit={(values: any) => {
-            alert("Check console output.");
-            console.log("Raw submission values:");
-            console.log(values);
-          }}
-        />
-      </StyledFormDebugger>
-    );
-  }
-}
-
-const Br = styled.div`
-  width: 100%;
-  height: 12px;
-`;
-
-const TabWrapper = styled.div`
-  background: #ffffff11;
-  height: 200px;
-  width: 100%;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  overflow: auto;
-  padding: 50px;
-`;
-
-const EditorWrapper = styled.div`
-  .ace_editor,
-  .ace_editor * {
-    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
-      monospace !important;
-    font-size: 12px !important;
-    font-weight: 400 !important;
-    letter-spacing: 0 !important;
-  }
-`;
-
-const StyledFormDebugger = styled.div`
-  position: relative;
-`;
-
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  margin-left: -2px;
-  padding: 0px 8px;
-  width: 85px;
-  float: right;
-  padding-bottom: 1px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: pointer;
-  border: 2px solid #969fbbaa;
-  :hover {
-    background: #ffffff11;
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    color: #969fbbaa;
-    font-weight: 600;
-    font-size: 14px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const initYaml = `name: Porter Example
-hasSource: true
-tabs:
-- name: main
-  label: Main
-  sections:
-  - name: header
-    contents: 
-    - type: heading
-      label: 🍺 Porter Demo Form
-    - type: subtitle
-      name: command_description
-      label: Basic form demonstrating some of the features of form.yaml
-    - type: string-input
-      placeholder: "ex: pilsner"
-      label: Required Field A
-      required: true
-      variable: field_a
-      info: This is some info
-    - type: string-input
-      placeholder: "ex: sapporo"
-      required: true
-      label: Required Field B
-      variable: field_b
-    - type: subtitle
-      label: "Note: Hidden required fields aren't supported yet (global only)"
-  - name: controlled-by-external
-    show_if:
-      or:
-        - checkbox_a
-        - not_a_variable
-    contents:
-    - type: heading
-      label: Conditional Display (A)
-    - type: subtitle
-      label: This section can be externally controlled by the value of checkbox_a
-    - type: string-input
-      variable: input_a
-      placeholder: "Override w/ input_a"
-  - name: domain_name
-    show_if: ingress.custom_domain
-    contents:
-    - type: array-input
-      variable: ingress.hosts
-      label: Domain Name
-- name: env
-  label: Environment
-  sections:
-  - name: env_vars
-    contents:
-    - type: heading
-      label: Environment Variables
-    - type: subtitle
-      label: Set environment variables for your secrets and environment-specific configuration.
-    - type: env-key-value-array
-      label: 
-      variable: container.env.normal
-- name: advanced
-  label: Advanced
-  sections:
-  - name: advanced
-    contents:
-    - type: heading
-      label: Some Header
-    - type: subtitle
-      label: Some helper text
-`;

+ 0 - 509
dashboard/src/components/values-form/FormWrapper.tsx

@@ -1,509 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import _ from "lodash";
-
-import { Section, FormElement } from "shared/types";
-import { Context } from "shared/Context";
-import TabRegion from "components/TabRegion";
-import ValuesForm from "components/values-form/ValuesForm";
-import SaveButton from "../SaveButton";
-
-type PropsType = {
-  formData: any;
-  onSubmit?: (formValues: any) => void;
-  saveValuesStatus?: string | null;
-  saveButtonText?: string | null;
-
-  // Handle additional non-form tabs
-  // TODO: find cleaner way to share submitValues w/ rerun jobs button
-  renderTabContents?: (currentTab: string, submitValues?: any) => any;
-  tabOptions?: any[];
-  tabOptionsOnly?: boolean;
-
-  // Allow external control of state
-  valuesToOverride?: any;
-  clearValuesToOverride?: () => void;
-
-  // External values made available to all child components
-  externalValues?: any;
-
-  // Display and debugger settings
-  isInModal?: boolean;
-  isReadOnly?: boolean;
-  showStateDebugger?: boolean;
-
-  // TabRegion props to pass through
-  color?: string;
-  addendum?: any;
-};
-
-type StateType = {
-  metaState: any;
-  requiredFields: string[];
-  currentTab: string;
-  tabOptions: { value: string; label: string }[];
-};
-
-/**
- * Renders from raw JSON form data and manages form state.
- *
- * To control values using external state prop in "valuesToOverride" (refer to
- * FormDebugger or LaunchTemplate for example usage).
- *
- * TODO: Handle passing in valuesToOverride at same time as formData
- */
-export default class FormWrapper extends Component<PropsType, StateType> {
-  state = {
-    metaState: {} as any,
-    requiredFields: [] as string[],
-    currentTab: "",
-    tabOptions: [] as { value: string; label: string }[],
-  };
-
-  updateTabs = (resetState?: boolean, callback?: any) => {
-    if (resetState) {
-      let tabOptions = [] as { value: string; label: string }[];
-      let tabs = this.props.formData?.tabs;
-      let requiredFields = [] as string[];
-      let metaState: any = {
-        "currentCluster.service.is_gcp": {
-          value: this.context.currentCluster.service == "gke",
-        },
-        "currentCluster.service.is_aws": {
-          value: this.context.currentCluster.service == "eks",
-        },
-        "currentCluster.service.is_do": {
-          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 };
-                }
-              });
-            });
-            if (!this.props.tabOptionsOnly) {
-              tabOptions.push({ value: tab.name, label: tab.label });
-            }
-          }
-        });
-      }
-      if (this.props.tabOptions?.length > 0) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
-      }
-      if (tabOptions.length > 0) {
-        this.setState(
-          {
-            tabOptions: tabOptions,
-            currentTab:
-              this.state.currentTab === ""
-                ? tabOptions[0].value
-                : this.state.currentTab,
-            metaState,
-            requiredFields: requiredFields,
-          },
-          callback
-        );
-      } else {
-        this.setState({ tabOptions }, callback);
-      }
-    } else {
-      // TODO: refactor by consolidating w/ above
-      // 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 });
-          }
-        });
-      }
-      if (this.props.tabOptions?.length > 0) {
-        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));
-      }
-      this.setState({ tabOptions }, callback);
-    }
-  };
-
-  componentDidMount() {
-    this.updateTabs(true, () => {
-      this.setState(
-        {
-          metaState: {
-            ...this.state.metaState,
-            ...this.props.valuesToOverride,
-          },
-        },
-        () => {
-          this.props.clearValuesToOverride &&
-            this.props.clearValuesToOverride();
-        }
-      );
-    });
-  }
-
-  componentDidUpdate(prevProps: any) {
-    // Override metaState values set from outside FormWrapper
-    if (
-      this.props.valuesToOverride &&
-      !_.isEmpty(this.props.valuesToOverride) &&
-      !_.isEqual(prevProps.valuesToOverride, this.props.valuesToOverride)
-    ) {
-      this.setState(
-        {
-          metaState: {
-            ...this.state.metaState,
-            ...this.props.valuesToOverride,
-          },
-        },
-        () => {
-          // Seems redundant with below but need to ensure no leaked state updates
-          if (
-            !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
-            !_.isEqual(prevProps.formData, this.props.formData)
-          ) {
-            let formHasChanged = !_.isEqual(
-              prevProps.formData,
-              this.props.formData
-            );
-            this.updateTabs(formHasChanged);
-          }
-          this.props.clearValuesToOverride &&
-            this.props.clearValuesToOverride();
-        }
-      );
-    } else if (
-      !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
-      !_.isEqual(prevProps.formData, this.props.formData)
-    ) {
-      if (
-        prevProps.tabOptions?.length === 0 &&
-        !_.isEqual(prevProps.tabOptions, this.props.tabOptions)
-      ) {
-        this.setState({ currentTab: "status" });
-      }
-      let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
-      this.updateTabs(formHasChanged);
-    }
-  }
-
-  isSet = (value: any) => {
-    if (
-      value === null ||
-      value === undefined ||
-      value === "" ||
-      value === false
-    ) {
-      return false;
-    }
-    return true;
-  };
-
-  isDisabled = () => {
-    if (this.props.saveValuesStatus == "loading") {
-      return true;
-    }
-
-    let requiredMissing = false;
-    this.state.requiredFields?.forEach((requiredKey: string, i: number) => {
-      if (!this.isSet(this.state.metaState[requiredKey]?.value)) {
-        requiredMissing = true;
-      }
-    });
-    return requiredMissing;
-  };
-
-  renderTabContents = () => {
-    let tabs = this.props.formData?.tabs;
-    if (tabs) {
-      let matchedTab = null as any;
-      tabs.forEach((tab: any, i: number) => {
-        if (tab?.name === this.state.currentTab) {
-          matchedTab = tab;
-        }
-      });
-      if (matchedTab) {
-        return (
-          <ValuesForm
-            externalValues={this.props.externalValues}
-            disabled={this.props.isReadOnly}
-            metaState={this.state.metaState}
-            setMetaState={(key: string, value: any) => {
-              let metaState: any = this.state.metaState;
-              metaState[key] = { value };
-              this.setState({ metaState });
-            }}
-            sections={matchedTab.sections}
-          />
-        );
-      }
-    }
-
-    // If no form tabs match, check against external tabs
-    if (this.props.renderTabContents) {
-      // TODO: find a cleaner way to share submissionValues w/ rerun button
-      let submissionValues: any = {};
-      Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
-        submissionValues[key] = this.state.metaState[key]?.value;
-      });
-
-      return this.props.renderTabContents(
-        this.state.currentTab,
-        submissionValues
-      );
-    }
-    return <div>No matched tabs found.</div>;
-  };
-
-  renderStateDebugger = () => {
-    if (this.props.showStateDebugger) {
-      return (
-        <>
-          <StateDisplay>
-            <Header>FormWrapper State</Header>
-            <ScrollWrapper>
-              {JSON.stringify(this.state.metaState, undefined, 2)}
-            </ScrollWrapper>
-          </StateDisplay>
-        </>
-      );
-    }
-  };
-
-  handleSubmit = () => {
-    // Extract metaState values
-    let submissionValues: any = {};
-    Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
-      submissionValues[key] = this.state.metaState[key]?.value;
-    });
-
-    this.props.onSubmit && this.props.onSubmit(submissionValues);
-  };
-
-  showSaveButton = (): boolean => {
-    if (this.props.isReadOnly || this.state.tabOptions?.length === 0) {
-      return false;
-    }
-
-    let tabs = this.props.formData?.tabs;
-    if (tabs) {
-      let matchedTab = null as any;
-      tabs.forEach((tab: any, i: number) => {
-        if (tab?.name === this.state.currentTab) {
-          matchedTab = tab;
-        }
-      });
-      if (matchedTab) {
-        return true;
-      }
-    }
-
-    // Check if current tab is among non-form tab options
-    let nonFormTabValues = this.props.tabOptions?.map((tab: any, i: number) => {
-      return tab.value;
-    });
-    if (nonFormTabValues && nonFormTabValues.includes(this.state.currentTab)) {
-      return false;
-    }
-    return true;
-  };
-
-  renderContents = (showSave: boolean) => {
-    return (
-      <>
-        <TabRegion
-          options={this.state.tabOptions}
-          currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          addendum={this.props.addendum}
-          color={this.props.color}
-        >
-          {this.renderTabContents()}
-        </TabRegion>
-        {showSave && (
-          <SaveButton
-            disabled={this.isDisabled()}
-            text={this.props.saveButtonText || "Deploy"}
-            onClick={this.handleSubmit}
-            status={
-              this.isDisabled() && this.props.saveValuesStatus != "loading"
-                ? "Missing required fields"
-                : this.props.saveValuesStatus
-            }
-            makeFlush={!this.props.isInModal}
-          />
-        )}
-        {this.renderStateDebugger()}
-      </>
-    );
-  };
-
-  render() {
-    let showSave = this.showSaveButton();
-    return (
-      <>
-        {this.props.isInModal ? (
-          <StyledValuesWrapper showSave={showSave}>
-            {this.renderContents(showSave)}
-          </StyledValuesWrapper>
-        ) : (
-          <PaddedWrapper>
-            <StyledValuesWrapper showSave={showSave}>
-              {this.renderContents(showSave)}
-            </StyledValuesWrapper>
-          </PaddedWrapper>
-        )}
-      </>
-    );
-  }
-}
-
-FormWrapper.contextType = Context;
-
-const Spacer = styled.div`
-  width: 100%;
-  height: 200px;
-  background: red;
-  position: relative;
-`;
-
-const TabWrapper = styled.div`
-  min-height: 100px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-`;
-
-const ScrollWrapper = styled.div`
-  padding: 20px;
-  overflow-y: auto;
-  max-height: 300px;
-  padding-top: 15px;
-`;
-
-const Header = styled.div`
-  width: 100%;
-  height: 40px;
-  color: #ffffff;
-  font-weight: 500;
-  padding-left: 17px;
-  background: #00000022;
-  display: flex;
-  align-items: center;
-`;
-
-const StateDisplay = styled.pre`
-  width: 100%;
-  font-size: 13px;
-  display:
-  overflow: hidden;
-  border-radius: 5px;
-  position: relative;
-  line-height: 1.5em;
-  color: #aaaabb;
-  background: #ffffff11;
-`;
-
-const StyledValuesWrapper = styled.div<{ showSave: boolean }>`
-  width: 100%;
-  padding: 0;
-  height: ${(props) => (props.showSave ? "calc(100% - 55px)" : "100%")};
-`;
-
-const PaddedWrapper = styled.div`
-  padding-bottom: 65px;
-  position: relative;
-`;

+ 0 - 166
dashboard/src/components/values-form/InputArray.tsx

@@ -1,166 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-type PropsType = {
-  label?: string;
-  values: string[];
-  setValues: (x: string[]) => void;
-  width?: string;
-  disabled?: boolean;
-};
-
-type StateType = {};
-
-export default class InputArray extends Component<PropsType, StateType> {
-  dict2arr = (dict: Record<string, any>) => {
-    let arr = [];
-    for (let key in dict) {
-      arr.push(`${key}: ${dict[key]}`);
-    }
-    return arr;
-  };
-
-  renderDeleteButton = (values: string[], i: number) => {
-    if (!this.props.disabled) {
-      return (
-        <DeleteButton
-          onClick={() => {
-            let v = [...values];
-            v.splice(i, 1);
-            this.props.setValues(v);
-          }}
-        >
-          <i className="material-icons">cancel</i>
-        </DeleteButton>
-      );
-    }
-  };
-
-  renderInputList = (values: string[]) => {
-    return (
-      <>
-        {values.map((value: string, i: number) => {
-          return (
-            <InputWrapper>
-              <Input
-                placeholder=""
-                width="270px"
-                value={value}
-                onChange={(e: any) => {
-                  let v = [...values];
-                  v[i] = e.target.value;
-                  this.props.setValues(v);
-                }}
-                disabled={this.props.disabled}
-              />
-              {this.renderDeleteButton(values, i)}
-            </InputWrapper>
-          );
-        })}
-      </>
-    );
-  };
-
-  render() {
-    let { values } = this.props;
-
-    if (!Array.isArray(values)) {
-      values = this.dict2arr(values);
-    }
-
-    return (
-      <StyledInputArray>
-        <Label>{this.props.label}</Label>
-        {values.length === 0 ? <></> : this.renderInputList(values)}
-        <AddRowButton
-          onClick={() => {
-            let v = [...values];
-            v.push("");
-            this.props.setValues(v);
-          }}
-        >
-          <i className="material-icons">add</i> Add Row
-        </AddRowButton>
-      </StyledInputArray>
-    );
-  }
-}
-
-const AddRowButton = styled.div`
-  display: flex;
-  align-items: center;
-  margin-top: 5px;
-  width: 270px;
-  font-size: 13px;
-  color: #aaaabb;
-  height: 30px;
-  border-radius: 3px;
-  cursor: pointer;
-  background: #ffffff11;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: #ffffff44;
-    font-size: 16px;
-    margin-left: 8px;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-`;
-
-const DeleteButton = styled.div`
-  width: 15px;
-  height: 15px;
-  display: flex;
-  align-items: center;
-  margin-left: 8px;
-  margin-top: -3px;
-  justify-content: center;
-
-  > i {
-    font-size: 17px;
-    color: #ffffff44;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    cursor: pointer;
-    :hover {
-      color: #ffffff88;
-    }
-  }
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  margin-bottom: 5px;
-  font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { disabled?: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled?: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
-  padding: 5px 10px;
-  height: 35px;
-`;
-
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-`;
-
-const StyledInputArray = styled.div`
-  margin-bottom: 15px;
-  margin-top: 22px;
-`;

+ 0 - 69
dashboard/src/components/values-form/RangeSlider.tsx

@@ -1,69 +0,0 @@
-import React, { ChangeEvent, Component } from "react";
-import Slider from "@material-ui/core/Slider";
-import styled from "styled-components";
-
-type PropsType = {};
-
-type StateType = {};
-
-export default class RangeSelector extends Component<PropsType, StateType> {
-  state = {};
-
-  render() {
-    return (
-      <StyledInputRow>
-        <Label>XYZ</Label>
-        <Slider
-          value={12}
-          onChange={() => console.log("xyz")}
-          valueLabelDisplay="auto"
-          aria-labelledby="range-slider"
-        />
-      </StyledInputRow>
-    );
-  }
-}
-
-const Required = styled.div`
-  margin-left: 8px;
-  color: #fc4976;
-`;
-
-const Unit = styled.div`
-  margin-left: 8px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  margin-bottom: -1px;
-  align-items: center;
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { disabled: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
-  padding: 5px 10px;
-  height: 35px;
-`;
-
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-`;
-
-const StyledInputRow = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;

+ 0 - 412
dashboard/src/components/values-form/ValuesForm.tsx

@@ -1,412 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import {
-  Section,
-  FormElement,
-  ShowIf,
-  ShowIfOr,
-  ShowIfAnd,
-  ShowIfNot,
-} from "shared/types";
-import { Context } from "shared/Context";
-
-import CheckboxRow from "./CheckboxRow";
-import InputRow from "./InputRow";
-import Base64InputRow from "./Base64InputRow";
-import SelectRow from "./SelectRow";
-import Helper from "./Helper";
-import Heading from "./Heading";
-import ExpandableResource from "../ExpandableResource";
-import ServiceRow from "./ServiceRow";
-import VeleroForm from "../forms/VeleroForm";
-import InputArray from "./InputArray";
-import KeyValueArray from "./KeyValueArray";
-
-type PropsType = {
-  sections?: Section[];
-  metaState?: any;
-  setMetaState?: (key: string, value: any) => void;
-  handleEnvChange?: (x: any) => void;
-  disabled?: boolean;
-  externalValues?: any;
-};
-
-type StateType = any;
-
-// Requires an internal representation unlike other values components because metaState value underdetermines input order
-export default class ValuesForm extends Component<PropsType, StateType> {
-  getInputValue = (item: FormElement) => {
-    if (item) {
-      let key = item.name || item.variable;
-      let value = this.props.metaState[key]?.value;
-
-      if (
-        item.settings &&
-        item.settings.unit &&
-        value &&
-        value.includes &&
-        !item.settings.omitUnitFromValue
-      ) {
-        value = value.split(item.settings.unit)[0];
-      }
-      return value;
-    }
-  };
-
-  renderSection = (section: Section) => {
-    return section.contents?.map((item: FormElement, i: number) => {
-      if (!item) {
-        return;
-      }
-
-      // If no name is assigned use values.yaml variable as identifier
-      let key = item.name || item.variable;
-      let isDisabled =
-        item.settings?.disableAfterLaunch &&
-        !this.props.externalValues?.isLaunch;
-      isDisabled = isDisabled || this.props.disabled;
-
-      switch (item.type) {
-        case "heading":
-          return (
-            <Heading key={i} docs={item.settings?.docs}>
-              {item.label}
-            </Heading>
-          );
-        case "subtitle":
-          return <Helper key={i}>{item.label}</Helper>;
-        case "service-ip-list":
-          if (Array.isArray(item.value)) {
-            return (
-              <ResourceList key={key}>
-                {item.value?.map((service: any, i: number) => {
-                  return <ServiceRow service={service} key={i} />;
-                })}
-              </ResourceList>
-            );
-          }
-        case "resource-list":
-          if (Array.isArray(item.value)) {
-            return (
-              <ResourceList key={key}>
-                {item.value?.map((resource: any, i: number) => {
-                  if (resource.data) {
-                    return (
-                      <ExpandableResource
-                        key={i}
-                        resource={resource}
-                        isLast={i === item.value.length - 1}
-                        roundAllCorners={true}
-                      />
-                    );
-                  }
-                })}
-              </ResourceList>
-            );
-          }
-        case "checkbox":
-          return (
-            <CheckboxRow
-              key={key}
-              disabled={isDisabled}
-              isRequired={item.required}
-              checked={this.props.metaState[key]?.value}
-              toggle={() =>
-                this.props.setMetaState(key, !this.props.metaState[key]?.value)
-              }
-              label={item.label}
-            />
-          );
-        case "env-key-value-array":
-          return (
-            <KeyValueArray
-              key={key}
-              width="100%"
-              envLoader={true}
-              externalValues={this.props.externalValues}
-              values={this.props.metaState[key]?.value}
-              setValues={(x: any) => {
-                this.props.setMetaState(key, x);
-
-                // Need to pull env vars out of form.yaml for createGHA build env vars
-                if (
-                  this.props.handleEnvChange &&
-                  key === "container.env.normal"
-                ) {
-                  // this.props.handleEnvChange(x);
-                }
-              }}
-              label={item.label}
-              disabled={isDisabled}
-              secretOption={true}
-            />
-          );
-        case "key-value-array":
-          return (
-            <KeyValueArray
-              key={key}
-              width="100%"
-              externalValues={this.props.externalValues}
-              values={this.props.metaState[key]?.value}
-              setValues={(x: any) => this.props.setMetaState(key, x)}
-              label={item.label}
-              disabled={isDisabled}
-            />
-          );
-        case "array-input":
-          return (
-            <InputArray
-              key={key}
-              width="100%"
-              values={this.props.metaState[key]?.value}
-              setValues={(x: string[]) => {
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              disabled={isDisabled}
-            />
-          );
-        case "string-input":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              placeholder={item.placeholder}
-              isRequired={item.required}
-              type="text"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              info={item.info}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "string-input-password":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              placeholder={item.placeholder}
-              isRequired={item.required}
-              type="password"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "number-input":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              isRequired={item.required}
-              placeholder={item.placeholder}
-              type="number"
-              value={this.getInputValue(item)}
-              setValue={(x: number) => {
-                let val: string | number = x;
-                if (Number.isNaN(x)) {
-                  val = "";
-                }
-
-                // Convert to string if unit is set
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  val = x.toString();
-                  val = val + item.settings.unit;
-                }
-
-                this.props.setMetaState(key, val);
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "select":
-          return (
-            <SelectRow
-              key={key}
-              value={this.props.metaState[key]?.value}
-              setActiveValue={(val) => this.props.setMetaState(key, val)}
-              options={item.settings.options}
-              dropdownLabel=""
-              label={item.label}
-            />
-          );
-        case "provider-select":
-          return (
-            <SelectRow
-              key={key}
-              value={this.props.metaState[key]?.value}
-              setActiveValue={(val) => this.props.setMetaState(key, val)}
-              options={[
-                { value: "aws", label: "Amazon Web Services (AWS)" },
-                { value: "gcp", label: "Google Cloud Platform (GCP)" },
-                { value: "do", label: "DigitalOcean" },
-              ]}
-              dropdownLabel=""
-              label={item.label}
-            />
-          );
-        case "velero-create-backup":
-          return <VeleroForm />;
-        case "base-64":
-          return (
-            <Base64InputRow
-              key={key}
-              width="100%"
-              isRequired={item.required}
-              type="text"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, btoa(x));
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "base-64-password":
-          return (
-            <Base64InputRow
-              key={key}
-              isRequired={item.required}
-              type="password"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, btoa(x));
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        default:
-      }
-    });
-  };
-
-  evalShowIf = (vals: ShowIf): boolean => {
-    if (!vals) {
-      return false;
-    }
-    if (typeof vals == "string") {
-      return !!this.props.metaState[vals]?.value;
-    }
-    if ((vals as ShowIfOr).or) {
-      vals = vals as ShowIfOr;
-      for (let i = 0; i < vals.or.length; i++) {
-        if (this.evalShowIf(vals.or[i])) {
-          return true;
-        }
-      }
-      return false;
-    }
-    if ((vals as ShowIfAnd).and) {
-      vals = vals as ShowIfAnd;
-      for (let i = 0; i < vals.and.length; i++) {
-        if (!this.evalShowIf(vals.and[i])) {
-          return false;
-        }
-      }
-      return true;
-    }
-    if ((vals as ShowIfNot).not) {
-      vals = vals as ShowIfNot;
-      return !this.evalShowIf(vals.not);
-    }
-
-    return false;
-  };
-
-  renderFormContents = () => {
-    if (this.props.metaState) {
-      return this.props.sections?.map((section: Section, i: number) => {
-        // Hide collapsible section if deciding field is false
-        if (section.show_if && !this.evalShowIf(section.show_if)) {
-          return null;
-        }
-
-        return <div key={i}>{this.renderSection(section)}</div>;
-      });
-    }
-  };
-
-  render() {
-    return (
-      <StyledValuesForm>
-        <DarkMatter />
-        {this.renderFormContents()}
-      </StyledValuesForm>
-    );
-  }
-}
-
-ValuesForm.contextType = Context;
-
-const ResourceList = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-  border-radius: 5px;
-  overflow: hidden;
-`;
-
-const DarkMatter = styled.div`
-  margin-top: 0px;
-`;
-
-const StyledValuesForm = styled.div`
-  width: 100%;
-  height: 100%;
-  background: #ffffff11;
-  color: #ffffff;
-  padding: 0px 35px 25px;
-  position: relative;
-  border-radius: 5px;
-  font-size: 13px;
-  overflow: auto;
-`;

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

+ 18 - 6
dashboard/src/main/home/Home.tsx

@@ -255,7 +255,6 @@ class Home extends Component<PropsType, StateType> {
     let { match } = this.props;
     let params = match.params as any;
     let { cluster } = params;
-    console.log("cluster is", cluster);
 
     let { user } = this.context;
 
@@ -486,7 +485,13 @@ class Home extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { currentModal, setCurrentModal, currentProject } = this.context;
+    let {
+      currentModal,
+      setCurrentModal,
+      currentProject,
+      currentOverlay,
+      setCurrentOverlay,
+    } = this.context;
 
     return (
       <StyledHome>
@@ -573,6 +578,15 @@ class Home extends Component<PropsType, StateType> {
           </Modal>
         )}
 
+        {currentOverlay && (
+          <ConfirmOverlay
+            show={true}
+            message={currentOverlay.message}
+            onYes={currentOverlay.onYes}
+            onNo={currentOverlay.onNo}
+          />
+        )}
+
         {this.renderSidebar()}
 
         <ViewWrapper>
@@ -605,7 +619,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 +629,8 @@ const ViewWrapper = styled.div`
 `;
 
 const DashboardWrapper = styled.div`
-  width: 80%;
-  padding-top: 50px;
+  width: calc(85%);
   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;
-  }
-`;

+ 4 - 4
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -252,8 +252,8 @@ const StyledChart = styled.div`
   cursor: pointer;
   margin-bottom: 25px;
   padding: 1px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: calc(100% + 2px);
@@ -271,7 +271,7 @@ const StyledChart = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;
@@ -304,7 +304,7 @@ const StyledChart = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;

+ 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: 370px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 105px;
+`;

+ 11 - 5
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -1,8 +1,8 @@
 import React, { useContext, useState } from "react";
 import styled from "styled-components";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
-import InputRow from "components/values-form/InputRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
@@ -122,6 +122,7 @@ const ClusterSettings: React.FC = () => {
     <div>
       <StyledSettingsSection showSource={false}>
         {keyRotationSection}
+        <DarkMatter />
         <Heading>Delete Cluster</Heading>
         {helperText}
         <Button
@@ -137,14 +138,19 @@ const ClusterSettings: React.FC = () => {
 
 export default ClusterSettings;
 
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -15px;
+`;
+
 const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   margin-top: 35px;
   width: 100%;
   background: #ffffff11;
   padding: 0 35px;
-  padding-bottom: 50px;
+  padding-bottom: 15px;
   position: relative;
-  border-radius: 5px;
+  border-radius: 8px;
   overflow: auto;
   height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
 `;

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

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

@@ -306,8 +306,8 @@ const StyledCard = styled.div`
   justify-content: space-between;
   align-items: center;
   border: 1px solid #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
-  border-radius: 5px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  border-radius: 8px;
   padding: 14px;
   animation: fadeIn 0.5s;
   @keyframes fadeIn {

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

@@ -146,8 +146,8 @@ const NodeListWrapper = styled.div`
 const StyledChart = styled.div`
   background: #26282f;
   padding: 14px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: 100%;

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

@@ -59,5 +59,5 @@ export const ConditionsTable: React.FunctionComponent<NodeStatusModalProps> = ({
 };
 
 const TableWrapper = styled.div`
-  margin-top: 14px;
+  margin-top: 36px;
 `;

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

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useHistory, useLocation, useParams } from "react-router";
 import styled from "styled-components";
-import closeImg from "assets/close.png";
+import backArrow from "assets/back_arrow.png";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
@@ -11,6 +11,7 @@ import { pushFiltered } from "shared/routing";
 import NodeUsage from "./NodeUsage";
 import { ConditionsTable } from "./ConditionsTable";
 import StatusSection from "components/StatusSection";
+import TitleSection from "components/TitleSection";
 
 type ExpandedNodeViewParams = {
   nodeId: string;
@@ -90,54 +91,73 @@ export const ExpandedNodeView = () => {
   }, [node]);
 
   return (
-    <>
-      <CloseOverlay onClick={closeNodeView} />
-      <StyledExpandedChart>
-        <HeaderWrapper>
-          <TitleSection>
-            <Title>
-              <IconWrapper>
-                <img src={nodePng} />
-              </IconWrapper>
-              {nodeId}
-              <InstanceType>{instanceType}</InstanceType>
-            </Title>
-          </TitleSection>
-
-          <CloseButton onClick={closeNodeView}>
-            <CloseButtonImg src={closeImg} />
-          </CloseButton>
-        </HeaderWrapper>
-        <BodyWrapper>
-          <NodeUsage node={node} />
-
-          <StatusWrapper>
-            <StatusSection status={nodeStatus} />
-          </StatusWrapper>
-
-          <TabSelector
-            options={tabOptions}
-            currentTab={currentTab}
-            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
-          />
-          {currentTabPage}
-        </BodyWrapper>
-      </StyledExpandedChart>
-    </>
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <BackButton onClick={closeNodeView}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={nodePng}>
+          {nodeId}
+          <InstanceType>{instanceType}</InstanceType>
+        </TitleSection>
+      </HeaderWrapper>
+      <BodyWrapper>
+        <NodeUsage node={node} />
+
+        <StatusWrapper>
+          <StatusSection status={nodeStatus} />
+        </StatusWrapper>
+
+        <TabSelector
+          options={tabOptions}
+          currentTab={currentTab}
+          setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+        />
+        {currentTabPage}
+      </BodyWrapper>
+    </StyledExpandedNodeView>
   );
 };
 
 export default ExpandedNodeView;
 
+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 StatusWrapper = styled.div`
   margin-left: 3px;
-  margin-bottom: 15px;
+  margin-bottom: 20px;
 `;
 
 const InstanceType = styled.div`
   font-weight: 400;
   color: #ffffff44;
   margin-left: 12px;
+  font-size: 16px;
 `;
 
 const BodyWrapper = styled.div`
@@ -146,104 +166,28 @@ const BodyWrapper = styled.div`
   overflow: hidden;
 `;
 
-const HeaderWrapper = 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 IconWrapper = styled.div`
-  font-size: 16px;
-  height: 20px;
-  width: 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 3px;
-  margin-right: 12px;
-
-  > img {
-    filter: brightness(50%);
-    width: 18px;
-  }
-`;
-
-const Title = styled.div`
-  font-size: 18px;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-  user-select: text;
-`;
-
-const TitleSection = styled.div`
-  width: 100%;
+const HeaderWrapper = styled.div`
   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);
+const StyledExpandedNodeView = styled.div`
+  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;
+  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 - 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>

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

@@ -7,10 +7,10 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Selector from "components/Selector";
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import { isAlphanumeric } from "shared/common";
 
@@ -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;

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

@@ -208,8 +208,8 @@ const StyledEnvGroup = styled.div`
   cursor: pointer;
   margin-bottom: 25px;
   padding: 1px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: calc(100% + 2px);
@@ -227,7 +227,7 @@ const StyledEnvGroup = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;
@@ -260,7 +260,7 @@ const StyledEnvGroup = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;

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

@@ -144,7 +144,7 @@ const Placeholder = styled.div`
   color: #ffffff44;
   background: #26282f;
   border-radius: 5px;
-  height: 320px;
+  height: 370px;
   display: flex;
   align-items: center;
   justify-content: center;

+ 273 - 219
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -1,20 +1,24 @@
 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 loading from "assets/loading.gif";
 
 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";
 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 Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = WithAuthProps & {
@@ -27,11 +31,17 @@ type PropsType = WithAuthProps & {
 type StateType = {
   loading: boolean;
   currentTab: string | null;
-  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 = [
@@ -43,32 +53,50 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
   state = {
     loading: true,
     currentTab: "environment",
-    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 +114,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;
+
+    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 apiEnvVariables: Record<string, string> = {};
-    let secretEnvVariables: Record<string, string> = {};
+  handleUpdateValues = () => {
+    const { namespace } = this.props;
+    const {
+      envGroup: { name, variables: envVariables },
+    } = this.state;
 
-    let envVariables = this.state.envVariables;
+    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 +223,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 +245,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 +278,32 @@ 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>
+
+                <DarkMatter />
+
                 <Heading>Manage Environment Group</Heading>
                 <Helper>
                   Permanently delete this set of environment variables. This
@@ -223,7 +311,13 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 </Helper>
                 <Button
                   color="#b91133"
-                  onClick={() => this.setState({ showDeleteOverlay: true })}
+                  onClick={() => {
+                    this.context.setCurrentOverlay({
+                      message: `Are you sure you want to delete ${this.state.envGroup.name}?`,
+                      onYes: this.handleDeleteEnvGroup,
+                      onNo: () => this.context.setCurrentOverlay(null),
+                    });
+                  }}
                 >
                   Delete {name}
                 </Button>
@@ -235,9 +329,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,11 +339,13 @@ 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 });
+    this.context.setCurrentOverlay(null);
     api
       .deleteConfigMap(
         "<token>",
@@ -265,71 +361,61 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
         this.setState({ deleting: false });
       })
       .catch((err) => {
-        this.setState({ deleting: false, showDeleteOverlay: false });
+        this.setState({ deleting: false });
       });
   };
 
-  renderDeleteOverlay = () => {
-    if (this.state.deleting) {
-      return (
-        <DeleteOverlay>
-          <Loading />
-        </DeleteOverlay>
-      );
-    }
-  };
-
   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>
-          <ConfirmOverlay
-            show={this.state.showDeleteOverlay}
-            message={`Are you sure you want to delete ${name}?`}
-            onYes={this.handleDeleteEnvGroup}
-            onNo={() => this.setState({ showDeleteOverlay: false })}
-          />
-          {this.renderDeleteOverlay()}
-
           <HeaderWrapper>
-            <TitleSection>
-              <Title>
-                <IconWrapper>
-                  <Icon src={key} />
-                </IconWrapper>
-                {name}
-              </Title>
-              <InfoWrapper>
-                <LastDeployed>
-                  Last updated {this.readableDate(timestamp)}
-                </LastDeployed>
-              </InfoWrapper>
-
+            <BackButton onClick={closeExpanded}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+            <TitleSection icon={key} iconWidth="33px">
+              {name}
               <TagWrapper>
                 Namespace <NamespaceTag>{namespace}</NamespaceTag>
               </TagWrapper>
             </TitleSection>
-
-            <CloseButton onClick={closeExpanded}>
-              <CloseButtonImg src={close} />
-            </CloseButton>
           </HeaderWrapper>
 
-          <TabRegion
-            currentTab={this.state.currentTab}
-            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={this.state.tabOptions}
-            color={null}
-          >
-            {this.renderTabContents()}
-          </TabRegion>
+          <InfoWrapper>
+            <LastDeployed>
+              Last updated {this.readableDate(timestamp)}
+            </LastDeployed>
+          </InfoWrapper>
+
+          {this.state.deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loading} /> Deleting "
+                    {this.state.envGroup.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <TabRegion
+              currentTab={this.state.currentTab}
+              setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+              options={this.state.tabOptions}
+              color={null}
+            >
+              {this.renderTabContents()}
+            </TabRegion>
+          )}
         </StyledExpandedChart>
       </>
     );
@@ -340,6 +426,73 @@ ExpandedEnvGroup.contextType = Context;
 
 export default withAuth(ExpandedEnvGroup);
 
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Placeholder = styled.div`
+  min-height: 400px;
+  height: 50vh;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const TextWrap = styled.div``;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 15px 0px 55px;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+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;
@@ -370,81 +523,23 @@ const InnerWrapper = styled.div<{ full?: boolean }>`
   height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};
   background: #ffffff11;
   padding: 0 35px;
-  padding-bottom: 50px;
+  padding-bottom: 15px;
   position: relative;
-  border-radius: 5px;
+  border-radius: 8px;
   overflow: auto;
 `;
 
 const TabWrapper = styled.div`
   height: 100%;
   width: 100%;
+  padding-bottom: 65px;
   overflow: hidden;
 `;
 
-const DeleteOverlay = styled.div`
-  position: absolute;
-  top: 0px;
-  opacity: 100%;
-  left: 0px;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-  display: flex;
-  padding-bottom: 30px;
-  align-items: center;
-  justify-content: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 18px;
-  font-weight: 500;
-  color: white;
-  flex-direction: column;
-  background: rgb(0, 0, 0, 0.73);
-  opacity: 0;
-  animation: lindEnter 0.2s;
-  animation-fill-mode: forwards;
-
-  @keyframes lindEnter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-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 +553,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 +584,44 @@ 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;
+  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;
+`;

+ 303 - 223
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";
 
@@ -21,10 +21,9 @@ import {
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import StatusIndicator from "components/StatusIndicator";
-import FormWrapper from "components/values-form/FormWrapper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import RevisionSection from "./RevisionSection";
 import ValuesYaml from "./ValuesYaml";
 import GraphSection from "./GraphSection";
@@ -34,6 +33,8 @@ 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";
+import { integrationList } from "shared/common";
 
 type Props = {
   namespace: string;
@@ -59,12 +60,14 @@ 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>(
     localStorage.getItem("devOpsMode") === "true"
   );
-  const [tabOptions, setTabOptions] = useState<any[]>([]);
+  const [rightTabOptions, setRightTabOptions] = useState<any[]>([]);
+  const [leftTabOptions, setLeftTabOptions] = useState<any[]>([]);
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
@@ -73,12 +76,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
     Record<string, Record<string, any>>
   >({});
   const [url, setUrl] = useState<string>(null);
-  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
   const [deleting, setDeleting] = useState<boolean>(false);
   const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
   const [newestImage, setNewestImage] = useState<string>(null);
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
-
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
 
   const {
@@ -88,9 +90,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
     closeWebsocket,
   } = useWebsockets();
 
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentError,
+    setCurrentOverlay,
+  } = useContext(Context);
 
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
@@ -151,7 +156,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
         setControllers((oldControllers) => ({
           ...oldControllers,
-          [c.metadata.kind]: c,
+          [c.metadata.uid]: c,
         }));
       });
 
@@ -170,21 +175,26 @@ const ExpandedChart: React.FC<Props> = (props) => {
     const wsConfig = {
       onmessage(evt: MessageEvent) {
         const event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
 
-        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,
-            };
-          });
+        if (event.event_type != "UPDATE") {
+          return;
         }
+
+        setControllers((oldControllers) => {
+          if (
+            oldControllers &&
+            oldControllers[object.metadata.uid]?.status?.conditions ==
+              object.status?.conditions
+          ) {
+            return oldControllers;
+          }
+          return {
+            ...oldControllers,
+            [object.metadata.uid]: object,
+          };
+        });
       },
       onerror() {
         closeWebsocket(kind);
@@ -195,6 +205,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const updateComponents = async (currentChart: ChartType) => {
+    setLoading(true);
     try {
       const res = await api.getChartComponents(
         "<token>",
@@ -210,12 +221,15 @@ const ExpandedChart: React.FC<Props> = (props) => {
         }
       );
       setComponents(res.data.Objects);
+      setLoading(false);
     } catch (error) {
       console.log(error);
+      setLoading(false);
     }
   };
 
   const onSubmit = async (rawValues: any) => {
+    console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     let values = {};
 
@@ -239,6 +253,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     setSaveValueStatus("loading");
     getChartData(currentChart);
+    console.log("valuesYaml", valuesYaml)
     try {
       await api.upgradeChartValues(
         "<token>",
@@ -378,7 +393,17 @@ const ExpandedChart: React.FC<Props> = (props) => {
           <SettingsSection
             currentChart={chart}
             refreshChart={() => getChartData(currentChart)}
-            setShowDeleteOverlay={(x: boolean) => setShowDeleteOverlay(x)}
+            setShowDeleteOverlay={(x: boolean) => {
+              if (x) {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete ${currentChart.name}?`,
+                  onYes: handleUninstallChart,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              } else {
+                setCurrentOverlay(null);
+              }
+            }}
           />
         );
       case "graph":
@@ -414,17 +439,18 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   const updateTabs = () => {
     // Collate non-form tabs
-    let tabOptions = [] as any[];
-    tabOptions.push({ label: "Status", value: "status" });
+    let rightTabOptions = [] as any[];
+    let leftTabOptions = [] as any[];
+    leftTabOptions.push({ label: "Status", value: "status" });
 
     if (props.isMetricsInstalled) {
-      tabOptions.push({ label: "Metrics", value: "metrics" });
+      leftTabOptions.push({ label: "Metrics", value: "metrics" });
     }
 
-    tabOptions.push({ label: "Chart Overview", value: "graph" });
+    rightTabOptions.push({ label: "Chart Overview", value: "graph" });
 
     if (devOpsMode) {
-      tabOptions.push(
+      rightTabOptions.push(
         { label: "Manifests", value: "list" },
         { label: "Helm Values", value: "values" }
       );
@@ -432,18 +458,22 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     // Settings tab is always last
     if (isAuthorized("application", "", ["get", "delete"])) {
-      tabOptions.push({ label: "Settings", value: "settings" });
+      rightTabOptions.push({ label: "Settings", value: "settings" });
     }
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (isPreview) {
       let liveTabs = ["status", "settings", "deploy", "metrics"];
-      tabOptions = tabOptions.filter(
+      rightTabOptions = rightTabOptions.filter(
+        (tab: any) => !liveTabs.includes(tab.value)
+      );
+      leftTabOptions = leftTabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
     }
 
-    setTabOptions(tabOptions);
+    setLeftTabOptions(leftTabOptions);
+    setRightTabOptions(rightTabOptions);
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
@@ -523,7 +553,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       return c.Kind === "Service";
     });
 
-    if (!service?.Name || !service?.Namespace) {
+    if (loading) {
       return (
         <Url>
           <Bolded>Loading...</Bolded>
@@ -531,6 +561,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
+    if (!service?.Name || !service?.Namespace) {
+      return;
+    }
+
     return (
       <Url>
         <Bolded>Internal URI:</Bolded>
@@ -541,6 +575,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   const handleUninstallChart = async () => {
     setDeleting(true);
+    setCurrentOverlay(null);
     try {
       await api.uninstallTemplate(
         "<token>",
@@ -553,7 +588,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
           cluster_id: currentCluster.id,
         }
       );
-      setShowDeleteOverlay(false);
       props.closeChart();
     } catch (error) {
       console.log(error);
@@ -630,95 +664,146 @@ const ExpandedChart: React.FC<Props> = (props) => {
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
 
+  const renderDeploymentType = () => {
+    const githubRepository = currentChart?.git_action_config?.git_repo;
+    const icon = githubRepository
+      ? integrationList.repo.icon
+      : integrationList.registry.icon;
+
+    const isWebOrWorkerDeployment = ["web", "worker"].includes(
+      currentChart?.chart?.metadata?.name
+    );
+    if (!isWebOrWorkerDeployment) {
+      return null;
+    }
+
+    const repository =
+      githubRepository ||
+      currentChart?.image_repo_uri ||
+      currentChart?.config?.image?.repository;
+
+    if (repository?.includes("hello-porter")) {
+      return null;
+    }
+
+    return (
+      <DeploymentImageContainer>
+        <DeploymentTypeIcon src={icon} />
+        <RepositoryName
+          onMouseOver={() => {
+            setShowRepoTooltip(true);
+          }}
+          onMouseOut={() => {
+            setShowRepoTooltip(false);
+          }}
+        >
+          {repository}
+        </RepositoryName>
+        {
+          showRepoTooltip && (
+            <Tooltip>{repository}</Tooltip>
+          )
+        }
+      </DeploymentImageContainer>
+    );
+  };
+
   return (
     <>
-      <CloseOverlay onClick={props.closeChart} />
       <StyledExpandedChart>
-        <ConfirmOverlay
-          show={showDeleteOverlay}
-          message={`Are you sure you want to delete ${currentChart.name}?`}
-          onYes={handleUninstallChart}
-          onNo={() => setShowDeleteOverlay(false)}
-        />
-        {deleting && (
-          <DeleteOverlay>
-            <Loading />
-          </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>
-
+          <BackButton onClick={props.closeChart}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+          <TitleSection
+            icon={currentChart.chart.metadata.icon}
+            iconWidth="33px"
+          >
+            {currentChart.name}
+            {renderDeploymentType()}
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
           </TitleSection>
 
-          <CloseButton onClick={props.closeChart}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-
-          <RevisionSection
-            showRevisions={showRevisions}
-            toggleShowRevisions={() => {
-              setShowRevisions(!showRevisions);
-            }}
-            chart={currentChart}
-            refreshChart={() => getChartData(currentChart)}
-            setRevision={setRevision}
-            forceRefreshRevisions={forceRefreshRevisions}
-            refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-            status={chartStatus}
-            shouldUpdate={
-              currentChart.latest_version &&
-              currentChart.latest_version !==
-                currentChart.chart.metadata.version
-            }
-            latestVersion={currentChart.latest_version}
-            upgradeVersion={handleUpgradeVersion}
-          />
+          {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>
         </HeaderWrapper>
-        <BodyWrapper>
-          <FormWrapper
-            isReadOnly={
-              imageIsPlaceholder ||
-              !isAuthorized("application", "", ["get", "update"])
-            }
-            formData={currentChart.form}
-            tabOptions={tabOptions}
-            isInModal={true}
-            renderTabContents={renderTabContents}
-            onSubmit={onSubmit}
-            saveValuesStatus={saveValuesStatus}
-            externalValues={{
-              namespace: props.namespace,
-              clusterId: currentCluster.id,
-            }}
-            color={isPreview ? "#f5cb42" : null}
-            addendum={
-              <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
-                <i className="material-icons">offline_bolt</i> DevOps Mode
-              </TabButton>
+        {deleting ? (
+          <>
+            <LineBreak />
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
+                </Header>
+                You will be automatically redirected after deletion is complete.
+              </TextWrap>
+            </Placeholder>
+          </>
+        ) : (
+          <>
+            <RevisionSection
+              showRevisions={showRevisions}
+              toggleShowRevisions={() => {
+                setShowRevisions(!showRevisions);
+              }}
+              chart={currentChart}
+              refreshChart={() => getChartData(currentChart)}
+              setRevision={setRevision}
+              forceRefreshRevisions={forceRefreshRevisions}
+              refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+              status={chartStatus}
+              shouldUpdate={
+                currentChart.latest_version &&
+                currentChart.latest_version !==
+                  currentChart.chart.metadata.version
+              }
+              latestVersion={currentChart.latest_version}
+              upgradeVersion={handleUpgradeVersion}
+            />
+            {
+              (isPreview || leftTabOptions.length > 0) && (
+                <BodyWrapper>
+                  <PorterFormWrapper
+                    formData={currentChart.form}
+                    valuesToOverride={{
+                      namespace: props.namespace,
+                      clusterId: currentCluster.id,
+                    }}
+                    renderTabContents={renderTabContents}
+                    isReadOnly={
+                      imageIsPlaceholder ||
+                      !isAuthorized("application", "", ["get", "update"])
+                    }
+                    onSubmit={onSubmit}
+                    rightTabOptions={rightTabOptions}
+                    leftTabOptions={leftTabOptions}
+                    color={isPreview ? "#f5cb42" : null}
+                    addendum={
+                      <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
+                        <i className="material-icons">offline_bolt</i> DevOps Mode
+                      </TabButton>
+                    }
+                    saveValuesStatus={saveValuesStatus}
+                  />
+                </BodyWrapper>
+              )
             }
-          />
-        </BodyWrapper>
+          </>
+        )}
       </StyledExpandedChart>
     </>
   );
@@ -726,8 +811,83 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 export default ExpandedChart;
 
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: -40px;
+  top: 28px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
 const TextWrap = styled.div``;
 
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 35px 0px;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  overflow: hidden;
+  margin-bottom: 120px;
+`;
+
+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;
@@ -736,7 +896,8 @@ const Header = styled.div`
 `;
 
 const Placeholder = styled.div`
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   padding: 30px;
   padding-bottom: 90px;
   font-size: 13px;
@@ -754,44 +915,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;
-  opacity: 100%;
-  left: 0px;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-  display: flex;
-  padding-bottom: 30px;
-  align-items: center;
-  justify-content: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 18px;
-  font-weight: 500;
-  color: white;
-  flex-direction: column;
-  background: rgb(0, 0, 0, 0.73);
-  opacity: 0;
-  animation: lindEnter 0.2s;
-  animation-fill-mode: forwards;
-
-  @keyframes lindEnter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
 const Bolded = styled.div`
   font-weight: 500;
   color: #ffffff44;
@@ -819,7 +942,7 @@ const TabButton = styled.div`
   position: absolute;
   right: 0px;
   height: 30px;
-  background: linear-gradient(to right, #26282f00, #26282f 20%);
+  background: linear-gradient(to right, #20222700, #202227 20%);
   padding-left: 30px;
   display: flex;
   align-items: center;
@@ -844,28 +967,10 @@ 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`
+  position: relative;
 `;
 
-const HeaderWrapper = styled.div``;
-
 const Dot = styled.div`
   margin-right: 9px;
 `;
@@ -887,13 +992,13 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  bottom: 0px;
-  right: 0px;
   height: 20px;
   font-size: 12px;
   display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
   align-items: center;
+  font-weight: 400;
   justify-content: center;
   color: #ffffff44;
   border: 1px solid #ffffff44;
@@ -938,66 +1043,41 @@ 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%;
+  overflow: hidden;
   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;
+  animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  padding: 25px;
   display: flex;
-  overflow: hidden;
   flex-direction: column;
 
-  @keyframes floatIn {
+  @keyframes fadeIn {
     from {
       opacity: 0;
-      transform: translateY(30px);
     }
     to {
       opacity: 1;
-      transform: translateY(0px);
     }
   }
 `;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;

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

@@ -93,7 +93,11 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { baseRoute, namespace } = match.params as any;
     let { loading, currentChart } = this.state;
     if (loading) {
-      return <Loading />;
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
     } else if (currentChart && baseRoute === "jobs") {
       return (
         <ExpandedJobChart
@@ -134,10 +138,8 @@ ExpandedChartWrapper.contextType = Context;
 
 export default withRouter(ExpandedChartWrapper);
 
-const NotFoundPlaceholder = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
+const LoadingWrapper = styled.div`
   width: 100%;
   height: 100%;
+  margin-top: -50px;
 `;

+ 150 - 215
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";
 
@@ -10,12 +11,11 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 
 import SaveButton from "components/SaveButton";
-import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
-import TabRegion from "components/TabRegion";
+import TitleSection from "components/TitleSection";
 import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
-import FormWrapper from "components/values-form/FormWrapper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { PlaceHolder } from "brace";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
@@ -33,15 +33,14 @@ type StateType = {
   newestImage: string;
   loading: boolean;
   jobs: any[];
-  tabOptions: any[];
+  leftTabOptions: any[];
+  rightTabOptions: any[];
   tabContents: any;
   currentTab: string | null;
   websockets: Record<string, any>;
-  showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
   formData: any;
-  valuesToOverride: any;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -51,15 +50,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     newestImage: null as string,
     loading: true,
     jobs: [] as any[],
-    tabOptions: [] as any[],
+    leftTabOptions: [] as any[],
+    rightTabOptions: [] as any[],
     tabContents: [] as any,
     currentTab: null as string | null,
     websockets: {} as Record<string, any>,
-    showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
-    valuesToOverride: {} as any,
   };
 
   // Retrieve full chart data (includes form and values)
@@ -422,12 +420,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
   renderTabContents = (currentTab: string, submitValues?: any) => {
     let saveButton = (
-      <SaveButton
-        text="Rerun Job"
-        onClick={() => this.handleSaveValues(submitValues, true)}
-        status={this.state.saveValuesStatus}
-        makeFlush={true}
-      />
+      <ButtonWrapper>
+        <SaveButton
+          onClick={() => this.handleSaveValues(submitValues, true)}
+          status={this.state.saveValuesStatus}
+          makeFlush={true}
+          clearPosition={true}
+          rounded={true}
+          statusPosition="right"
+        >
+          <i className="material-icons">play_arrow</i> Run Job
+        </SaveButton>
+      </ButtonWrapper>
     );
 
     if (!this.props.isAuthorized("job", "", ["get", "update", "create"])) {
@@ -451,13 +455,13 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         return (
           <TabWrapper>
+            {saveButton}
             <JobList
               jobs={this.state.jobs}
               setJobs={(jobs: any) => {
                 this.setState({ jobs });
               }}
             />
-            {saveButton}
           </TabWrapper>
         );
       case "settings":
@@ -467,9 +471,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               showSource={true}
               currentChart={this.state.currentChart}
               refreshChart={() => this.refreshChart(0)}
-              setShowDeleteOverlay={(x: boolean) =>
-                this.setState({ showDeleteOverlay: x })
-              }
+              setShowDeleteOverlay={(x: boolean) => {
+                let { setCurrentOverlay } = this.context;
+                if (x) {
+                  setCurrentOverlay({
+                    message: `Are you sure you want to delete ${this.state.currentChart.name}?`,
+                    onYes: this.handleUninstallChart,
+                    onNo: () => setCurrentOverlay(null),
+                  });
+                } else {
+                  setCurrentOverlay(null);
+                }
+              }}
               saveButtonText="Save Config"
             />
           )
@@ -485,43 +498,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         formData,
       });
     }
-    let tabOptions = [] as any[];
-
-    // Append universal tabs
-    tabOptions.push({ label: "Jobs", value: "jobs" });
-
-    if (formData) {
-      formData.tabs.map((tab: any, i: number) => {
-        tabOptions.push({
-          value: tab.name,
-          label: tab.label,
-          sections: tab.sections,
-          context: tab.context,
-        });
-      });
-    }
-
+    let rightTabOptions = [] as any[];
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
-      tabOptions.push({ label: "Settings", value: "settings" });
+      rightTabOptions.push({ label: "Settings", value: "settings" });
     }
 
     // Filter tabs if previewing an old revision
-    this.setState({ tabOptions });
+    this.setState({
+      leftTabOptions: [{ label: "Jobs", value: "jobs" }],
+      rightTabOptions,
+    });
   }
 
-  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();
@@ -546,9 +534,10 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
   }
 
   handleUninstallChart = () => {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject, currentCluster, setCurrentOverlay } = this.context;
     let { currentChart } = this.state;
     this.setState({ deleting: true });
+    setCurrentOverlay(null);
     api
       .uninstallTemplate(
         "<token>",
@@ -562,22 +551,11 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.setState({ showDeleteOverlay: false });
         this.props.closeChart();
       })
       .catch(console.log);
   };
 
-  renderDeleteOverlay = () => {
-    if (this.state.deleting) {
-      return (
-        <DeleteOverlay>
-          <Loading />
-        </DeleteOverlay>
-      );
-    }
-  };
-
   render() {
     let { closeChart } = this.props;
     let { currentChart } = this.state;
@@ -585,62 +563,71 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
     return (
       <>
-        <CloseOverlay onClick={closeChart} />
         <StyledExpandedChart>
-          <ConfirmOverlay
-            show={this.state.showDeleteOverlay}
-            message={`Are you sure you want to delete ${currentChart.name}?`}
-            onYes={this.handleUninstallChart}
-            onNo={() => this.setState({ showDeleteOverlay: false })}
-          />
-          {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>
-
+            <BackButton onClick={closeChart}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+            <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>
-            <FormWrapper
-              isReadOnly={
-                this.state.imageIsPlaceholder ||
-                !this.props.isAuthorized("job", "", ["get", "update"])
-              }
-              valuesToOverride={this.state.valuesToOverride}
-              clearValuesToOverride={() =>
-                this.setState({ valuesToOverride: {} })
-              }
-              formData={this.state.formData}
-              tabOptions={this.state.tabOptions}
-              isInModal={true}
-              renderTabContents={this.renderTabContents}
-              tabOptionsOnly={true}
-              onSubmit={(formValues) =>
-                this.handleSaveValues(formValues, false)
-              }
-              saveValuesStatus={this.state.saveValuesStatus}
-              saveButtonText="Save Config"
-            />
-          </BodyWrapper>
+          {this.state.deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loading} /> Deleting "{currentChart.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <BodyWrapper>
+              {(this.state.leftTabOptions?.length > 0 ||
+                this.state.formData.tabs?.length > 0 ||
+                this.state.rightTabOptions?.length > 0) && (
+                <PorterFormWrapper
+                  formData={this.state.formData}
+                  valuesToOverride={{
+                    namespace: chart.namespace,
+                    clusterId: this.props.currentCluster.id,
+                  }}
+                  renderTabContents={this.renderTabContents}
+                  isReadOnly={
+                    this.state.imageIsPlaceholder ||
+                    !this.props.isAuthorized("job", "", ["get", "update"])
+                  }
+                  onSubmit={(formValues) => {
+                    console.log(formValues);
+                    this.handleSaveValues(formValues, false);
+                  }}
+                  leftTabOptions={this.state.leftTabOptions}
+                  rightTabOptions={this.state.rightTabOptions}
+                  saveValuesStatus={this.state.saveValuesStatus}
+                  saveButtonText="Save Config"
+                />
+              )}
+            </BodyWrapper>
+          )}
         </StyledExpandedChart>
       </>
     );
@@ -651,6 +638,44 @@ ExpandedJobChart.contextType = Context;
 
 export default withAuth(ExpandedJobChart);
 
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 15px 0px 55px;
+`;
+
+const ButtonWrapper = styled.div`
+  margin: 5px 0 35px;
+`;
+
+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`
@@ -661,7 +686,8 @@ const Header = styled.div`
 `;
 
 const Placeholder = styled.div`
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   padding: 30px;
   padding-bottom: 70px;
   font-size: 13px;
@@ -680,71 +706,21 @@ const Spinner = styled.img`
 `;
 
 const BodyWrapper = styled.div`
-  width: 100%;
-  height: 100%;
+  position: relative;
   overflow: hidden;
 `;
 
 const TabWrapper = styled.div`
   height: 100%;
   width: 100%;
+  padding-bottom: 47px;
   overflow: hidden;
 `;
 
-const DeleteOverlay = styled.div`
-  position: absolute;
-  top: 0px;
-  opacity: 100%;
-  left: 0px;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-  display: flex;
-  padding-bottom: 30px;
-  align-items: center;
-  justify-content: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 18px;
-  font-weight: 500;
-  color: white;
-  flex-direction: column;
-  background: rgb(0, 0, 0, 0.73);
-  opacity: 0;
-  animation: lindEnter 0.2s;
-  animation-fill-mode: forwards;
-
-  @keyframes lindEnter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-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`
+  position: relative;
 `;
 
-const HeaderWrapper = styled.div``;
-
 const Dot = styled.div`
   margin-right: 9px;
   margin-left: 9px;
@@ -767,13 +743,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;
@@ -818,65 +794,24 @@ 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;
+  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: 400px;
+  height: 50vh;
   font-size: 13px;
-  border-radius: 5px;
   overflow: hidden;
+  border-radius: 8px;
+  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: 400px;
+  height: 50vh;
+  font-size: 13px;
   overflow: hidden;
+  border-radius: 8px;
+  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);
+    }
+  }
 `;

+ 35 - 19
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -9,6 +9,9 @@ import { ChartType, StorageType } from "shared/types";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+
 type PropsType = WithAuthProps & {
   showRevisions: boolean;
   toggleShowRevisions: () => void;
@@ -216,6 +219,13 @@ class RevisionSection extends Component<PropsType, StateType> {
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
+      const isGithubApp = !!this.props.chart.git_action_config;
+      const imageTag = revision.config?.image?.tag;
+
+      const parsedImageTag = isGithubApp
+        ? String(imageTag).slice(0, 7)
+        : imageTag;
+
       return (
         <Tr
           key={i}
@@ -224,7 +234,7 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{this.renderStatus(revision)}</Td>
+          <Td>{parsedImageTag || "N/A"}</Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -253,7 +263,9 @@ class RevisionSection extends Component<PropsType, StateType> {
               <Tr disableHover={true}>
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
-                <Th>Status</Th>
+                <Th>
+                  {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
+                </Th>
                 <Th>Template Version</Th>
                 <Th>Rollback</Th>
               </Tr>
@@ -281,6 +293,26 @@ class RevisionSection extends Component<PropsType, StateType> {
       this.state.maxVersion === 0;
     return (
       <div>
+        {this.state.upgradeVersion &&
+              <Modal
+                onRequestClose={() => this.setState({ upgradeVersion: "" })}
+                width="500px"
+                height="450px"
+              >
+                <UpgradeChartModal 
+                  currentChart={this.props.chart}
+                  closeModal={() => {
+                    this.setState({ upgradeVersion: "" });
+                  }}
+                  onSubmit={() => {
+                    this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                      this.setState({ loading: false });
+                    });
+                    this.setState({ upgradeVersion: "", loading: true });
+                  }}
+                />
+              </Modal>
+              }
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           isCurrent={isCurrent}
@@ -304,22 +336,6 @@ class RevisionSection extends Component<PropsType, StateType> {
                 <i className="material-icons">notification_important</i>
                 Template Update Available
               </RevisionUpdateMessage>
-              <ConfirmOverlay
-                show={!!this.state.upgradeVersion}
-                message={`Are you sure you want to redeploy and upgrade to version ${this.state.upgradeVersion}?`}
-                onYes={(e) => {
-                  e.stopPropagation();
-
-                  this.props.upgradeVersion(this.state.upgradeVersion, () => {
-                    this.setState({ loading: false });
-                  });
-                  this.setState({ upgradeVersion: "", loading: true });
-                }}
-                onNo={(e) => {
-                  e.stopPropagation();
-                  this.setState({ upgradeVersion: "" });
-                }}
-              />
             </div>
           )}
         </RevisionHeader>
@@ -480,7 +496,7 @@ const StyledRevisionSection = styled.div`
   background: #ffffff11;
   margin: 25px 0px 18px;
   overflow: hidden;
-  border-radius: 5px;
+  border-radius: 8px;
   animation: ${(props: { showRevisions: boolean }) =>
     props.showRevisions ? "expandRevisions 0.3s" : ""};
   animation-timing-function: ease-out;

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

@@ -13,8 +13,8 @@ import { Context } from "shared/Context";
 
 import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
 import useAuth from "shared/auth/useAuth";
@@ -371,6 +371,7 @@ const A = styled.a`
 
 const Wrapper = styled.div`
   width: 100%;
+  padding-bottom: 65px;
   height: 100%;
 `;
 
@@ -378,9 +379,9 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   width: 100%;
   background: #ffffff11;
   padding: 0 35px;
-  padding-bottom: 50px;
+  padding-bottom: 15px;
   position: relative;
-  border-radius: 5px;
+  border-radius: 8px;
   overflow: auto;
   height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "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: 8px;
+  border: 1px solid #ffffff33;
 `;
 
 const StyledValuesYaml = styled.div`
   display: flex;
   flex-direction: column;
   width: 100%;
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  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) =>

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -112,7 +112,8 @@ export default withAuth(JobList);
 
 const Placeholder = styled.div`
   width: 100%;
-  height: 100%;
+  min-height: 250px;
+  height: 30vh;
   display: flex;
   align-items: center;
   justify-content: center;

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

@@ -8,7 +8,7 @@ import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import trash from "assets/trash.png";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import KeyValueArray from "components/form-components/KeyValueArray";
 
 type PropsType = {
   job: any;

+ 335 - 246
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,48 +1,25 @@
-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,
-} from "@visx/tooltip";
+import { 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 +36,355 @@ 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;
-
-    if (width == 0 || height == 0 || width < 10) {
-      return null;
-    }
-
-    // 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, 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 { 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;
-
-        if (d1 && getDate(d1)) {
-          d =
-            x0.valueOf() - getDate(d0).valueOf() >
-            getDate(d1).valueOf() - x0.valueOf()
-              ? d1
-              : d0;
-        }
+    tooltipTop,
+    tooltipLeft,
+  } = useTooltip<{
+    data: NormalizedMetricsData;
+    tooltipHpaData: NormalizedMetricsData;
+  }>();
+
+  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;
+
+  // 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]
+  );
+
+  // tooltip handler
+  const handleTooltip = useCallback(
+    (
+      event: React.TouchEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>
+    ) => {
+      const isHpaEnabled = hpaEnabled && !!hpaData.length;
+
+      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;
+
+      if (d1 && getDate(d1)) {
+        d =
+          x0.valueOf() - getDate(d0).valueOf() >
+          getDate(d1).valueOf() - x0.valueOf()
+            ? d1
+            : d0;
+      }
+
+      const hpaIndex = bisectDate(hpaData, x0, 1);
+      // Get new index without min value to be sure that data exists for HPA
+      const hpaIndex2 = bisectDate(hpaData, x0);
+
+      if (!isHpaEnabled || hpaIndex !== hpaIndex2) {
         showTooltip({
-          tooltipData: d,
+          tooltipData: { data: d, tooltipHpaData: undefined },
           tooltipLeft: x || 0,
           tooltipTop: valueScale(getValue(d)) || 0,
         });
-      },
-      [showTooltip, valueScale, dateScale, width, height, data]
-    );
-
-    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}
+        return;
+      }
+
+      const tooltipHpaData0 = hpaData[hpaIndex - 1];
+      const tooltipHpaData1 = hpaData[hpaIndex];
+      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))) ||
+    null;
+
+  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 && hpaGraphTooltipGlyphPosition !== null && (
+              <>
+                <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 && hpaGraphTooltipGlyphPosition !== null && (
+              <div style={{ color: "#FFF" }}>
+                Autoscaling 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),
+      };
+    });
+  }
+}

+ 386 - 470
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -1,79 +1,26 @@
-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 SelectRow from "components/form-components/SelectRow";
+import AreaChart from "./AreaChart";
+import { MetricNormalizer } from "./MetricNormalizer";
+import { AvailableMetrics, NormalizedMetricsData } from "./types";
+import CheckboxRow from "components/form-components/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 } = {
-  "1H": "15s",
+  "1H": "1s",
   "6H": "15s",
   "1D": "15s",
   "1M": "5h",
@@ -86,39 +33,68 @@ 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: "Number of replicas" },
+        ];
+      });
+    } else {
+      setMetricsOptions((prev) => {
+        const hpaReplicasOptionIndex = prev.findIndex(
+          (option) => option.value === "hpa_replicas"
+        );
+        const options = [...prev];
+        if (hpaReplicasOptionIndex > -1) {
+          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 +106,34 @@ 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[] });
         })
         .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 +149,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 });
-        });
-
-        // iterate through the controllers to get the list of pods
-        this.setState({
-          controllerOptions,
-          selectedController: controllerOptions[0].value,
+          return { value: controller, label: name };
         });
 
-        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();
-    }
-
-    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];
-    }
+  }, [currentChart, currentCluster, currentProject]);
 
-    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 ||
@@ -391,11 +184,14 @@ export default class MetricsSection extends Component<PropsType, StateType> {
       }
       i += 1;
     }
+
     selectors.push(selector);
 
-    this.setState(({ isLoading }) => {
-      return { isLoading: isLoading + 1 };
-    });
+    if (selectors[0] === "") {
+      return;
+    }
+
+    setIsLoading((prev) => prev + 1);
 
     api
       .getMatchingPods(
@@ -415,80 +211,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 +384,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 +407,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: any) => !prev)}
+                checked={hpaEnabled}
+                label="Show Autoscaling Threshold"
+              />
+            )}
           <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 +547,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 +651,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 +688,26 @@ const MetricsLabel = styled.div`
 
 const StyledMetricsSection = styled.div`
   width: 100%;
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   display: flex;
   flex-direction: column;
   position: relative;
   font-size: 13px;
-  border-radius: 5px;
-  overflow: hidden;
+  border-radius: 8px;
+  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";

+ 327 - 405
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -1,220 +1,201 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
 import ResourceTab from "components/ResourceTab";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import PodRow from "./PodRow";
+import { timeFormat } from "d3-time-format";
 
-type PropsType = {
+type Props = {
   controller: any;
   selectedPod: any;
-  selectPod: Function;
+  selectPod: (newPod: any) => unknown;
   selectors: any;
   isLast?: boolean;
   isFirst?: boolean;
   setPodError: (x: string) => void;
 };
 
-type StateType = {
-  pods: any[];
-  raw: any[];
-  showTooltip: boolean[];
-  podPendingDelete: any;
-  websockets: Record<string, any>;
-  selectors: string[];
-  available: number;
-  total: number;
-  canUpdatePod: boolean;
-};
-
 // Controller tab in log section that displays list of pods on click.
-export default class ControllerTab extends Component<PropsType, StateType> {
-  state = {
-    pods: [] as any[],
-    raw: [] as any[],
-    showTooltip: [] as boolean[],
-    podPendingDelete: null as any,
-    websockets: {} as Record<string, any>,
-    selectors: [] as string[],
-    available: null as number,
-    total: null as number,
-    canUpdatePod: true,
-  };
+export type ControllerTabPodType = {
+  namespace: string;
+  name: string;
+  phase: string;
+  status: any;
+  replicaSetName: string;
+  restartCount: number | string;
+  podAge: string;
+  revisionNumber?: number;
+};
 
-  updatePods = () => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let { controller, selectPod, isFirst } = this.props;
+const formatCreationTimestamp = timeFormat("%H:%M:%S %b %d, '%y");
+
+const ControllerTabFC: React.FunctionComponent<Props> = ({
+  controller,
+  selectPod,
+  isFirst,
+  isLast,
+  selectors,
+  setPodError,
+  selectedPod,
+}) => {
+  const [pods, setPods] = useState<ControllerTabPodType[]>([]);
+  const [rawPodList, setRawPodList] = useState<any[]>([]);
+  const [podPendingDelete, setPodPendingDelete] = useState<any>(null);
+  const [available, setAvailable] = useState<number>(null);
+  const [total, setTotal] = useState<number>(null);
+  const [userSelectedPod, setUserSelectedPod] = useState<boolean>(false);
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const currentSelectors = useMemo(() => {
+    if (controller.kind.toLowerCase() == "job" && selectors) {
+      return [...selectors];
+    }
+    let newSelectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    newSelectors.push(selector);
+    return [...newSelectors];
+  }, [controller, selectors]);
+
+  useEffect(() => {
+    updatePods();
+    [controller?.kind, "pod"].forEach((kind) => {
+      setupWebsocket(kind, controller?.metadata?.uid);
+    });
+    () => closeAllWebsockets();
+  }, [currentSelectors, controller, currentCluster, currentProject]);
 
-    api
-      .getMatchingPods(
+  const updatePods = async () => {
+    try {
+      const res = await api.getMatchingPods(
         "<token>",
         {
           cluster_id: currentCluster.id,
           namespace: controller?.metadata?.namespace,
-          selectors: this.state.selectors,
+          selectors: currentSelectors,
         },
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => {
-        let pods = res?.data?.map((pod: any) => {
+      );
+      const data = res?.data as any[];
+      let newPods = data
+        // Parse only data that we need
+        .map<ControllerTabPodType>((pod: any) => {
+          const replicaSetName =
+            Array.isArray(pod?.metadata?.ownerReferences) &&
+            pod?.metadata?.ownerReferences[0]?.name;
+          const containerStatus =
+            Array.isArray(pod?.status?.containerStatuses) &&
+            pod?.status?.containerStatuses[0];
+
+          const restartCount = containerStatus
+            ? containerStatus.restartCount
+            : "N/A";
+
+          const podAge = formatCreationTimestamp(
+            new Date(pod?.metadata?.creationTimestamp)
+          );
+
           return {
             namespace: pod?.metadata?.namespace,
             name: pod?.metadata?.name,
             phase: pod?.status?.phase,
+            status: pod?.status,
+            replicaSetName,
+            restartCount,
+            podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
+            revisionNumber:
+              (pod?.metadata?.annotations &&
+                pod?.metadata?.annotations["helm.sh/revision"]) ||
+              "N/A",
           };
         });
-        let showTooltip = new Array(pods.length);
-        for (let j = 0; j < pods.length; j++) {
-          showTooltip[j] = false;
-        }
-
-        this.setState({ pods, raw: res.data, showTooltip });
-
-        if (isFirst) {
-          let pod = res.data[0];
-          let status = this.getPodStatus(pod.status);
-          status === "failed" &&
-            pod.status?.message &&
-            this.props.setPodError(pod.status?.message);
-          if (this.state.canUpdatePod) {
-            // this prevents multiple requests from changing the first pod
-            selectPod(res.data[0]);
-            this.setState({
-              canUpdatePod: false,
-            });
-          }
-        }
-      })
-      .catch((err) => {
-        console.log(err);
-        setCurrentError(JSON.stringify(err));
-        return;
-      });
-  };
-
-  getPodSelectors = (callback: () => void) => {
-    let { controller } = this.props;
 
-    let selectors = [] as string[];
-    let ml =
-      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
-    let i = 1;
-    let selector = "";
-    for (var key in ml) {
-      selector += key + "=" + ml[key];
-      if (i != Object.keys(ml).length) {
-        selector += ",";
+      setPods(newPods);
+      setRawPodList(data);
+      // If the user didn't click a pod, select the first returned from list.
+      if (!userSelectedPod) {
+        let status = getPodStatus(newPods[0].status);
+        status === "failed" &&
+          newPods[0].status?.message &&
+          setPodError(newPods[0].status?.message);
+        handleSelectPod(newPods[0], data);
       }
-      i += 1;
-    }
-    selectors.push(selector);
-    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
-      selectors = this.props.selectors;
-    }
-
-    this.setState({ selectors }, () => {
-      callback();
-    });
+    } catch (error) {}
   };
 
-  componentDidMount() {
-    this.getPodSelectors(() => {
-      this.updatePods();
-      this.setControllerWebsockets([this.props.controller.kind, "pod"]);
-    });
-  }
-
-  componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close();
-      });
-    }
-  }
-
-  setControllerWebsockets = (controller_types: any[]) => {
-    let websockets = controller_types.map((kind: string) => {
-      return this.setupWebsocket(kind);
-    });
-    this.setState({ websockets });
+  /**
+   * handleSelectPod is a wrapper for the selectPod function received from parent.
+   * Internally we use the ControllerPodType but we want to pass to the parent the
+   * raw pod returned from the API.
+   *
+   * @param pod A ControllerPodType pod that will be used to search the raw pod to pass
+   * @param rawList A rawList of pods in case we don't want to use the state one. Useful to
+   * avoid problems with reactivity
+   */
+  const handleSelectPod = (pod: ControllerTabPodType, rawList?: any[]) => {
+    const rawPod = [...rawPodList, ...(rawList || [])].find(
+      (rawPod) => rawPod?.metadata?.name === pod?.name
+    );
+    selectPod(rawPod);
   };
 
-  setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = this.context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    let connString = `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    if (kind == "pod" && this.state.selectors) {
-      connString += `&selectors=${this.state.selectors[0]}`;
-    }
-    let ws = new WebSocket(connString);
-
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
+  const currentSelectedPod = useMemo(() => {
+    const pod = selectedPod;
+    const replicaSetName =
+      Array.isArray(pod?.metadata?.ownerReferences) &&
+      pod?.metadata?.ownerReferences[0]?.name;
+    return {
+      namespace: pod?.metadata?.namespace,
+      name: pod?.metadata?.name,
+      phase: pod?.status?.phase,
+      status: pod?.status,
+      replicaSetName,
+    } as ControllerTabPodType;
+  }, [selectedPod]);
+
+  const currentControllerStatus = useMemo(() => {
+    let status = available == total ? "running" : "waiting";
 
-      // update pods no matter what if ws message is a pod event.
-      // If controller event, check if ws message corresponds to the designated controller in props.
+    controller?.status?.conditions?.forEach((condition: any) => {
       if (
-        event.Kind != "pod" &&
-        object.metadata.uid != this.props.controller.metadata.uid
-      )
-        return;
-
-      if (event.Kind != "pod") {
-        let [available, total] = this.getAvailability(
-          object.metadata.kind,
-          object
-        );
-        this.setState({ available, total });
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
       }
+    });
 
-      this.updatePods();
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
-    };
-
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
-    };
-
-    return ws;
-  };
-
-  getAvailability = (kind: string, c: any) => {
-    switch (kind?.toLowerCase()) {
-      case "deployment":
-      case "replicaset":
-        return [
-          c.status?.availableReplicas ||
-            c.status?.replicas - c.status?.unavailableReplicas ||
-            0,
-          c.status?.replicas || 0,
-        ];
-      case "statefulset":
-        return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
-      case "daemonset":
-        return [
-          c.status?.numberAvailable || 0,
-          c.status?.desiredNumberScheduled || 0,
-        ];
-      case "job":
-        return [1, 1];
+    if (controller.kind.toLowerCase() === "job" && pods.length == 0) {
+      status = "completed";
     }
-  };
+    return status;
+  }, [controller, available, total, pods]);
 
-  getPodStatus = (status: any) => {
+  const getPodStatus = (status: any) => {
     if (
       status?.phase === "Pending" &&
       status?.containerStatuses !== undefined
@@ -245,272 +226,213 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
   };
 
-  renderTooltip = (x: string, ind: number): JSX.Element | undefined => {
-    if (this.state.showTooltip[ind]) {
-      return <Tooltip>{x}</Tooltip>;
-    }
-  };
-
-  handleDeletePod = (pod: any) => {
+  const handleDeletePod = (pod: any) => {
     api
       .deletePod(
         "<token>",
         {
-          cluster_id: this.context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         {
           name: pod.metadata?.name,
           namespace: pod.metadata?.namespace,
-          id: this.context.currentProject.id,
+          id: currentProject.id,
         }
       )
       .then((res) => {
-        this.updatePods();
-        this.setState({ podPendingDelete: null });
+        updatePods();
+        setPodPendingDelete(null);
       })
       .catch((err) => {
-        this.context.setCurrentError(JSON.stringify(err));
-        this.setState({ podPendingDelete: null });
+        setCurrentError(JSON.stringify(err));
+        setPodPendingDelete(null);
       });
   };
 
-  renderDeleteButton = (pod: any) => {
-    return (
-      <CloseIcon
-        className="material-icons-outlined"
-        onClick={() => this.setState({ podPendingDelete: pod })}
-      >
-        close
-      </CloseIcon>
-    );
-  };
-
-  render() {
-    let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
-    let { available, total } = this.state;
-    let status = available == total ? "running" : "waiting";
-
-    controller?.status?.conditions?.forEach((condition: any) => {
+  const replicaSetArray = useMemo(() => {
+    const podsDividedByReplicaSet = pods.reduce<
+      Array<Array<ControllerTabPodType>>
+    >(function (prev, currentPod, i) {
       if (
-        condition.type == "Progressing" &&
-        condition.status == "False" &&
-        condition.reason == "ProgressDeadlineExceeded"
+        !i ||
+        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
       ) {
-        status = "failed";
+        return prev.concat([[currentPod]]);
       }
-    });
+      prev[prev.length - 1].push(currentPod);
+      return prev;
+    }, []);
+
+    if (podsDividedByReplicaSet.length === 1) {
+      return [];
+    } else {
+      return podsDividedByReplicaSet;
+    }
+  }, [pods]);
 
-    if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
-      status = "completed";
+  const getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return [
+          c.status?.availableReplicas ||
+            c.status?.replicas - c.status?.unavailableReplicas ||
+            0,
+          c.status?.replicas || 0,
+        ];
+      case "statefulset":
+        return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
+      case "daemonset":
+        return [
+          c.status?.numberAvailable || 0,
+          c.status?.desiredNumberScheduled || 0,
+        ];
+      case "job":
+        return [1, 1];
     }
+  };
 
-    return (
-      <ResourceTab
-        label={controller.kind}
-        // handle CronJob case
-        name={controller.metadata?.name || controller.name}
-        status={{ label: status, available, total }}
-        isLast={isLast}
-        expanded={isFirst}
-      >
-        {this.state.raw.map((pod, i) => {
-          let status = this.getPodStatus(pod.status);
-          return (
-            <Tab
-              key={pod.metadata?.name}
-              selected={selectedPod?.metadata?.name === pod?.metadata?.name}
-              onClick={() => {
-                this.props.setPodError("");
-                status === "failed" &&
-                  pod.status?.message &&
-                  this.props.setPodError(pod.status?.message);
-                selectPod(pod);
-                this.setState({
-                  canUpdatePod: false,
-                });
-              }}
-            >
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={i === this.state.raw.length - 1} />
-              </Gutter>
-              <Name
-                onMouseOver={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = true;
-                  this.setState({ showTooltip });
-                }}
-                onMouseOut={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = false;
-                  this.setState({ showTooltip });
-                }}
-              >
-                {pod.metadata?.name}
-              </Name>
-              {this.renderTooltip(pod.metadata?.name, i)}
-              <Status>
-                <StatusColor status={status} />
-                {status}
-                {status === "failed" && this.renderDeleteButton(pod)}
-              </Status>
-            </Tab>
-          );
-        })}
-        <ConfirmOverlay
-          message="Are you sure you want to delete this pod?"
-          show={this.state.podPendingDelete}
-          onYes={() => this.handleDeletePod(this.state.podPendingDelete)}
-          onNo={() => this.setState({ podPendingDelete: null })}
-        />
-      </ResourceTab>
-    );
-  }
-}
+  const setupWebsocket = (kind: string, controllerUid: string) => {
+    let apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+    if (kind == "pod" && currentSelectors) {
+      apiEndpoint += `&selectors=${currentSelectors[0]}`;
+    }
 
-ControllerTab.contextType = Context;
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
 
-const CloseIcon = styled.i`
-  font-size: 14px;
-  display: flex;
-  font-weight: bold;
-  align-items: center;
-  justify-content: center;
-  border-radius: 5px;
-  background: #ffffff22;
-  width: 18px;
-  height: 18px;
-  margin-right: -6px;
-  margin-left: 10px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff44;
-  }
-`;
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
 
-const Rail = styled.div`
-  width: 2px;
-  background: ${(props: { lastTab?: boolean }) =>
-    props.lastTab ? "" : "#52545D"};
-  height: 50%;
-`;
+      // Make a new API call to update pods only when the event type is UPDATE
+      if (event.event_type !== "UPDATE") {
+        return;
+      }
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (event.Kind != "pod" && object.metadata.uid !== controllerUid) {
+        return;
+      }
 
-const Circle = styled.div`
-  min-width: 10px;
-  min-height: 2px;
-  margin-bottom: -2px;
-  margin-left: 8px;
-  background: #52545d;
-`;
+      if (event.Kind != "pod") {
+        let [available, total] = getAvailability(object.metadata.kind, object);
+        setAvailable(available);
+        setTotal(total);
+        return;
+      }
+      updatePods();
+    };
 
-const Gutter = styled.div`
-  position: absolute;
-  top: 0px;
-  left: 10px;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  overflow: visible;
-`;
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
 
-const Status = styled.div`
-  display: flex;
-  font-size: 12px;
-  text-transform: capitalize;
-  margin-left: 5px;
-  justify-content: flex-end;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  color: #aaaabb;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(kind);
+    };
 
-const StatusColor = styled.div`
-  margin-right: 7px;
-  width: 7px;
-  min-width: 7px;
-  height: 7px;
-  background: ${(props: { status: string }) =>
-    props.status === "running"
-      ? "#4797ff"
-      : props.status === "failed"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
-  border-radius: 20px;
-`;
+    newWebsocket(kind, apiEndpoint, options);
+    openWebsocket(kind);
+  };
+
+  const mapPods = (podList: ControllerTabPodType[]) => {
+    return podList.map((pod, i, arr) => {
+      let status = getPodStatus(pod.status);
+      return (
+        <PodRow
+          key={i}
+          pod={pod}
+          isSelected={currentSelectedPod?.name === pod?.name}
+          podStatus={status}
+          isLastItem={i === arr.length - 1}
+          onTabClick={() => {
+            setPodError("");
+            status === "failed" &&
+              pod.status?.message &&
+              setPodError(pod.status?.message);
+            handleSelectPod(pod);
+            setUserSelectedPod(true);
+          }}
+          onDeleteClick={() => setPodPendingDelete(pod)}
+        />
+      );
+    });
+  };
 
-const Name = styled.div`
-  overflow: hidden;
-  text-overflow: ellipsis;
-  line-height: 16px;
-  word-wrap: break-word;
-  max-height: 32px;
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
+  return (
+    <ResourceTab
+      label={controller.kind}
+      // handle CronJob case
+      name={controller.metadata?.name || controller.name}
+      status={{ label: currentControllerStatus, available, total }}
+      isLast={isLast}
+      expanded={isFirst}
+    >
+      {!!replicaSetArray.length &&
+        replicaSetArray.map((subArray, index) => {
+          const firstItem = subArray[0];
+          return (
+            <div key={firstItem.replicaSetName + index}>
+              <ReplicaSetContainer>
+                <ReplicaSetName>
+                  {
+                    firstItem?.revisionNumber && firstItem?.revisionNumber.toString() != "N/A" && (
+                      <Bold>Revision {firstItem.revisionNumber}:</Bold>
+                    )
+                  } {firstItem.replicaSetName}
+                </ReplicaSetName>
+              </ReplicaSetContainer>
+              {mapPods(subArray)}
+            </div>
+          );
+        })}
+      {!replicaSetArray.length && mapPods(pods)}
+      <ConfirmOverlay
+        message="Are you sure you want to delete this pod?"
+        show={podPendingDelete}
+        onYes={() => handleDeletePod(podPendingDelete)}
+        onNo={() => setPodPendingDelete(null)}
+      />
+    </ResourceTab>
+  );
+};
+
+export default ControllerTabFC;
+
+const Bold = styled.span`
+  font-weight: 500;
+  display: inline;
+  color: #ffffff;
 `;
 
-const Tooltip = styled.div`
-  position: absolute;
-  left: 35px;
-  word-wrap: break-word;
-  top: 38px;
-  min-height: 18px;
-  max-width: calc(100% - 75px);
-  padding: 2px 5px;
-  background: #383842dd;
-  display: flex;
-  justify-content: center;
-  flex: 1;
-  color: white;
-  text-transform: none;
+const RevisionLabel = styled.div`
   font-size: 12px;
-  font-family: "Work Sans", sans-serif;
-  outline: 1px solid #ffffff55;
-  opacity: 0;
-  animation: faded-in 0.2s 0.15s;
-  animation-fill-mode: forwards;
-  @keyframes faded-in {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
+  color: #ffffff33;
+  width: 78px;
+  text-align: right;
+  padding-top: 7px;
+  margin-right: 10px;
+  margin-left: 10px;
+  overflow-wrap: anywhere;
 `;
 
-const Tab = styled.div`
-  width: 100%;
-  height: 50px;
-  position: relative;
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
   display: flex;
-  align-items: center;
+  overflow-wrap: anywhere;
   justify-content: space-between;
-  color: ${(props: { selected: boolean }) =>
-    props.selected ? "white" : "#ffffff66"};
-  background: ${(props: { selected: boolean }) =>
-    props.selected ? "#ffffff18" : ""};
-  font-size: 13px;
-  padding: 20px 19px 20px 42px;
-  text-shadow: 0px 0px 8px none;
-  overflow: visible;
-  cursor: pointer;
-  :hover {
-    color: white;
-    background: #ffffff18;
-  }
+  border-top: 2px solid #ffffff11;
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
 `;

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

@@ -337,7 +337,7 @@ const Scroll = styled.div`
   }
 
   > input {
-    width; 18px;
+    width: 18px;
     margin-left: 10px;
     margin-right: 6px;
     pointer-events: none;
@@ -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;

+ 221 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx

@@ -0,0 +1,221 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { ControllerTabPodType } from "./ControllerTab";
+
+type PodRowProps = {
+  pod: ControllerTabPodType;
+  isSelected: boolean;
+  isLastItem: boolean;
+  onTabClick: any;
+  onDeleteClick: any;
+  podStatus: string;
+};
+
+const PodRow: React.FunctionComponent<PodRowProps> = ({
+  pod,
+  isSelected,
+  onTabClick,
+  onDeleteClick,
+  isLastItem,
+  podStatus,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <Tab key={pod?.name} selected={isSelected} onClick={onTabClick}>
+      <Gutter>
+        <Rail />
+        <Circle />
+        <Rail lastTab={isLastItem} />
+      </Gutter>
+      <Name
+        onMouseOver={() => {
+          setShowTooltip(true);
+        }}
+        onMouseOut={() => {
+          setShowTooltip(false);
+        }}
+      >
+        {pod?.name}
+      </Name>
+      {
+        showTooltip && (
+          <Tooltip>
+            {pod?.name}
+            <Grey>Restart count: {pod.restartCount}</Grey>
+            <Grey>Created on: {pod.podAge}</Grey>
+          </Tooltip>
+        )
+      }
+
+        <Status>
+          <StatusColor status={podStatus} />
+          {podStatus}
+          {podStatus === "failed" && (
+            <CloseIcon
+              className="material-icons-outlined"
+              onClick={onDeleteClick}
+            >
+              close
+            </CloseIcon>
+          )}
+        </Status>
+    </Tab>
+  );
+};
+
+export default PodRow;
+
+const InfoIcon = styled.div`
+  width: 22px;
+`;
+
+const Grey = styled.div`
+  margin-top: 5px;
+  color: #aaaabb;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const CloseIcon = styled.i`
+  font-size: 14px;
+  display: flex;
+  font-weight: bold;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  background: #ffffff22;
+  width: 18px;
+  height: 18px;
+  margin-right: -6px;
+  margin-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 50px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: ${(props: { selected: boolean }) =>
+    props.selected ? "white" : "#ffffff66"};
+  background: ${(props: { selected: boolean }) =>
+    props.selected ? "#ffffff18" : ""};
+  font-size: 13px;
+  padding: 20px 19px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+const Rail = styled.div`
+  width: 2px;
+  background: ${(props: { lastTab?: boolean }) =>
+    props.lastTab ? "" : "#52545D"};
+  height: 50%;
+`;
+
+const Circle = styled.div`
+  min-width: 10px;
+  min-height: 2px;
+  margin-bottom: -2px;
+  margin-left: 8px;
+  background: #52545d;
+`;
+
+const Gutter = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 10px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 12px;
+  text-transform: capitalize;
+  margin-left: 5px;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const Name = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5em;
+  display: -webkit-box;
+  overflow-wrap: anywhere;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+`;

+ 91 - 86
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import api from "shared/api";
@@ -9,82 +9,108 @@ import Loading from "components/Loading";
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
 
-type PropsType = {
+type Props = {
   selectors?: string[];
   currentChart: ChartType;
 };
 
-type StateType = {
-  logs: string[];
-  pods: any[];
-  selectedPod: any;
-  controllers: any[];
-  loading: boolean;
-  podError: string;
-};
-
-export default class StatusSection extends Component<PropsType, StateType> {
-  state = {
-    logs: [] as string[],
-    pods: [] as any[],
-    selectedPod: {} as any,
-    controllers: [] as any[],
-    loading: true,
-    podError: "",
-  };
+const StatusSectionFC: React.FunctionComponent<Props> = ({
+  currentChart,
+  selectors,
+}) => {
+  const [selectedPod, setSelectedPod] = useState<any>({});
+  const [controllers, setControllers] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [podError, setPodError] = useState<string>("");
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res: any) => {
+        if (!isSubscribed) {
+          return;
+        }
+        let controllers =
+          currentChart.chart.metadata.name == "job"
+            ? res.data[0]?.status.active
+            : res.data;
+        setControllers(controllers);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (!isSubscribed) {
+          return;
+        }
+        setCurrentError(JSON.stringify(err));
+        setControllers([]);
+        setIsLoading(false);
+      });
+    return () => (isSubscribed = false);
+  }, [currentProject, currentCluster, setCurrentError, currentChart]);
 
-  renderLogs = () => {
+  const renderLogs = () => {
     return (
       <Logs
-        podError={this.state.podError}
-        key={this.state.selectedPod?.metadata?.name}
-        selectedPod={this.state.selectedPod}
+        podError={podError}
+        key={selectedPod?.metadata?.name}
+        selectedPod={selectedPod}
       />
     );
   };
 
-  selectPod = (pod: any) => {
-    this.setState({
-      selectedPod: pod,
-    });
-  };
-
-  renderTabs = () => {
-    return this.state.controllers.map((c, i) => {
+  const renderTabs = () => {
+    return controllers.map((c, i) => {
       return (
         <ControllerTab
           // handle CronJob case
           key={c.metadata?.uid || c.uid}
-          selectedPod={this.state.selectedPod}
-          selectPod={this.selectPod.bind(this)}
-          selectors={this.props.selectors ? [this.props.selectors[i]] : null}
+          selectedPod={selectedPod}
+          selectPod={setSelectedPod}
+          selectors={selectors ? [selectors[i]] : null}
           controller={c}
-          isLast={i === this.state.controllers?.length - 1}
+          isLast={i === controllers?.length - 1}
           isFirst={i === 0}
-          setPodError={(x: string) => this.setState({ podError: x })}
+          setPodError={(x: string) => setPodError(x)}
         />
       );
     });
   };
 
-  renderStatusSection = () => {
-    if (this.state.loading) {
+  const renderStatusSection = () => {
+    if (isLoading) {
       return (
         <NoControllers>
           <Loading />
         </NoControllers>
       );
     }
-    if (this.state.controllers?.length > 0) {
+    if (controllers?.length > 0) {
       return (
         <Wrapper>
-          <TabWrapper>{this.renderTabs()}</TabWrapper>
-          {this.renderLogs()}
+          <TabWrapper>{renderTabs()}</TabWrapper>
+          {renderLogs()}
         </Wrapper>
       );
     }
 
-    if (this.props.currentChart.chart.metadata.name === "job") {
+    if (currentChart?.chart?.metadata?.name === "job") {
       return (
         <NoControllers>
           <i className="material-icons">category</i>
@@ -102,45 +128,10 @@ export default class StatusSection extends Component<PropsType, StateType> {
     );
   };
 
-  componentDidMount() {
-    const { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    api
-      .getChartControllers(
-        "<token>",
-        {
-          namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
-          name: currentChart.name,
-          revision: currentChart.version,
-        }
-      )
-      .then((res: any) => {
-        let controllers =
-          currentChart.chart.metadata.name == "job"
-            ? res.data[0]?.status.active
-            : res.data;
-        this.setState({ controllers, loading: false });
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        this.setState({ controllers: [], loading: false });
-      });
-  }
-
-  render() {
-    return (
-      <StyledStatusSection>{this.renderStatusSection()}</StyledStatusSection>
-    );
-  }
-}
+  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+};
 
-StatusSection.contextType = Context;
+export default StatusSectionFC;
 
 const TabWrapper = styled.div`
   width: 35%;
@@ -150,14 +141,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: 400px;
+  height: 50vh;
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  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`

+ 5 - 42
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -8,7 +8,7 @@ import {
   DetailedClusterType,
   DetailedIngressError,
 } from "shared/types";
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
 
 import { RouteComponentProps, withRouter } from "react-router";
@@ -232,7 +232,6 @@ const CodeBlock = styled.span`
   width: 90%;
   margin-left: 5%;
   margin-top: 20px;
-  overflow-x: hidden;
   overflow-y: auto;
   padding: 10px;
   overflow-wrap: break-word;
@@ -241,6 +240,7 @@ const CodeBlock = styled.span`
 const StyledClusterList = styled.div`
   margin-top: -17px;
   padding-left: 2px;
+  overflow: visible;
 `;
 
 const TitleContainer = styled.div`
@@ -282,7 +282,7 @@ const TemplateBlock = styled.div`
   border: 1px solid #ffffff00;
   align-items: center;
   user-select: none;
-  border-radius: 5px;
+  border-radius: 8px;
   display: flex;
   font-size: 13px;
   font-weight: 500;
@@ -295,7 +295,7 @@ const TemplateBlock = styled.div`
   color: #ffffff;
   position: relative;
   background: #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
+  box-shadow: 0 4px 15px 0px #00000055;
   :hover {
     background: #ffffff11;
   }
@@ -313,6 +313,7 @@ const TemplateBlock = styled.div`
 
 const TemplateList = styled.div`
   overflow-y: auto;
+  overflow: visible;
   margin-top: 32px;
   padding-bottom: 150px;
   display: grid;
@@ -321,44 +322,6 @@ 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: 18px;
-      color: #858faaaa;
-      cursor: pointer;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
-const TemplatesWrapper = styled.div`
-  width: calc(90% - 150px);
-  min-width: 300px;
-  padding-top: 50px;
-`;
-
 const Url = styled.a`
   width: 100%;
   font-size: 13px;

+ 10 - 36
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -11,7 +11,8 @@ import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
-import FormDebugger from "components/values-form/FormDebugger";
+import FormDebugger from "components/porter-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;
-  }
-`;

+ 96 - 197
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useEffect, useContext, useState } from "react";
 import styled from "styled-components";
 import GHIcon from "assets/GithubIcon";
 
@@ -8,44 +8,31 @@ import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import api from "shared/api";
 import { pushFiltered } from "shared/routing";
+import Loading from "../../../components/Loading";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import SlackIntegrationList from "./SlackIntegrationList";
+import TitleSection from "components/TitleSection";
 
-type PropsType = RouteComponentProps & {
+type Props = RouteComponentProps & {
   category: string;
 };
 
-type StateType = {
-  // currentIntegration: string | null;
-  currentOptions: any[];
-  currentTitles: any[];
-  currentIds: any[];
-  currentIntegrationData: any[];
-};
+const IntegrationCategories: React.FC<Props> = (props) => {
+  const [currentOptions, setCurrentOptions] = useState([]);
+  const [currentTitles, setCurrentTitles] = useState([]);
+  const [currentIds, setCurrentIds] = useState([]);
+  const [currentIntegrationData, setCurrentIntegrationData] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [slackData, setSlackData] = useState([]);
 
-class IntegrationCategories extends Component<PropsType, StateType> {
-  state = {
-    currentOptions: [] as any[],
-    currentTitles: [] as any[],
-    currentIds: [] as any[],
-    currentIntegrationData: [] as any[],
-  };
+  const { currentProject, setCurrentModal } = useContext(Context);
 
-  componentDidMount() {
-    this.getIntegrationsForCategory(this.props.category);
-  }
+  const getIntegrationsForCategory = (categoryType: string) => {
+    setLoading(true);
+    setCurrentOptions([]);
+    setCurrentTitles([]);
+    setCurrentIntegrationData([]);
 
-  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
-    if (this.props.category != prevProps.category) {
-      this.getIntegrationsForCategory(this.props.category);
-    }
-  }
-
-  getIntegrationsForCategory = (categoryType: string) => {
-    const { currentProject } = this.context;
-    this.setState({
-      currentOptions: [],
-      currentTitles: [],
-      currentIntegrationData: [],
-    });
     switch (categoryType) {
       case "kubernetes":
         api
@@ -72,39 +59,25 @@ class IntegrationCategories extends Component<PropsType, StateType> {
                 val.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
               );
             });
-
-            let currentOptions = [] as string[];
-            let currentTitles = [] as string[];
+            let newCurrentOptions = [] as string[];
+            let newCurrentTitles = [] as string[];
             final.forEach((integration: any, i: number) => {
-              currentOptions.push(integration.service);
-              currentTitles.push(integration.name);
-            });
-            this.setState({
-              currentOptions,
-              currentTitles,
-              currentIntegrationData: final,
+              newCurrentOptions.push(integration.service);
+              newCurrentTitles.push(integration.name);
             });
+            setCurrentOptions(newCurrentOptions);
+            setCurrentTitles(newCurrentTitles);
+            setCurrentIntegrationData(final);
+            setLoading(false);
           })
           .catch(console.log);
         break;
-      case "repo":
+      case "slack":
         api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
+          .getSlackIntegrations("<token>", {}, { id: currentProject.id })
           .then((res) => {
-            let currentOptions = [] as string[];
-            let currentTitles = [] as string[];
-            let currentIds = [] as any[];
-            res.data.forEach((item: any) => {
-              currentOptions.push(item.service);
-              currentTitles.push(item.repo_entity);
-              currentIds.push(item.id);
-            });
-            this.setState({
-              currentOptions,
-              currentTitles,
-              currentIds,
-              currentIntegrationData: res.data,
-            });
+            setSlackData(res.data);
+            setLoading(false);
           })
           .catch(console.log);
         break;
@@ -113,121 +86,77 @@ class IntegrationCategories extends Component<PropsType, StateType> {
     }
   };
 
-  render = () => {
-    const { category: currentCategory } = this.props;
-    let icon =
-      integrationList[currentCategory] && integrationList[currentCategory].icon;
-    let label =
-      integrationList[currentCategory] &&
-      integrationList[currentCategory].label;
-    let buttonText =
-      integrationList[currentCategory] &&
-      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>
-            <Button
-              onClick={() =>
-                this.context.setCurrentModal("IntegrationsModal", {
-                  category: currentCategory,
-                  setCurrentIntegration: (x: string) =>
-                    pushFiltered(
-                      this.props,
-                      `/integrations/${this.props.category}/create/${x}`,
-                      ["project_id"]
-                    ),
-                })
-              }
-            >
-              <i className="material-icons">add</i>
-              {buttonText}
-            </Button>
-          </TitleSectionAlt>
-
-          <LineBreak />
-
-          <IntegrationList
-            currentCategory={currentCategory}
-            integrations={this.state.currentOptions}
-            titles={this.state.currentTitles}
-            itemIdentifier={this.state.currentIntegrationData}
-            updateIntegrationList={() =>
-              this.getIntegrationsForCategory(this.props.category)
+  useEffect(() => {
+    getIntegrationsForCategory(props.category);
+  }, [props.category]);
+
+  const { category: currentCategory } = props;
+  const icon =
+    integrationList[currentCategory] && integrationList[currentCategory].icon;
+  const label =
+    integrationList[currentCategory] && integrationList[currentCategory].label;
+  const buttonText =
+    integrationList[currentCategory] &&
+    integrationList[currentCategory].buttonText;
+
+  return (
+    <>
+      <Flex>
+        <TitleSection
+          handleNavBack={() =>
+            pushFiltered(props, "/integrations", ["project_id"])
+          }
+          icon={icon}
+        >
+          {label}
+        </TitleSection>
+        <Button
+          onClick={() => {
+            if (props.category != "slack") {
+              setCurrentModal("IntegrationsModal", {
+                category: currentCategory,
+                setCurrentIntegration: (x: string) =>
+                  pushFiltered(
+                    props,
+                    `/integrations/${props.category}/create/${x}`,
+                    ["project_id"]
+                  ),
+              });
+            } else {
+              window.location.href = `/api/oauth/projects/${currentProject.id}/slack`;
             }
-          />
-        </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>
-            <Button
-              onClick={() =>
-                window.open(
-                  `/api/oauth/projects/${this.context.currentProject.id}/github`
-                )
-              }
-            >
-              <GHIcon />
-              {buttonText}
-            </Button>
-          </TitleSectionAlt>
-
-          <LineBreak />
-
-          <IntegrationList
-            currentCategory={currentCategory}
-            integrations={this.state.currentOptions}
-            titles={this.state.currentTitles}
-            itemIdentifier={this.state.currentIds}
-            updateIntegrationList={() =>
-              this.getIntegrationsForCategory(this.props.category)
-            }
-          />
-        </div>
-      );
-    }
-  };
-}
-
-IntegrationCategories.contextType = Context;
+          }}
+        >
+          <i className="material-icons">add</i>
+          {buttonText}
+        </Button>
+      </Flex>
+      {loading ? (
+        <Loading />
+      ) : props.category == "slack" ? (
+        <SlackIntegrationList slackData={slackData} />
+      ) : (
+        <IntegrationList
+          currentCategory={props.category}
+          integrations={currentOptions}
+          titles={currentTitles}
+          itemIdentifier={currentIntegrationData}
+          updateIntegrationList={() =>
+            getIntegrationsForCategory(props.category)
+          }
+        />
+      )}
+    </>
+  );
+};
 
 export default withRouter(IntegrationCategories);
 
-const Icon = styled.img`
-  width: 27px;
-  margin-right: 12px;
-  margin-bottom: -1px;
-`;
-
 const Flex = styled.div`
   display: flex;
   align-items: center;
+  margin-bottom: -20px;
+  justify-content: space-between;
 
   > i {
     cursor: pointer;
@@ -244,6 +173,7 @@ const Flex = styled.div`
 
 const Button = styled.div`
   height: 100%;
+  margin-top: -12px;
   background: #616feecc;
   :hover {
     background: #505edddd;
@@ -270,34 +200,3 @@ const Button = styled.div`
     justify-content: center;
   }
 `;
-
-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;
-`;

+ 3 - 3
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -237,8 +237,8 @@ const Integration = styled.div`
   cursor: ${(props: { disabled: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
 `;
 
 const Label = styled.div`
@@ -254,7 +254,7 @@ const Icon = styled.img`
 
 const Placeholder = styled.div`
   width: 100%;
-  height: 150px;
+  height: 250px;
   display: flex;
   align-items: center;
   font-size: 13px;

+ 2 - 2
dashboard/src/main/home/integrations/IntegrationRow.tsx

@@ -133,8 +133,8 @@ const Integration = styled.div`
   cursor: ${(props: { disabled: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
 `;
 
 const Icon = styled.img`

Некоторые файлы не были показаны из-за большого количества измененных файлов