2
0
Эх сурвалжийг харах

Merge branch 'master' into 0.5.0-frontend-contributor-guide

Ivan Galakhov 4 жил өмнө
parent
commit
b47fb0613b
100 өөрчлөгдсөн 5267 нэмэгдсэн , 3340 устгасан
  1. 8 3
      .github/workflows/dev.yaml
  2. 7 2
      .github/workflows/production.yaml
  3. 7 2
      .github/workflows/staging.yaml
  4. 3 0
      .gitignore
  5. 101 0
      api/types/policy.go
  6. 10 5
      cli/cmd/api/api.go
  7. 59 52
      cli/cmd/auth.go
  8. 1 1
      cli/cmd/deploy.go
  9. 72 6
      cli/cmd/deploy/build.go
  10. 1 1
      cli/cmd/deploy/create.go
  11. 36 9
      cli/cmd/deploy/deploy.go
  12. 4 0
      cli/cmd/docker.go
  13. 79 5
      cli/cmd/docker/builder.go
  14. 15 3
      cli/cmd/login/server.go
  15. 14 0
      cli/cmd/project.go
  16. 1 0
      cli/cmd/server.go
  17. 10 1
      cli/cmd/utils/browser.go
  18. 29 0
      cli/cmd/utils/wsl.go
  19. 1 1
      cli/cmd/version.go
  20. 1 30
      cmd/app/main.go
  21. 8 6
      cmd/migrate/keyrotate/helpers_test.go
  22. 4 33
      cmd/migrate/main.go
  23. 5 0
      dashboard/package-lock.json
  24. BIN
      dashboard/src/assets/back_arrow.png
  25. BIN
      dashboard/src/assets/node.png
  26. 1 1
      dashboard/src/components/ExpandableResource.tsx
  27. 12 4
      dashboard/src/components/Loading.tsx
  28. 1 1
      dashboard/src/components/PageNotFound.tsx
  29. 4 1
      dashboard/src/components/RadioSelector.tsx
  30. 2 5
      dashboard/src/components/ResourceTab.tsx
  31. 100 26
      dashboard/src/components/SaveButton.tsx
  32. 93 0
      dashboard/src/components/SearchBar.tsx
  33. 28 40
      dashboard/src/components/TabRegion.tsx
  34. 8 3
      dashboard/src/components/Table.tsx
  35. 96 0
      dashboard/src/components/TitleSection.tsx
  36. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  37. 4 3
      dashboard/src/components/YamlEditor.tsx
  38. 0 0
      dashboard/src/components/form-components/CheckboxList.tsx
  39. 0 0
      dashboard/src/components/form-components/CheckboxRow.tsx
  40. 0 0
      dashboard/src/components/form-components/Heading.tsx
  41. 0 0
      dashboard/src/components/form-components/Helper.tsx
  42. 0 0
      dashboard/src/components/form-components/InputRow.tsx
  43. 34 16
      dashboard/src/components/form-components/KeyValueArray.tsx
  44. 0 0
      dashboard/src/components/form-components/SelectRow.tsx
  45. 0 0
      dashboard/src/components/form-components/TextArea.tsx
  46. 0 0
      dashboard/src/components/form-components/UploadArea.tsx
  47. 537 0
      dashboard/src/components/porter-form/FormDebugger.tsx
  48. 237 0
      dashboard/src/components/porter-form/PorterForm.tsx
  49. 442 0
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  50. 96 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  51. 74 53
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  52. 68 0
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  53. 109 0
      dashboard/src/components/porter-form/field-components/Input.tsx
  54. 518 0
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  55. 0 0
      dashboard/src/components/porter-form/field-components/MultiSelect.tsx
  56. 32 0
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  57. 99 0
      dashboard/src/components/porter-form/field-components/Select.tsx
  58. 23 0
      dashboard/src/components/porter-form/field-components/ServiceIPList.tsx
  59. 0 0
      dashboard/src/components/porter-form/field-components/ServiceRow.tsx
  60. 3 3
      dashboard/src/components/porter-form/field-components/VeleroForm.tsx
  61. 85 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  62. 248 0
      dashboard/src/components/porter-form/types.ts
  63. 2 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  64. 1 1
      dashboard/src/components/repo-selector/ActionDetails.tsx
  65. 71 50
      dashboard/src/components/repo-selector/BranchList.tsx
  66. 5 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  67. 1 1
      dashboard/src/components/repo-selector/NewGHAction.tsx
  68. 111 164
      dashboard/src/components/repo-selector/RepoList.tsx
  69. 0 99
      dashboard/src/components/values-form/Base64InputRow.tsx
  70. 0 323
      dashboard/src/components/values-form/FormDebugger.tsx
  71. 0 492
      dashboard/src/components/values-form/FormWrapper.tsx
  72. 0 69
      dashboard/src/components/values-form/RangeSlider.tsx
  73. 0 412
      dashboard/src/components/values-form/ValuesForm.tsx
  74. 4 5
      dashboard/src/index.html
  75. 1 1
      dashboard/src/main/Main.tsx
  76. 4 1
      dashboard/src/main/MainWrapper.tsx
  77. 5 5
      dashboard/src/main/auth/VerifyEmail.tsx
  78. 137 39
      dashboard/src/main/home/Home.tsx
  79. 65 76
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  80. 9 38
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  81. 12 4
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  82. 46 2
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  83. 11 5
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  84. 20 37
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  85. 28 21
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  86. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  87. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  88. 64 120
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx
  89. 17 8
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx
  90. 4 4
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  91. 13 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  92. 10 8
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  93. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  94. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  95. 328 239
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  96. 566 547
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  97. 7 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  98. 168 219
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  99. 17 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  100. 21 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx

+ 8 - 3
.github/workflows/dev.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/production.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:latest
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
           kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/staging.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter

+ 3 - 0
.gitignore

@@ -1,6 +1,7 @@
 .DS_Store
 .env
 docker/.env
+docker/github_app_private_key.pem
 app
 *.db
 test.yaml
@@ -10,6 +11,8 @@ internal/local_templates
 gon*.hcl
 *prod.Dockerfile
 staging.sh
+*.crt
+*.key
 
 # Local .terraform directories
 **/.terraform/*

+ 101 - 0
api/types/policy.go

@@ -0,0 +1,101 @@
+package types
+
+type PermissionScope string
+
+const (
+	UserScope        PermissionScope = "user"
+	ProjectScope     PermissionScope = "project"
+	ClusterScope     PermissionScope = "cluster"
+	NamespaceScope   PermissionScope = "namespace"
+	SettingsScope    PermissionScope = "settings"
+	ApplicationScope PermissionScope = "application"
+)
+
+type NameOrUInt struct {
+	Name string `json:"name"`
+	UInt uint   `json:"uint"`
+}
+
+type PolicyDocument struct {
+	Scope     PermissionScope                     `json:"scope"`
+	Resources []NameOrUInt                        `json:"resources"`
+	Verbs     []APIVerb                           `json:"verbs"`
+	Children  map[PermissionScope]*PolicyDocument `json:"children"`
+}
+
+type ScopeTree map[PermissionScope]ScopeTree
+
+/* ScopeHeirarchy describes the scope tree:
+			Project
+		   /	   \
+		Cluster   Settings
+		/
+	Namespace
+       |
+	 Release
+*/
+var ScopeHeirarchy = ScopeTree{
+	ProjectScope: {
+		ClusterScope: {
+			NamespaceScope: {
+				ApplicationScope: {},
+			},
+		},
+		SettingsScope: {},
+	},
+}
+
+type Policy []*PolicyDocument
+
+type APIVerb string
+
+const (
+	APIVerbGet    APIVerb = "get"
+	APIVerbCreate APIVerb = "create"
+	APIVerbList   APIVerb = "list"
+	APIVerbUpdate APIVerb = "update"
+	APIVerbDelete APIVerb = "delete"
+)
+
+type APIVerbGroup []APIVerb
+
+func ReadVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList}
+}
+
+func ReadWriteVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList, APIVerbCreate, APIVerbUpdate, APIVerbDelete}
+}
+
+var AdminPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+	},
+}
+
+var DeveloperPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: ReadVerbGroup(),
+			},
+		},
+	},
+}
+
+var ViewerPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: []APIVerb{},
+			},
+		},
+	},
+}

+ 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

+ 1 - 1
cli/cmd/deploy.go

@@ -234,7 +234,7 @@ func init() {
 		"path",
 		"p",
 		".",
-		"If local build, the path to the build directory",
+		"If local build, the path to the build directory. If remote build, the relative path from the repository root to the build directory.",
 	)
 
 	updateCmd.PersistentFlags().StringVarP(

+ 72 - 6
cli/cmd/deploy/build.go

@@ -2,6 +2,9 @@ package deploy
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
 
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -19,17 +22,34 @@ type BuildAgent struct {
 }
 
 // BuildDocker uses the local Docker daemon to build the image
-func (b *BuildAgent) BuildDocker(dockerAgent *docker.Agent, dst, tag string) error {
+func (b *BuildAgent) BuildDocker(
+	dockerAgent *docker.Agent,
+	basePath,
+	buildCtx,
+	dockerfilePath,
+	tag string,
+) error {
+	buildCtx, dockerfilePath, isDockerfileInCtx, err := ResolveDockerPaths(
+		basePath,
+		buildCtx,
+		dockerfilePath,
+	)
+
+	if err != nil {
+		return err
+	}
+
 	opts := &docker.BuildOpts{
-		ImageRepo:    b.imageRepo,
-		Tag:          tag,
-		BuildContext: dst,
-		Env:          b.env,
+		ImageRepo:         b.imageRepo,
+		Tag:               tag,
+		BuildContext:      buildCtx,
+		Env:               b.env,
+		DockerfilePath:    dockerfilePath,
+		IsDockerfileInCtx: isDockerfileInCtx,
 	}
 
 	return dockerAgent.BuildLocal(
 		opts,
-		b.LocalDockerfile,
 	)
 }
 
@@ -72,3 +92,49 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error
 		fmt.Sprintf("%s:%s", b.imageRepo, tag),
 	)
 }
+
+// ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path
+// to the build context that is absolute.
+//
+// The return value will be relative if the dockerfile exists within the build context, absolute
+// otherwise. The second return value is true if the dockerfile exists within the build context,
+// false otherwise.
+func ResolveDockerPaths(
+	basePath string,
+	buildContextPath string,
+	dockerfilePath string,
+) (
+	resBuildCtxPath string,
+	resDockerfilePath string,
+	isDockerfileRelative bool,
+	err error,
+) {
+	resBuildCtxPath, err = filepath.Abs(buildContextPath)
+	resDockerfilePath = dockerfilePath
+
+	// determine if the given dockerfile path is relative
+	if !filepath.IsAbs(dockerfilePath) {
+		// if path is relative, join basepath with path
+		resDockerfilePath = filepath.Join(basePath, dockerfilePath)
+	}
+
+	// compare the path to the dockerfile with the build context
+	pathComp, err := filepath.Rel(resBuildCtxPath, resDockerfilePath)
+
+	if err != nil {
+		return "", "", false, err
+	}
+
+	if !strings.HasPrefix(pathComp, ".."+string(os.PathSeparator)) {
+		// return the relative path to the dockerfile
+		return resBuildCtxPath, pathComp, true, nil
+	}
+
+	resDockerfilePath, err = filepath.Abs(resDockerfilePath)
+
+	if err != nil {
+		return "", "", false, err
+	}
+
+	return resBuildCtxPath, resDockerfilePath, false, nil
+}

+ 1 - 1
cli/cmd/deploy/create.go

@@ -279,7 +279,7 @@ func (c *CreateAgent) CreateFromDocker(
 	}
 
 	if opts.Method == DeployBuildTypeDocker {
-		err = buildAgent.BuildDocker(agent, opts.LocalPath, "latest")
+		err = buildAgent.BuildDocker(agent, opts.LocalPath, ".", opts.LocalDockerfile, "latest")
 	} else {
 		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest")
 	}

+ 36 - 9
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
@@ -193,7 +193,8 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 // buildpack or docker.
 func (d *DeployAgent) Build() error {
 	// if build is not local, fetch remote source
-	var dst string
+	var basePath string
+	buildCtx := d.opts.LocalPath
 	var err error
 
 	if !d.opts.Local {
@@ -208,18 +209,22 @@ func (d *DeployAgent) Build() error {
 		}
 
 		// download the repository from remote source into a temp directory
-		dst, err = d.downloadRepoToDir(zipResp.URLString)
+		basePath, err = d.downloadRepoToDir(zipResp.URLString)
+
+		if err != nil {
+			return err
+		}
 
 		if d.tag == "" {
 			shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
 			d.tag = shortRef
 		}
+	} else {
+		basePath, err = filepath.Abs(".")
 
 		if err != nil {
 			return err
 		}
-	} else {
-		dst = filepath.Dir(d.opts.LocalPath)
 	}
 
 	if d.tag == "" {
@@ -247,10 +252,16 @@ func (d *DeployAgent) Build() error {
 	}
 
 	if d.opts.Method == DeployBuildTypeDocker {
-		return buildAgent.BuildDocker(d.agent, dst, d.tag)
+		return buildAgent.BuildDocker(
+			d.agent,
+			basePath,
+			buildCtx,
+			d.dockerfilePath,
+			d.tag,
+		)
 	}
 
-	return buildAgent.BuildPack(d.agent, dst, d.tag)
+	return buildAgent.BuildPack(d.agent, buildCtx, d.tag)
 }
 
 // Push pushes a local image to the remote repository linked in the release
@@ -268,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
 	}

+ 4 - 0
cli/cmd/docker.go

@@ -138,6 +138,10 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		configFile.CredentialHelpers = make(map[string]string)
 	}
 
+	if configFile.AuthConfigs == nil {
+		configFile.AuthConfigs = make(map[string]types.AuthConfig)
+	}
+
 	for _, regURL := range regToAdd {
 		// if this is a dockerhub registry, see if an auth config has already been generated
 		// for index.docker.io

+ 79 - 5
cli/cmd/docker/builder.go

@@ -1,31 +1,59 @@
 package docker
 
 import (
+	"archive/tar"
+	"bytes"
 	"context"
 	"fmt"
+	"io"
+	"io/ioutil"
 	"os"
+	"time"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/term"
+	"github.com/pkg/errors"
 )
 
 type BuildOpts struct {
-	ImageRepo    string
-	Tag          string
-	BuildContext string
-	Env          map[string]string
+	ImageRepo         string
+	Tag               string
+	BuildContext      string
+	DockerfilePath    string
+	IsDockerfileInCtx bool
+
+	Env map[string]string
 }
 
 // BuildLocal
-func (a *Agent) BuildLocal(opts *BuildOpts, dockerfilePath string) error {
+func (a *Agent) BuildLocal(opts *BuildOpts) error {
+	dockerfilePath := opts.DockerfilePath
 	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{})
 
 	if err != nil {
 		return err
 	}
 
+	if !opts.IsDockerfileInCtx {
+		dockerfileCtx, err := os.Open(dockerfilePath)
+
+		if err != nil {
+			return errors.Errorf("unable to open Dockerfile: %v", err)
+		}
+
+		defer dockerfileCtx.Close()
+
+		// add the dockerfile to the build context
+		tar, dockerfilePath, err = AddDockerfileToBuildContext(dockerfileCtx, tar)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	buildArgs := make(map[string]*string)
 
 	for key, val := range opts.Env {
@@ -52,3 +80,49 @@ func (a *Agent) BuildLocal(opts *BuildOpts, dockerfilePath string) error {
 
 	return jsonmessage.DisplayJSONMessagesStream(out.Body, os.Stderr, termFd, isTerm, nil)
 }
+
+// AddDockerfileToBuildContext from a ReadCloser, returns a new archive and
+// the relative path to the dockerfile in the context.
+func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) {
+	file, err := ioutil.ReadAll(dockerfileCtx)
+	dockerfileCtx.Close()
+	if err != nil {
+		return nil, "", err
+	}
+	now := time.Now()
+	hdrTmpl := &tar.Header{
+		Mode:       0600,
+		Uid:        0,
+		Gid:        0,
+		ModTime:    now,
+		Typeflag:   tar.TypeReg,
+		AccessTime: now,
+		ChangeTime: now,
+	}
+	randomName := ".dockerfile." + stringid.GenerateRandomID()[:20]
+
+	buildCtx = archive.ReplaceFileTarWrapper(buildCtx, map[string]archive.TarModifierFunc{
+		// Add the dockerfile with a random filename
+		randomName: func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
+			return hdrTmpl, file, nil
+		},
+		// Update .dockerignore to include the random filename
+		".dockerignore": func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
+			if h == nil {
+				h = hdrTmpl
+			}
+
+			b := &bytes.Buffer{}
+			if content != nil {
+				if _, err := b.ReadFrom(content); err != nil {
+					return nil, nil, err
+				}
+			} else {
+				b.WriteString(".dockerignore")
+			}
+			b.WriteString("\n" + randomName + "\n")
+			return h, b.Bytes(), nil
+		},
+	})
+	return buildCtx, randomName, nil
+}

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

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

+ 1 - 0
cli/cmd/server.go

@@ -202,6 +202,7 @@ func startLocal(
 		"SQL_LITE=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		fmt.Sprintf("SERVER_PORT=%d", port),
 		"REDIS_ENABLED=false",
 	}...)
 

+ 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 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "v0.2.0"
+var Version string = "v0.5.0"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 1 - 30
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,34 +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{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -253,12 +253,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 4 - 33
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,38 +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{},
-	)
+	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;

+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

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

+ 4 - 1
dashboard/src/components/RadioSelector.tsx

@@ -17,7 +17,10 @@ export default class RadioSelector extends Component<PropsType, StateType> {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                 </Indicator>

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

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

+ 100 - 26
dashboard/src/components/SaveButton.tsx

@@ -3,15 +3,18 @@ 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
   makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
 };
 
 type StateType = {};
@@ -21,47 +24,71 @@ export default class SaveButton extends Component<PropsType, StateType> {
     if (this.props.status) {
       if (this.props.status === "successful") {
         return (
-          <StatusWrapper successful={true}>
-            <i className="material-icons">done</i> Successfully updated
+          <StatusWrapper position={this.props.statusPosition} successful={true}>
+            <i className="material-icons">done</i>
+            <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "loading") {
         return (
-          <StatusWrapper successful={false}>
-            <LoadingGif src={loading} /> Updating . . .
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>Updating . . .</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "error") {
         return (
-          <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> Could not update
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
-          <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> {this.props.status}
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
           </StatusWrapper>
         );
       }
     } else if (this.props.helper) {
       return (
-        <StatusWrapper successful={true}>{this.props.helper}</StatusWrapper>
+        <StatusWrapper position={this.props.statusPosition} successful={true}>
+          {this.props.helper}
+        </StatusWrapper>
       );
     }
   };
 
   render() {
     return (
-      <ButtonWrapper makeFlush={this.props.makeFlush}>
-        {this.renderStatus()}
+      <ButtonWrapper
+        makeFlush={this.props.makeFlush}
+        clearPosition={this.props.clearPosition}
+      >
+        {this.props.statusPosition !== "right" && (
+          <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>
+        )}
       </ButtonWrapper>
     );
   }
@@ -74,25 +101,39 @@ const LoadingGif = styled.img`
   margin-bottom: 0px;
 `;
 
-const StatusWrapper = styled.div`
+const StatusTextWrapper = styled.p`
+  display: -webkit-box;
+  line-clamp: 2;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 19px;
+  margin: 0;
+`;
+
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
   display: flex;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   color: #ffffff55;
-  margin-right: 25px;
-  padding: 0 10px;
-
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
   max-width: 500px;
-  white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 
   > i {
     font-size: 18px;
     margin-right: 10px;
-    color: ${(props: { successful: boolean }) =>
-      props.successful ? "#4797ff" : "#fcba03"};
+    float: left;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
   animation: statusFloatIn 0.5s;
@@ -111,33 +152,52 @@ const StatusWrapper = styled.div`
 `;
 
 const ButtonWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  position: absolute;
-  ${(props: { makeFlush: boolean }) => {
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
     if (!props.makeFlush) {
       return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
         bottom: 25px;
         right: 27px;
+        left: 27px;
       `;
     }
     return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
       bottom: 5px;
       right: 0;
     `;
   }}
 `;
 
-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;
+  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"};
@@ -149,4 +209,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;
+  }
 `;

+ 93 - 0
dashboard/src/components/SearchBar.tsx

@@ -0,0 +1,93 @@
+import React, { useState } from "react";
+import Button from "./Button";
+import styled from "styled-components";
+
+interface Props {
+  setSearchFilter: (x: string) => void;
+  disabled: boolean;
+  prompt?: string;
+}
+
+const SearchBar: React.FC<Props> = ({ setSearchFilter, disabled, prompt }) => {
+  const [searchInput, setSearchInput] = useState("");
+
+  return (
+    <SearchRowWrapper>
+      <SearchBarWrapper>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={searchInput}
+          onChange={(e: any) => {
+            setSearchInput(e.target.value);
+          }}
+          onKeyPress={({ key }) => {
+            if (key === "Enter") {
+              setSearchFilter(searchInput);
+            }
+          }}
+          placeholder={prompt}
+        />
+      </SearchBarWrapper>
+      <ButtonWrapper disabled={disabled}>
+        <Button
+          onClick={() => setSearchFilter(searchInput)}
+          disabled={disabled}
+        >
+          Search
+        </Button>
+      </ButtonWrapper>
+    </SearchRowWrapper>
+  );
+};
+
+export default SearchBar;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  background: #ffffff11;
+  border-bottom: 1px solid #606166;
+  margin-bottom: 10px;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-bottom: 0;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+`;
+
+const ButtonWrapper = styled.div`
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+  height: 40px;
+  display: flex;
+  align-items: center;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 13px;
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 20px;
+`;

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

+ 8 - 3
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 }) => {
@@ -31,6 +31,7 @@ export type TableProps = {
   onRowClick?: (row: Row) => void;
   isLoading: boolean;
   disableGlobalFilter?: boolean;
+  disableHover?: boolean;
 };
 
 const Table: React.FC<TableProps> = ({
@@ -39,6 +40,7 @@ const Table: React.FC<TableProps> = ({
   onRowClick,
   isLoading,
   disableGlobalFilter = false,
+  disableHover,
 }) => {
   const {
     getTableProps,
@@ -81,6 +83,7 @@ const Table: React.FC<TableProps> = ({
 
           return (
             <StyledTr
+              disableHover={disableHover}
               {...row.getRowProps()}
               enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
@@ -161,6 +164,8 @@ export const StyledTd = styled.td`
 
 export const StyledTHead = styled.thead`
   width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
 `;
 
 export const StyledTh = styled.th`
@@ -205,8 +210,8 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 7px;
-  margin-top: 7px;
+  margin-bottom: 15px;
+  margin-top: 0px;
   i {
     width: 18px;
     height: 18px;

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

+ 59 - 0
dashboard/src/components/UnauthorizedPage.tsx

@@ -0,0 +1,59 @@
+import React from "react";
+import styled from "styled-components";
+
+const UnauthorizedPage: React.FunctionComponent = () => (
+  <StyledUnauthorizedPage>
+    <Mega>
+      401
+      <Inside>You're not authorized to access this page</Inside>
+    </Mega>
+  </StyledUnauthorizedPage>
+);
+
+export default UnauthorizedPage;
+
+const StyledUnauthorizedPage = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  padding-bottom: 20px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 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


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

@@ -8,6 +8,11 @@ import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
 import { keysIn } from "lodash";
 
+export type KeyValue = {
+  key: string;
+  value: string;
+};
+
 type PropsType = {
   label?: string;
   values: any;
@@ -45,21 +50,32 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
 
   valuesToObject = () => {
     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()));
+    };
     this.state.values.forEach((entry: any, i: number) => {
-      obj[entry.key] = entry.value;
+      if (isNumber(entry.value)) {
+        obj[entry.key] = entry.value;
+      } else {
+        obj[entry.key] = fixNewlines(entry.value);
+      }
     });
     return obj;
   };
 
-  objectToValues = (obj: any) => {
-    let values = [] as any[];
-    Object.keys(obj).forEach((key: string, i: number) => {
-      let entry = {} as any;
-      entry.key = key;
-      entry.value = obj[key];
-      values.push(entry);
-    });
-    return values;
+  objectToValues = (obj: Record<string, string>): KeyValue[] => {
+    return Object.entries(obj)?.map(([key, value]) => ({ key, value }));
   };
 
   renderDeleteButton = (i: number) => {
@@ -93,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") {
@@ -148,16 +164,18 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       return (
         <Modal
           onRequestClose={() => this.setState({ showEnvModal: false })}
-          width="665px"
-          height="342px"
+          width="765px"
+          height="542px"
         >
           <LoadEnvGroupModal
+            existingValues={this.props.values}
             namespace={this.props.externalValues?.namespace}
             clusterId={this.props.externalValues?.clusterId}
             closeModal={() => this.setState({ showEnvModal: false })}
-            setValues={(values: any) => {
-              this.props.setValues(values);
-              this.setState({ values: this.objectToValues(values) });
+            setValues={(values) => {
+              const newValues = { ...this.props.values, ...values };
+              this.props.setValues(newValues);
+              this.setState({ values: this.objectToValues(newValues) });
             }}
           />
         </Modal>

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

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

@@ -0,0 +1,237 @@
+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) {
+      console.log("hm fuck");
+      return props.renderTabContents(currentTab);
+    }
+
+    const tab = formData.tabs?.filter((tab) => tab.name == currentTab)[0];
+    console.log("currentTab", currentTab);
+    console.log("tab", tab);
+
+    // 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;
+  };
+
+  console.log(getTabOptions());
+  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;

+ 74 - 53
dashboard/src/components/values-form/InputArray.tsx → dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -1,33 +1,36 @@
-import React, { Component } from "react";
+import React 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]}`);
+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] : [],
+      },
     }
-    return arr;
-  };
+  );
+
+  if (state == undefined) return <></>;
 
-  renderDeleteButton = (values: string[], i: number) => {
-    if (!this.props.disabled) {
+  const renderDeleteButton = (values: string[], i: number) => {
+    if (!props.isReadOnly) {
       return (
         <DeleteButton
           onClick={() => {
-            let v = [...values];
-            v.splice(i, 1);
-            this.props.setValues(v);
+            setVars((prev) => {
+              return {
+                [props.variable]: prev[props.variable]
+                  .slice(0, i)
+                  .concat(prev[props.variable].slice(i + 1)),
+              };
+            });
           }}
         >
           <i className="material-icons">cancel</i>
@@ -36,7 +39,7 @@ export default class InputArray extends Component<PropsType, StateType> {
     }
   };
 
-  renderInputList = (values: string[]) => {
+  const renderInputList = (values: string[]) => {
     return (
       <>
         {values.map((value: string, i: number) => {
@@ -47,13 +50,20 @@ export default class InputArray extends Component<PropsType, StateType> {
                 width="270px"
                 value={value}
                 onChange={(e: any) => {
-                  let v = [...values];
-                  v[i] = e.target.value;
-                  this.props.setValues(v);
+                  e.persist();
+                  setVars((prev) => {
+                    return {
+                      [props.variable]: prev[props.variable].map(
+                        (t: string, j: number) => {
+                          return i == j ? e.target.value : t;
+                        }
+                      ),
+                    };
+                  });
                 }}
-                disabled={this.props.disabled}
+                disabled={props.isReadOnly}
               />
-              {this.renderDeleteButton(values, i)}
+              {renderDeleteButton(values, i)}
             </InputWrapper>
           );
         })}
@@ -61,30 +71,41 @@ export default class InputArray extends Component<PropsType, StateType> {
     );
   };
 
-  render() {
-    let { values } = this.props;
-
-    if (!Array.isArray(values)) {
-      values = this.dict2arr(values);
-    }
+  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>
+  );
+};
 
-    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>
-    );
-  }
-}
+export default ArrayInput;
+
+export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
+  vars,
+  props: ArrayInputField
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: [],
+      };
+};
 
 const AddRowButton = styled.div`
   display: flex;

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

@@ -0,0 +1,68 @@
+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
+) => {
+  if (vars[props.variable] === false) {
+    return { [props.variable]: false };
+  } else if (vars[props.variable] === true) {
+    return { [props.variable]: true };
+  }
+  return { [props.variable]: !!props.settings?.default };
+};

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

@@ -0,0 +1,109 @@
+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] !== ""
+        : settings?.default != undefined,
+    },
+    initVars: {
+      [variable]: value
+        ? clipOffUnit(settings?.unit, value[0])
+        : settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  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;

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

@@ -0,0 +1,518 @@
+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 = (): any => {
+    let obj = {} as any;
+    state.values?.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()}
+            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]: {},
+    };
+
+  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;
+`;

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

@@ -0,0 +1,99 @@
+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.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;

+ 2 - 2
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -49,12 +49,12 @@ const ActionConfEditor: React.FC<Props> = (props) => {
   } else if (!branch) {
     return (
       <>
-        <ExpandedWrapper>
+        <ExpandedWrapperAlt>
           <BranchList
             actionConfig={actionConfig}
             setBranch={(branch: string) => setBranch(branch)}
           />
-        </ExpandedWrapper>
+        </ExpandedWrapperAlt>
         <Br />
         <BackButton
           width="135px"

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

+ 71 - 50
dashboard/src/components/repo-selector/BranchList.tsx

@@ -1,36 +1,28 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
-import info from "assets/info.svg";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
-import { ActionConfigType } from "../..//shared/types";
+import { ActionConfigType } from "../../shared/types";
 
 import Loading from "../Loading";
+import SearchBar from "../SearchBar";
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType;
   setBranch: (x: string) => void;
 };
 
-type StateType = {
-  loading: boolean;
-  error: boolean;
-  branches: string[];
-};
-
-export default class BranchList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    error: false,
-    branches: [] as string[],
-  };
+const BranchList: React.FC<Props> = ({ setBranch, actionConfig }) => {
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const [branches, setBranches] = useState<string[]>([]);
+  const [searchFilter, setSearchFilter] = useState(null);
 
-  componentDidMount() {
-    let { actionConfig } = this.props;
-    let { currentProject } = this.context;
+  const { currentProject } = useContext(Context);
 
+  useEffect(() => {
     // Get branches
     api
       .getBranches(
@@ -44,17 +36,19 @@ export default class BranchList extends Component<PropsType, StateType> {
           name: actionConfig.git_repo.split("/")[1],
         }
       )
-      .then((res) =>
-        this.setState({ branches: res.data, loading: false, error: false })
-      )
+      .then((res) => {
+        setBranches(res.data);
+        setLoading(false);
+        setError(false);
+      })
       .catch((err) => {
         console.log(err);
-        this.setState({ loading: false, error: true });
+        setLoading(false);
+        setError(true);
       });
-  }
+  }, []);
 
-  renderBranchList = () => {
-    let { branches, loading, error } = this.state;
+  const renderBranchList = () => {
     if (loading) {
       return (
         <LoadingWrapper>
@@ -65,34 +59,47 @@ export default class BranchList extends Component<PropsType, StateType> {
       return <LoadingWrapper>Error loading branches</LoadingWrapper>;
     }
 
-    return branches.map((branch: string, i: number) => {
+    let results =
+      searchFilter != null
+        ? branches.filter((branch) => {
+            return branch
+              .toLowerCase()
+              .includes(searchFilter.toLowerCase() || "");
+          })
+        : branches.slice(0, 10);
+
+    if (results.length == 0) {
+      return <LoadingWrapper>No matching Branches found.</LoadingWrapper>;
+    }
+    return results.map((branch: string, i: number) => {
       return (
         <BranchName
           key={i}
           lastItem={i === branches.length - 1}
-          onClick={() => this.props.setBranch(branch)}
+          onClick={() => setBranch(branch)}
         >
-          <img src={branch_icon} />
+          <img src={branch_icon} alt={"branch icon"} />
           {branch}
         </BranchName>
       );
     });
   };
 
-  render() {
-    return (
-      <>
-        <InfoRow lastItem={false}>
-          <img src={info} />
-          Select Branch
-        </InfoRow>
-        {this.renderBranchList()}
-      </>
-    );
-  }
-}
+  return (
+    <>
+      <SearchBar
+        setSearchFilter={setSearchFilter}
+        disabled={error || loading}
+        prompt={"Search branches..."}
+      />
+      <BranchListWrapper>
+        <ExpandedWrapper>{renderBranchList()}</ExpandedWrapper>
+      </BranchListWrapper>
+    </>
+  );
+};
 
-BranchList.contextType = Context;
+export default BranchList;
 
 const BranchName = styled.div`
   display: flex;
@@ -123,14 +130,6 @@ const BranchName = styled.div`
   }
 `;
 
-const InfoRow = styled(BranchName)`
-  cursor: default;
-  color: #ffffff55;
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;
@@ -140,3 +139,25 @@ const LoadingWrapper = styled.div`
   font-size: 13px;
   color: #ffffff44;
 `;
+
+const BranchListWrapper = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapper = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  border: 0px solid #ffffff44;
+  max-height: 221px;
+  top: 40px;
+
+  > i {
+    font-size: 18px;
+    display: block;
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+`;

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

@@ -273,6 +273,10 @@ export default class ContentsList extends Component<PropsType, StateType> {
         );
       }
 
+      if (processes.length == 0) {
+        this.props.setProcfilePath("");
+      }
+
       return (
         <Overlay>
           <BgOverlay
@@ -638,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";
 

+ 111 - 164
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useContext, useEffect, useRef } from "react";
+import React, { useState, useContext, useEffect } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
 
@@ -7,8 +7,14 @@ import { RepoType, ActionConfigType } from "shared/types";
 import { Context } from "shared/Context";
 
 import Loading from "../Loading";
-import Button from "../Button";
-import { AxiosResponse } from "axios";
+import SearchBar from "../SearchBar";
+import Helper from "../form-components/Helper";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
 
 type Props = {
   actionConfig: ActionConfigType | null;
@@ -24,80 +30,96 @@ const RepoList: React.FC<Props> = ({
   readOnly,
 }) => {
   const [repos, setRepos] = useState<RepoType[]>([]);
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState(false);
+  const [repoLoading, setRepoLoading] = useState(true);
+  const [repoError, setRepoError] = useState(false);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
   const [searchFilter, setSearchFilter] = useState(null);
-  const [searchInput, setSearchInput] = useState("");
   const { currentProject } = useContext(Context);
 
   // TODO: Try to unhook before unmount
   useEffect(() => {
-    // load git repo ids, and then repo names from that
-    // this only happens once during the lifecycle
-    new Promise((resolve, reject) => {
-      if (!userId && userId !== 0) {
-        api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
-          .then(async (res) => {
-            resolve(res.data.map((gitrepo: any) => gitrepo.id));
-          })
-          .catch((err) => {
-            reject(err);
-          });
-      } else {
-        resolve([userId]);
-      }
-    })
-      .then((ids: number[]) => {
-        Promise.all(
-          ids.map((id) => {
-            return new Promise((resolve, reject) => {
-              api
-                .getGitRepoList(
-                  "<token>",
-                  {},
-                  { project_id: currentProject.id, git_repo_id: id }
-                )
-                .then((res) => {
-                  resolve(res.data);
-                })
-                .catch((err) => {
-                  reject(err);
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      })
+      .finally(() => {
+        // load git repo ids, and then repo names from that
+        // this only happens once during the lifecycle
+        new Promise((resolve, reject) => {
+          if (!userId && userId !== 0) {
+            api
+              .getGitRepos("<token>", {}, { project_id: currentProject.id })
+              .then(async (res) => {
+                resolve(res.data);
+              })
+              .catch(() => {
+                resolve([]);
+              });
+          } else {
+            reject(null);
+          }
+        })
+          .then((ids: number[]) => {
+            Promise.all(
+              ids.map((id) => {
+                return new Promise((resolve, reject) => {
+                  api
+                    .getGitRepoList(
+                      "<token>",
+                      {},
+                      { project_id: currentProject.id, git_repo_id: id }
+                    )
+                    .then((res) => {
+                      resolve(res.data);
+                    })
+                    .catch((err) => {
+                      reject(err);
+                    });
                 });
-            });
-          })
-        )
-          .then((repos: RepoType[][]) => {
-            const names = new Set();
-            // note: would be better to use .flat() here but you need es2019 for
-            setRepos(
-              repos
-                .map((arr, idx) =>
-                  arr.map((el) => {
-                    el.GHRepoID = ids[idx];
-                    return el;
-                  })
-                )
-                .reduce((acc, val) => acc.concat(val), [])
-                .reduce((acc, val) => {
-                  if (!names.has(val.FullName)) {
-                    names.add(val.FullName);
-                    return acc.concat(val);
-                  } else {
-                    return acc;
-                  }
-                }, [])
-            );
-            setLoading(false);
+              })
+            )
+              .then((repos: RepoType[][]) => {
+                const names = new Set();
+                // note: would be better to use .flat() here but you need es2019 for
+                setRepos(
+                  repos
+                    .map((arr, idx) =>
+                      arr.map((el) => {
+                        el.GHRepoID = ids[idx];
+                        return el;
+                      })
+                    )
+                    .reduce((acc, val) => acc.concat(val), [])
+                    .reduce((acc, val) => {
+                      if (!names.has(val.FullName)) {
+                        names.add(val.FullName);
+                        return acc.concat(val);
+                      } else {
+                        return acc;
+                      }
+                    }, [])
+                );
+                setRepoLoading(false);
+              })
+              .catch((_) => {
+                setRepoLoading(false);
+                setRepoError(true);
+              });
           })
           .catch((_) => {
-            setLoading(false);
-            setError(true);
+            setRepoLoading(false);
+            setRepoError(true);
           });
-      })
-      .catch((_) => {
-        setLoading(false);
-        setError(true);
       });
   }, []);
 
@@ -109,25 +131,30 @@ const RepoList: React.FC<Props> = ({
   };
 
   const renderRepoList = () => {
-    if (loading) {
+    if (repoLoading || accessLoading) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error) {
+    } else if (repoError || accessError) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
     } else if (repos.length == 0) {
-      return (
+      return accessData.has_access ? (
         <LoadingWrapper>
           No connected Github repos found. You can
-          <A
-            href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-          >
-            log in with GitHub
+          <A href={"/api/integrations/github-app/install"}>
+            Install Porter in more repositories
           </A>
           .
         </LoadingWrapper>
+      ) : (
+        <LoadingWrapper>
+          No connected Github repos found.
+          <A href={"/api/integrations/github-app/oauth"}>
+            Authorize Porter to view your repositories.
+          </A>
+        </LoadingWrapper>
       );
     }
 
@@ -135,7 +162,9 @@ const RepoList: React.FC<Props> = ({
     let results =
       searchFilter != null
         ? repos.filter((repo: RepoType) => {
-            return repo.FullName.includes(searchFilter || "");
+            return repo.FullName.toLowerCase().includes(
+              searchFilter.toLowerCase() || ""
+            );
           })
         : repos.slice(0, 10);
 
@@ -151,7 +180,7 @@ const RepoList: React.FC<Props> = ({
             onClick={() => setRepo(repo)}
             readOnly={readOnly}
           >
-            <img src={github} />
+            <img src={github} alt={"github icon"} />
             {repo.FullName}
           </RepoName>
         );
@@ -165,31 +194,11 @@ const RepoList: React.FC<Props> = ({
     } else {
       return (
         <>
-          <SearchRowTop>
-            <SearchBar>
-              <i className="material-icons">search</i>
-              <SearchInput
-                value={searchInput}
-                onChange={(e: any) => {
-                  setSearchInput(e.target.value);
-                }}
-                onKeyPress={({ key }) => {
-                  if (key === "Enter") {
-                    setSearchFilter(searchInput);
-                  }
-                }}
-                placeholder="Search repos..."
-              />
-            </SearchBar>
-            <ButtonWrapper disabled={loading || error}>
-              <Button
-                onClick={() => setSearchFilter(searchInput)}
-                disabled={loading || error}
-              >
-                Search
-              </Button>
-            </ButtonWrapper>
-          </SearchRowTop>
+          <SearchBar
+            setSearchFilter={setSearchFilter}
+            disabled={repoError || repoLoading || accessError || accessLoading}
+            prompt={"Search repos..."}
+          />
           <RepoListWrapper>
             <ExpandedWrapper>{renderRepoList()}</ExpandedWrapper>
           </RepoListWrapper>
@@ -203,39 +212,12 @@ const RepoList: React.FC<Props> = ({
 
 export default RepoList;
 
-const ButtonWrapper = styled.div`
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-  height: 40px;
-  display: flex;
-  align-items: center;
-`;
-
 const RepoListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
   overflow-y: auto;
 `;
 
-const SearchRow = styled.div`
-  display: flex;
-  align-items: center;
-  height: 40px;
-  background: #ffffff11;
-  border-bottom: 1px solid #606166;
-  margin-bottom: 10px;
-`;
-
-const SearchRowTop = styled(SearchRow)`
-  border-bottom: 0;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-`;
-
 const RepoName = styled.div`
   display: flex;
   width: 100%;
@@ -280,18 +262,6 @@ const RepoName = styled.div`
   }
 `;
 
-const InfoRow = styled(RepoName)`
-  cursor: default;
-  color: #ffffff55;
-  :hover {
-    background: #ffffff11;
-
-    > i {
-      background: none;
-    }
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;
@@ -330,26 +300,3 @@ const A = styled.a`
   margin-left: 5px;
   cursor: pointer;
 `;
-
-const SearchBar = styled.div`
-  display: flex;
-  flex: 1;
-
-  > i {
-    color: #aaaabb;
-    padding-top: 1px;
-    margin-left: 13px;
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  height: 20px;
-`;

+ 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 - 492
dashboard/src/components/values-form/FormWrapper.tsx

@@ -1,492 +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) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
-      }
-      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)
-    ) {
-      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 - 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"

+ 1 - 1
dashboard/src/main/Main.tsx

@@ -142,7 +142,7 @@ export default class Main extends Component<PropsType, StateType> {
           path="/register"
           render={() => {
             if (!this.state.isLoggedIn) {
-              return <Register authenticate={this.initialize} />;
+              return <Register authenticate={this.authenticate} />;
             } else {
               return <Redirect to="/" />;
             }

+ 4 - 1
dashboard/src/main/MainWrapper.tsx

@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
 import { ContextProvider } from "../shared/Context";
 import Main from "./Main";
 import { RouteComponentProps, withRouter } from "react-router";
+import AuthProvider from "shared/auth/AuthContext";
 
 type PropsType = RouteComponentProps & {};
 
@@ -14,7 +15,9 @@ class MainWrapper extends Component<PropsType, StateType> {
     let { history, location } = this.props;
     return (
       <ContextProvider history={history} location={location}>
-        <Main />
+        <AuthProvider>
+          <Main />
+        </AuthProvider>
       </ContextProvider>
     );
   }

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

@@ -36,13 +36,13 @@ export default class VerifyEmail extends Component<PropsType, StateType> {
     let formSection = (
       <div>
         <InputWrapper>
-          <StatusText>A verification email will be sent to</StatusText>
+          <StatusText>A verification email should have been sent to</StatusText>
           <Email>{this.context.user?.email}</Email>
         </InputWrapper>
-        <StatusText>
-          Proceed below to verify your email and finish setting up your profile
-        </StatusText>
-        <Button onClick={this.handleSendEmail}>Send Verification Email</Button>
+        <StatusText>Didn't get it?</StatusText>
+        <Button onClick={this.handleSendEmail}>
+          Resend Verification Email
+        </Button>
       </div>
     );
 

+ 137 - 39
dashboard/src/main/home/Home.tsx

@@ -26,13 +26,35 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
-
-type PropsType = RouteComponentProps & {
-  logOut: () => void;
-  currentProject: ProjectType;
-  currentCluster: ClusterType;
-  currentRoute: PorterUrl;
-};
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
+import discordLogo from "../../assets/discord.svg";
+// Guarded components
+const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
+  "get",
+  "list",
+  "update",
+  "create",
+  "delete",
+])(ProjectSettings);
+
+const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
+  "get",
+  "list",
+  "update",
+  "create",
+  "delete",
+])(Integrations);
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    logOut: () => void;
+    currentProject: ProjectType;
+    currentCluster: ClusterType;
+    currentRoute: PorterUrl;
+  };
 
 type StateType = {
   forceSidebar: boolean;
@@ -90,9 +112,6 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
       .getCapabilities("<token>", {}, {})
       .then((res) => {
@@ -236,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;
 
@@ -336,9 +354,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
         );
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       return <Templates />;
     } else if (currentView === "new-project") {
@@ -360,6 +378,15 @@ class Home extends Component<PropsType, StateType> {
           }
         />
       );
+    } else {
+      return (
+        <>
+          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+            <Icon src={discordLogo} />
+            Join Our Discord
+          </DiscordButton>
+        </>
+      );
     }
   };
 
@@ -458,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>
@@ -471,19 +504,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
           </Modal>
         )}
-        {currentModal === "UpdateClusterModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
-            height="275px"
-          >
-            <UpdateClusterModal
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </Modal>
-        )}
+
+        {/* We should be careful, as this component is named Update but is for deletion */}
+        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
+          currentModal === "UpdateClusterModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="565px"
+              height="275px"
+            >
+              <UpdateClusterModal
+                setRefreshClusters={(x: boolean) =>
+                  this.setState({ forceRefreshClusters: x })
+                }
+              />
+            </Modal>
+          )}
         {currentModal === "IntegrationsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
@@ -502,25 +538,55 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
-        {currentModal === "NamespaceModal" && (
+        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
+          currentModal === "NamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="600px"
+              height="220px"
+            >
+              <NamespaceModal />
+            </Modal>
+          )}
+        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
+          currentModal === "DeleteNamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="700px"
+              height="280px"
+            >
+              <DeleteNamespaceModal />
+            </Modal>
+          )}
+
+        {currentModal === "EditInviteOrCollaboratorModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
-            height="220px"
+            height="250px"
           >
-            <NamespaceModal />
+            <EditInviteOrCollaboratorModal />
           </Modal>
         )}
-        {currentModal === "DeleteNamespaceModal" && (
+        {currentModal === "AccountSettingsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
+            width="760px"
+            height="440px"
           >
-            <DeleteNamespaceModal />
+            <AccountSettingsModal />
           </Modal>
         )}
 
+        {currentOverlay && (
+          <ConfirmOverlay
+            show={true}
+            message={currentOverlay.message}
+            onYes={currentOverlay.onYes}
+            onNo={currentOverlay.onNo}
+          />
+        )}
+
         {this.renderSidebar()}
 
         <ViewWrapper>
@@ -548,12 +614,12 @@ class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-export default withRouter(Home);
+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;
@@ -563,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`
@@ -591,3 +655,37 @@ const StyledHome = styled.div`
     }
   }
 `;
+
+const DiscordButton = styled.a`
+  position: absolute;
+  z-index: 100;
+  text-decoration: none;
+  bottom: 17px;
+  display: flex;
+  align-items: center;
+  width: 170px;
+  left: 15px;
+  border: 2px solid #ffffff44;
+  border-radius: 3px;
+  color: #ffffff44;
+  height: 40px;
+  font-family: Work Sans, sans-serif;
+  font-size: 14px;
+  font-weight: bold;
+  cursor: pointer;
+  :hover {
+    > img {
+      opacity: 60%;
+    }
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
+const Icon = styled.img`
+  height: 25px;
+  width: 25px;
+  opacity: 30%;
+  margin-left: 7px;
+  margin-right: 5px;
+`;

+ 65 - 76
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -13,22 +13,25 @@ import {
   pushQueryParams,
 } from "shared/routing";
 
+import DashboardHeader from "./DashboardHeader";
 import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-  setSidebar: (x: boolean) => void;
-  currentView: PorterUrl;
-};
+import GuardedRoute from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+    setSidebar: (x: boolean) => void;
+    currentView: PorterUrl;
+  };
 
 type StateType = {
   namespace: string;
@@ -110,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.";
@@ -128,14 +123,23 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
   renderBody = () => {
     let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
       <>
-        <ControlRow>
-          <Button
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-          >
-            <i className="material-icons">add</i> Launch Template
-          </Button>
+        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+          {isAuthorizedToAdd && (
+            <Button
+              onClick={() =>
+                pushFiltered(this.props, "/launch", ["project_id"])
+              }
+            >
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+          )}
           <SortFilterWrapper>
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
@@ -172,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()}
       </>
     );
@@ -203,9 +196,30 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             isMetricsInstalled={this.state.isMetricsInstalled}
           />
         </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
+        <GuardedRoute
+          path={"/jobs"}
+          scope="job"
+          resource=""
+          verb={["get", "list"]}
+        >
           {this.renderContents()}
-        </Route>
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/applications"}
+          scope="application"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/env-groups"}
+          scope="env_group"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
         <Route path={["/cluster-dashboard"]}>
           <DashboardRoutes />
         </Route>
@@ -216,11 +230,21 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 ClusterDashboard.contextType = Context;
 
-export default withRouter(ClusterDashboard);
+export default withRouter(withAuth(ClusterDashboard));
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -364,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;
-  }
-`;

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

@@ -11,9 +11,14 @@ import api from "shared/api";
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  release: any;
 };
 
-const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+const Chart: React.FunctionComponent<Props> = ({
+  chart,
+  controllers,
+  release,
+}) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
   const context = useContext(Context);
@@ -105,7 +110,10 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
             margin_left={"17px"}
           />
           <LastDeployed>
-            <Dot>•</Dot> Last deployed {readableDate(chart.info.last_deployed)}
+            <Dot>•</Dot> Last deployed{" "}
+            {readableDate(
+              release?.info?.last_deployed || chart.info.last_deployed
+            )}
           </LastDeployed>
         </InfoWrapper>
 
@@ -115,7 +123,7 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
         </TagWrapper>
       </BottomWrapper>
 
-      <Version>v{chart.version}</Version>
+      <Version>v{release?.version || chart.version}</Version>
     </StyledChart>
   );
 };
@@ -244,7 +252,7 @@ const StyledChart = styled.div`
   cursor: pointer;
   margin-bottom: 25px;
   padding: 1px;
-  border-radius: 5px;
+  border-radius: 8px;
   box-shadow: 0 5px 8px 0px #00000033;
   position: relative;
   border: 2px solid #9eb4ff00;

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

@@ -33,6 +33,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   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);
 
@@ -103,6 +104,47 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
   };
 
+  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}`;
@@ -149,6 +191,7 @@ const ChartList: React.FunctionComponent<Props> = ({
       "daemonset",
       "replicaset",
     ]);
+    setupHelmReleasesWebsocket();
 
     return () => {
       closeAllWebsockets();
@@ -198,6 +241,7 @@ const ChartList: React.FunctionComponent<Props> = ({
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
           controllers={controllers || {}}
+          release={releases[chart.name] || {}}
         />
       );
     });
@@ -216,7 +260,7 @@ const Placeholder = styled.div`
   color: #ffffff44;
   background: #26282f;
   border-radius: 5px;
-  height: 320px;
+  height: 370px;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -234,5 +278,5 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledChartList = styled.div`
-  padding-bottom: 85px;
+  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%")};
 `;

+ 20 - 37
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,13 +1,15 @@
-import React, { useContext, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import TabSelector from "components/TabSelector";
+import TitleSection from "components/TitleSection";
 
 import NodeList from "./NodeList";
 
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
+import useAuth from "shared/auth/useAuth";
 
 type TabEnum = "nodes" | "settings" | "namespaces";
 
@@ -22,6 +24,9 @@ const tabOptions: {
 
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
+  const [isAuthorized] = useAuth();
+
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
@@ -35,13 +40,24 @@ export const Dashboard: React.FunctionComponent = () => {
     }
   };
 
+  useEffect(() => {
+    setCurrentTabOptions(
+      tabOptions.filter((option) => {
+        if (option.value === "settings") {
+          return isAuthorized("cluster", "", ["get", "delete"]);
+        }
+        return true;
+      })
+    );
+  }, [isAuthorized]);
+
   return (
     <>
       <TitleSection>
         <DashboardIcon>
           <i className="material-icons">device_hub</i>
         </DashboardIcon>
-        <Title>{context.currentCluster.name}</Title>
+        {context.currentCluster.name}
       </TitleSection>
 
       <InfoSection>
@@ -56,7 +72,7 @@ export const Dashboard: React.FunctionComponent = () => {
       </InfoSection>
 
       <TabSelector
-        options={tabOptions}
+        options={currentTabOptions}
         currentTab={currentTab}
         setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
       />
@@ -71,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;
@@ -113,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;
-  }
-`;

+ 28 - 21
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 import { ClusterType, ProjectType } from "shared/types";
 import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
+import useAuth from "shared/auth/useAuth";
 
 const OptionsDropdown: React.FC = ({ children }) => {
   const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +69,8 @@ export const NamespaceList: React.FunctionComponent = () => {
     setCurrentModal("DeleteNamespaceModal", namespace);
   };
 
+  const [isAuthorized] = useAuth();
+
   const isAvailableForDeletion = (namespaceName: string) => {
     // Only the namespaces that doesn't start with kube- or has by name default will be
     // available for deletion (as those are the k8s namespaces)
@@ -109,7 +112,7 @@ export const NamespaceList: React.FunctionComponent = () => {
             (namespace) => namespace.metadata.name === data.Object.metadata.name
           );
           oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
-          return oldNamespaces;
+          return [...oldNamespaces];
         });
       }
     };
@@ -133,18 +136,20 @@ export const NamespaceList: React.FunctionComponent = () => {
   return (
     <NamespaceListWrapper>
       <ControlRow>
-        <Button
-          onClick={() =>
-            setCurrentModal(
-              "NamespaceModal",
-              namespaces.map((namespace) => ({
-                value: namespace.metadata.name,
-              }))
-            )
-          }
-        >
-          <i className="material-icons">add</i> Add namespace
-        </Button>
+        {isAuthorized("namespace", "", ["get", "create"]) && (
+          <Button
+            onClick={() =>
+              setCurrentModal(
+                "NamespaceModal",
+                namespaces.map((namespace) => ({
+                  value: namespace.metadata.name,
+                }))
+              )
+            }
+          >
+            <i className="material-icons">add</i> Add namespace
+          </Button>
+        )}
       </ControlRow>
       <NamespacesGrid>
         {sortedNamespaces.map((namespace) => {
@@ -165,14 +170,16 @@ export const NamespaceList: React.FunctionComponent = () => {
                   {namespace?.status?.phase}
                 </Status>
               </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) && (
-                <OptionsDropdown>
-                  <DropdownOption onClick={() => onDelete(namespace)}>
-                    <i className="material-icons-outlined">delete</i>
-                    <span>Delete</span>
-                  </DropdownOption>
-                </OptionsDropdown>
-              )}
+              {isAuthorized("namespace", "", ["get", "delete"]) &&
+                isAvailableForDeletion(namespace?.metadata?.name) &&
+                namespace?.status?.phase === "Active" && (
+                  <OptionsDropdown>
+                    <DropdownOption onClick={() => onDelete(namespace)}>
+                      <i className="material-icons-outlined">delete</i>
+                      <span>Delete</span>
+                    </DropdownOption>
+                  </OptionsDropdown>
+                )}
             </StyledCard>
           );
         })}

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

@@ -18,11 +18,11 @@ const NodeList: React.FC = () => {
   const columns = useMemo<Column<any>[]>(
     () => [
       {
-        Header: "Node name",
+        Header: "Node Name",
         accessor: "name",
       },
       {
-        Header: "Machine type",
+        Header: "Machine Type",
         accessor: "machine_type",
       },
       {

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

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

@@ -5,8 +5,13 @@ import key from "assets/key.svg";
 
 import { Context } from "shared/Context";
 
+export type EnvGroupData = {
+  data: Record<string, string>;
+  metadata: any;
+};
+
 type PropsType = {
-  envGroup: any;
+  envGroup: EnvGroupData;
   setExpanded: () => void;
 };
 
@@ -71,6 +76,13 @@ export default class EnvGroup extends Component<PropsType, StateType> {
   }
 }
 
+export function formattedEnvironmentValue(value: string) {
+  if (value.startsWith("PORTERSECRET_")) {
+    return "••••";
+  }
+  return value;
+}
+
 EnvGroup.contextType = Context;
 
 const BottomWrapper = styled.div`

+ 10 - 8
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -197,14 +197,16 @@ export default class EnvGroupArray extends Component<PropsType, StateType> {
   };
 
   readFile = (env: string) => {
-    let envObj = this.parseEnv(env, null);
-    let push = true;
-    let _values = this.props.values;
-
-    for (let key in envObj) {
-      for (var i = 0; i < this.props.values.length; i++) {
-        let existingKey = this.props.values[i]["key"];
-        if (key === existingKey) {
+    const envObj = this.parseEnv(env, null);
+    const _values = this.props.values;
+
+    for (const key in envObj) {
+      let push = true;
+
+      for (let i = 0; i < this.props.values.length; i++) {
+        const existingKey = this.props.values[i]["key"];
+        const isExistingKeyDeleted = this.props.values[i]["deleted"];
+        if (key === existingKey && !isExistingKeyDeleted) {
           _values[i]["value"] = envObj[key];
           push = false;
         }

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

@@ -14,10 +14,12 @@ import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushQueryParams } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+  };
 
 type StateType = {
   expand: boolean;
@@ -59,16 +61,22 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         />
       );
     } else {
+      const isAuthorizedToAdd = this.props.isAuthorized("env_group", "", [
+        "get",
+        "create",
+      ]);
       return (
         <>
-          <ControlRow>
-            <Button
-              onClick={() =>
-                this.setState({ createEnvMode: !this.state.createEnvMode })
-              }
-            >
-              <i className="material-icons">add</i> Create Env Group
-            </Button>
+          <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+            {isAuthorizedToAdd && (
+              <Button
+                onClick={() =>
+                  this.setState({ createEnvMode: !this.state.createEnvMode })
+                }
+              >
+                <i className="material-icons">add</i> Create Env Group
+              </Button>
+            )}
             <SortFilterWrapper>
               <SortSelector
                 setSortType={(sortType) => this.setState({ sortType })}
@@ -131,7 +139,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
 
 EnvGroupDashboard.contextType = Context;
 
-export default withRouter(EnvGroupDashboard);
+export default withRouter(withAuth(EnvGroupDashboard));
 
 const SortFilterWrapper = styled.div`
   width: 468px;
@@ -141,7 +149,12 @@ const SortFilterWrapper = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 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;

+ 328 - 239
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -1,22 +1,27 @@
 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 = {
+type PropsType = WithAuthProps & {
   namespace: string;
   envGroup: any;
   currentCluster: ClusterType;
@@ -26,10 +31,17 @@ type PropsType = {
 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 = [
@@ -37,53 +49,114 @@ const tabOptions = [
   { value: "settings", label: "Settings" },
 ];
 
-export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+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
+    // inside the settings tab. (This is make to avoid confussion for the user)
+    this.setState((prevState) => {
+      return {
+        ...prevState,
+        tabOptions: prevState.tabOptions.filter((option) => {
+          if (option.value === "settings") {
+            return this.props.isAuthorized("env_group", "", ["get", "delete"]);
+          }
+          return true;
+        }),
+      };
+    });
   }
 
-  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);
 
@@ -150,9 +223,15 @@ export default 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":
@@ -166,45 +245,93 @@ export default 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={
+                  !this.props.isAuthorized("env_group", "", [
+                    "get",
+                    "create",
+                    "delete",
+                    "update",
+                  ])
+                }
               />
             </InnerWrapper>
-            <SaveButton
-              text="Update"
-              onClick={() => this.handleUpdateValues()}
-              status={this.state.saveValuesStatus}
-              makeFlush={true}
-            />
+            {this.props.isAuthorized("env_group", "", ["get", "update"]) && (
+              <SaveButton
+                text="Update"
+                onClick={() => this.handleUpdateValues()}
+                status={this.state.saveValuesStatus}
+                makeFlush={true}
+              />
+            )}
           </TabWrapper>
         );
       default:
         return (
           <TabWrapper>
-            <InnerWrapper full={true}>
-              <Heading>Manage Environment Group</Heading>
-              <Helper>
-                Permanently delete this set of environment variables. This
-                action cannot be undone.
-              </Helper>
-              <Button
-                color="#b91133"
-                onClick={() => this.setState({ showDeleteOverlay: true })}
-              >
-                Delete {name}
-              </Button>
-            </InnerWrapper>
+            {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
+                  action cannot be undone.
+                </Helper>
+                <Button
+                  color="#b91133"
+                  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>
+              </InnerWrapper>
+            )}
           </TabWrapper>
         );
     }
   };
 
   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",
     });
@@ -212,11 +339,13 @@ export default 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>",
@@ -232,71 +361,61 @@ export default 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={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>
       </>
     );
@@ -305,6 +424,75 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
 
 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;
@@ -335,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;
 `;
 
@@ -423,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;
@@ -454,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;
+`;

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 566 - 547
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx


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

+ 168 - 219
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,15 +11,15 @@ 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";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -32,33 +33,31 @@ 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;
 };
 
-export default class ExpandedJobChart extends Component<PropsType, StateType> {
+class ExpandedJobChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
     imageIsPlaceholder: false,
     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)
@@ -421,14 +420,24 @@ export default 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"])) {
+      saveButton = null;
+    }
+
     switch (currentTab) {
       case "jobs":
         if (this.state.imageIsPlaceholder) {
@@ -446,26 +455,37 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         return (
           <TabWrapper>
+            {saveButton}
             <JobList
               jobs={this.state.jobs}
               setJobs={(jobs: any) => {
                 this.setState({ jobs });
               }}
             />
-            {saveButton}
           </TabWrapper>
         );
       case "settings":
         return (
-          <SettingsSection
-            showSource={true}
-            currentChart={this.state.currentChart}
-            refreshChart={() => this.refreshChart(0)}
-            setShowDeleteOverlay={(x: boolean) =>
-              this.setState({ showDeleteOverlay: x })
-            }
-            saveButtonText="Save Config"
-          />
+          this.props.isAuthorized("job", "", ["get", "delete"]) && (
+            <SettingsSection
+              showSource={true}
+              currentChart={this.state.currentChart}
+              refreshChart={() => this.refreshChart(0)}
+              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"
+            />
+          )
         );
       default:
     }
@@ -478,41 +498,18 @@ export default 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"])) {
+      rightTabOptions.push({ label: "Settings", value: "settings" });
     }
 
-    tabOptions.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();
@@ -537,9 +534,10 @@ export default 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>",
@@ -553,22 +551,11 @@ export default 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;
@@ -576,59 +563,71 @@ export default 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}
-              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>
       </>
     );
@@ -637,6 +636,46 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 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`
@@ -647,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;
@@ -666,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;
@@ -753,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;
@@ -804,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);
+    }
+  }
 `;

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

@@ -120,28 +120,44 @@ 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;
 `;
 
 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);
+    }
+  }
 `;

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно