Ivan Galakhov 4 лет назад
Родитель
Сommit
deb75cd283
90 измененных файлов с 5825 добавлено и 1798 удалено
  1. 56 11
      cli/cmd/run.go
  2. 47 0
      dashboard/package-lock.json
  3. 3 0
      dashboard/package.json
  4. 121 0
      dashboard/react-table.d.ts
  5. 6 10
      dashboard/src/App.tsx
  6. BIN
      dashboard/src/assets/trash.png
  7. 111 0
      dashboard/src/components/CopyToClipboard.tsx
  8. 0 0
      dashboard/src/components/Helper.tsx
  9. 189 0
      dashboard/src/components/PageNotFound.tsx
  10. 53 1
      dashboard/src/components/Selector.tsx
  11. 208 0
      dashboard/src/components/Table.tsx
  12. 3 7
      dashboard/src/components/image-selector/ImageList.tsx
  13. 5 101
      dashboard/src/components/image-selector/ImageSelector.tsx
  14. 54 3
      dashboard/src/components/image-selector/TagList.tsx
  15. 11 4
      dashboard/src/components/values-form/InputRow.tsx
  16. 0 1
      dashboard/src/components/values-form/UploadArea.tsx
  17. 7 0
      dashboard/src/components/values-form/ValuesForm.tsx
  18. 2 2
      dashboard/src/main/Main.tsx
  19. 23 0
      dashboard/src/main/MainWrapper.tsx
  20. 62 71
      dashboard/src/main/home/Home.tsx
  21. 98 0
      dashboard/src/main/home/NoClusterPlaceholder.tsx
  22. 78 48
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  23. 27 3
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  24. 15 6
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  25. 152 170
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  26. 182 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  27. 149 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  28. 387 0
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  29. 175 0
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  30. 65 0
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx
  31. 7 2
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  32. 0 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  33. 9 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  34. 3 3
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  35. 143 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  36. 29 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  37. 83 57
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  38. 65 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  39. 59 25
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  40. 105 22
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  41. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  42. 219 34
      dashboard/src/main/home/dashboard/ClusterList.tsx
  43. 3 13
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  44. 3 9
      dashboard/src/main/home/dashboard/Dashboard.tsx
  45. 11 4
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  46. 12 5
      dashboard/src/main/home/integrations/Integrations.tsx
  47. 35 36
      dashboard/src/main/home/launch/Launch.tsx
  48. 0 14
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  49. 42 37
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  50. 14 8
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  51. 11 2
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  52. 11 2
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  53. 200 0
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  54. 0 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  55. 208 0
      dashboard/src/main/home/modals/NamespaceModal.tsx
  56. 4 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  57. 13 17
      dashboard/src/main/home/project-settings/InviteList.tsx
  58. 17 9
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  59. 3 4
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  60. 7 3
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  61. 12 11
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  62. 2 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  63. 32 7
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  64. 6 2
      dashboard/src/main/home/sidebar/Drawer.tsx
  65. 10 7
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  66. 82 13
      dashboard/src/main/home/sidebar/Sidebar.tsx
  67. 51 10
      dashboard/src/shared/Context.tsx
  68. 69 0
      dashboard/src/shared/api.tsx
  69. 43 8
      dashboard/src/shared/routing.tsx
  70. 11 0
      dashboard/src/shared/types.tsx
  71. 2 2
      docs/deploy/applications/deploying-from-docker-registry.md
  72. 27 0
      docs/guides/advanced-nginx-settings.md
  73. 1 0
      go.sum
  74. 9 0
      internal/forms/integration.go
  75. 4 0
      internal/forms/k8s.go
  76. 1002 958
      internal/kubernetes/agent.go
  77. 99 0
      internal/kubernetes/errors.go
  78. 122 0
      internal/kubernetes/nodes/helpers.go
  79. 77 0
      internal/kubernetes/nodes/nodes.go
  80. 29 6
      internal/models/cluster.go
  81. 21 10
      internal/registry/registry.go
  82. 17 0
      internal/repository/gorm/auth.go
  83. 51 0
      internal/repository/gorm/auth_test.go
  84. 1 0
      internal/repository/integrations.go
  85. 17 0
      internal/repository/memory/auth.go
  86. 30 1
      server/api/cluster_handler.go
  87. 101 0
      server/api/integration_handler.go
  88. 214 6
      server/api/k8s_handler.go
  89. 1 1
      server/api/oauth_google_handler.go
  90. 75 0
      server/router/router.go

+ 56 - 11
cli/cmd/run.go

@@ -56,25 +56,55 @@ func init() {
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 
 
-	podNames, err := getPods(client, namespace, args[0])
+	podsSimple, err := getPods(client, namespace, args[0])
 
 
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
 	}
 	}
 
 
 	// if length of pods is 0, throw error
 	// if length of pods is 0, throw error
-	pod := ""
+	var selectedPod podSimple
 
 
-	if len(podNames) == 0 {
+	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podNames) == 1 {
-		pod = podNames[0]
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
 	} else {
 	} else {
-		pod, err = utils.PromptSelect("Select the pod:", podNames)
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
 	}
 	}
 
 
 	restConf, err := getRESTConfig(client)
 	restConf, err := getRESTConfig(client)
@@ -83,7 +113,7 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 	}
 
 
-	return executeRun(restConf, namespace, pod, args[1:])
+	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 }
 
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -120,7 +150,12 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 	return restConf, nil
 	return restConf, nil
 }
 }
 
 
-func getPods(client *api.Client, namespace, releaseName string) ([]string, error) {
+type podSimple struct {
+	Name           string
+	ContainerNames []string
+}
+
+func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
 	pID := getProjectID()
 	pID := getProjectID()
 	cID := getClusterID()
 	cID := getClusterID()
 
 
@@ -130,16 +165,25 @@ func getPods(client *api.Client, namespace, releaseName string) ([]string, error
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	res := make([]string, 0)
+	res := make([]podSimple, 0)
 
 
 	for _, pod := range resp {
 	for _, pod := range resp {
-		res = append(res, pod.ObjectMeta.Name)
+		containerNames := make([]string, 0)
+
+		for _, container := range pod.Spec.Containers {
+			containerNames = append(containerNames, container.Name)
+		}
+
+		res = append(res, podSimple{
+			Name:           pod.ObjectMeta.Name,
+			ContainerNames: containerNames,
+		})
 	}
 	}
 
 
 	return res, nil
 	return res, nil
 }
 }
 
 
-func executeRun(config *rest.Config, namespace, name string, args []string) error {
+func executeRun(config *rest.Config, namespace, name, container string, args []string) error {
 	restClient, err := rest.RESTClientFor(config)
 	restClient, err := rest.RESTClientFor(config)
 
 
 	if err != nil {
 	if err != nil {
@@ -159,6 +203,7 @@ func executeRun(config *rest.Config, namespace, name string, args []string) erro
 	req.Param("stdin", "true")
 	req.Param("stdin", "true")
 	req.Param("stdout", "true")
 	req.Param("stdout", "true")
 	req.Param("tty", "true")
 	req.Param("tty", "true")
+	req.Param("container", container)
 
 
 	t := term.TTY{
 	t := term.TTY{
 		In:  os.Stdin,
 		In:  os.Stdin,

+ 47 - 0
dashboard/package-lock.json

@@ -677,6 +677,15 @@
         "@types/react-router": "*"
         "@types/react-router": "*"
       }
       }
     },
     },
+    "@types/react-table": {
+      "version": "7.7.1",
+      "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.1.tgz",
+      "integrity": "sha512-oed13swLIS4Ffyo4jAjl9lGbYMaY0uavKoI9GNMvf2R6vh8JfpRUpizQ90X1VI4WrhfaMb/HMsN7TTBvkGOQXQ==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/react-transition-group": {
     "@types/react-transition-group": {
       "version": "4.4.0",
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
@@ -2105,6 +2114,16 @@
         }
         }
       }
       }
     },
     },
+    "clipboard": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.8.tgz",
+      "integrity": "sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==",
+      "requires": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
     "cliui": {
     "cliui": {
       "version": "5.0.0",
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
@@ -2710,6 +2729,11 @@
         "rimraf": "^2.6.3"
         "rimraf": "^2.6.3"
       }
       }
     },
     },
+    "delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
     "depd": {
     "depd": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@@ -3950,6 +3974,14 @@
         }
         }
       }
       }
     },
     },
+    "good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
+      "requires": {
+        "delegate": "^3.1.2"
+      }
+    },
     "graceful-fs": {
     "graceful-fs": {
       "version": "4.2.4",
       "version": "4.2.4",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
@@ -6259,6 +6291,11 @@
         "tiny-warning": "^1.0.0"
         "tiny-warning": "^1.0.0"
       }
       }
     },
     },
+    "react-table": {
+      "version": "7.7.0",
+      "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
+      "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
+    },
     "react-transition-group": {
     "react-transition-group": {
       "version": "4.4.1",
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
       "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
@@ -6595,6 +6632,11 @@
         "ajv-keywords": "^3.5.2"
         "ajv-keywords": "^3.5.2"
       }
       }
     },
     },
+    "select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+      "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
+    },
     "select-hose": {
     "select-hose": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -7529,6 +7571,11 @@
         "setimmediate": "^1.0.4"
         "setimmediate": "^1.0.4"
       }
       }
     },
     },
+    "tiny-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+    },
     "tiny-invariant": {
     "tiny-invariant": {
       "version": "1.1.0",
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",
       "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",

+ 3 - 0
dashboard/package.json

@@ -25,6 +25,7 @@
     "anser": "^2.0.1",
     "anser": "^2.0.1",
     "axios": "^0.20.0",
     "axios": "^0.20.0",
     "brace": "^0.11.1",
     "brace": "^0.11.1",
+    "clipboard": "^2.0.8",
     "d3-array": "^2.11.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
     "dotenv": "^8.2.0",
@@ -41,6 +42,7 @@
     "react-dom": "^16.13.1",
     "react-dom": "^16.13.1",
     "react-modal": "^3.11.2",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-router-dom": "^5.2.0",
+    "react-table": "^7.7.0",
     "semver": "^7.3.5",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
     "styled-components": "^5.2.0"
   },
   },
@@ -63,6 +65,7 @@
     "@types/react-modal": "^3.10.6",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",
     "@types/react-router-dom": "^5.1.5",
+    "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",
     "file-loader": "^6.1.0",

+ 121 - 0
dashboard/react-table.d.ts

@@ -0,0 +1,121 @@
+import {
+  UseColumnOrderInstanceProps,
+  UseColumnOrderState,
+  UseExpandedHooks,
+  UseExpandedInstanceProps,
+  UseExpandedOptions,
+  UseExpandedRowProps,
+  UseExpandedState,
+  UseFiltersColumnOptions,
+  UseFiltersColumnProps,
+  UseFiltersInstanceProps,
+  UseFiltersOptions,
+  UseFiltersState,
+  UseGlobalFiltersColumnOptions,
+  UseGlobalFiltersInstanceProps,
+  UseGlobalFiltersOptions,
+  UseGlobalFiltersState,
+  UseGroupByCellProps,
+  UseGroupByColumnOptions,
+  UseGroupByColumnProps,
+  UseGroupByHooks,
+  UseGroupByInstanceProps,
+  UseGroupByOptions,
+  UseGroupByRowProps,
+  UseGroupByState,
+  UsePaginationInstanceProps,
+  UsePaginationOptions,
+  UsePaginationState,
+  UseResizeColumnsColumnOptions,
+  UseResizeColumnsColumnProps,
+  UseResizeColumnsOptions,
+  UseResizeColumnsState,
+  UseRowSelectHooks,
+  UseRowSelectInstanceProps,
+  UseRowSelectOptions,
+  UseRowSelectRowProps,
+  UseRowSelectState,
+  UseRowStateCellProps,
+  UseRowStateInstanceProps,
+  UseRowStateOptions,
+  UseRowStateRowProps,
+  UseRowStateState,
+  UseSortByColumnOptions,
+  UseSortByColumnProps,
+  UseSortByHooks,
+  UseSortByInstanceProps,
+  UseSortByOptions,
+  UseSortByState,
+} from "react-table";
+
+declare module "react-table" {
+  // take this file as-is, or comment out the sections that don't apply to your plugin configuration
+
+  export interface TableOptions<
+    D extends object = {}
+  > extends UseExpandedOptions<D>,
+      UseFiltersOptions<D>,
+      UseGlobalFiltersOptions<D>,
+      UseGroupByOptions<D>,
+      UsePaginationOptions<D>,
+      UseResizeColumnsOptions<D>,
+      UseRowSelectOptions<D>,
+      UseRowStateOptions<D>,
+      UseSortByOptions<D>,
+      // note that having Record here allows you to add anything to the options, this matches the spirit of the
+      // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
+      // feature set, this is a safe default.
+      Record<string, any> {}
+
+  export interface Hooks<D extends object = {}>
+    extends UseExpandedHooks<D>,
+      UseGroupByHooks<D>,
+      UseRowSelectHooks<D>,
+      UseSortByHooks<D> {}
+
+  export interface TableInstance<D extends object = {}>
+    extends UseColumnOrderInstanceProps<D>,
+      UseExpandedInstanceProps<D>,
+      UseFiltersInstanceProps<D>,
+      UseGlobalFiltersInstanceProps<D>,
+      UseGroupByInstanceProps<D>,
+      UsePaginationInstanceProps<D>,
+      UseRowSelectInstanceProps<D>,
+      UseRowStateInstanceProps<D>,
+      UseSortByInstanceProps<D> {}
+
+  export interface TableState<D extends object = {}>
+    extends UseColumnOrderState<D>,
+      UseExpandedState<D>,
+      UseFiltersState<D>,
+      UseGlobalFiltersState<D>,
+      UseGroupByState<D>,
+      UsePaginationState<D>,
+      UseResizeColumnsState<D>,
+      UseRowSelectState<D>,
+      UseRowStateState<D>,
+      UseSortByState<D> {}
+
+  export interface ColumnInterface<D extends object = {}>
+    extends UseFiltersColumnOptions<D>,
+      UseGlobalFiltersColumnOptions<D>,
+      UseGroupByColumnOptions<D>,
+      UseResizeColumnsColumnOptions<D>,
+      UseSortByColumnOptions<D> {}
+
+  export interface ColumnInstance<D extends object = {}>
+    extends UseFiltersColumnProps<D>,
+      UseGroupByColumnProps<D>,
+      UseResizeColumnsColumnProps<D>,
+      UseSortByColumnProps<D> {}
+
+  export interface Cell<D extends object = {}, V = any>
+    extends UseGroupByCellProps<D>,
+      UseRowStateCellProps<D> {}
+
+  export interface Row<D extends object = {}>
+    extends UseExpandedRowProps<D>,
+      UseGroupByRowProps<D>,
+      UseRowSelectRowProps<D>,
+      UseRowStateRowProps<D> {}
+}

+ 6 - 10
dashboard/src/App.tsx

@@ -1,18 +1,14 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
+import { BrowserRouter } from "react-router-dom";
 
 
-import { ContextProvider } from "./shared/Context";
-import Main from "./main/Main";
+import MainWrapper from "./main/MainWrapper";
 
 
-type PropsType = {};
-
-type StateType = {};
-
-export default class App extends Component<PropsType, StateType> {
+export default class App extends Component {
   render() {
   render() {
     return (
     return (
-      <ContextProvider>
-        <Main />
-      </ContextProvider>
+      <BrowserRouter>
+        <MainWrapper />
+      </BrowserRouter>
     );
     );
   }
   }
 }
 }

BIN
dashboard/src/assets/trash.png


+ 111 - 0
dashboard/src/components/CopyToClipboard.tsx

@@ -0,0 +1,111 @@
+// import ClipboardJS from "clipboard";
+import ClipboardJS from "clipboard";
+import React, { Component, RefObject } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
+import styled from "styled-components";
+import { styled as materialStyled } from "@material-ui/core/styles";
+
+type PropsType = {
+  text: string;
+  onSuccess?: (e: ClipboardJS.Event) => void;
+  onError?: (e: ClipboardJS.Event) => void;
+  wrapperProps?: any;
+  as?: any;
+};
+
+type StateType = {
+  clipboard: ClipboardJS | undefined;
+  success: boolean;
+};
+
+/**
+ * Dynamic component to enable copy to clipboard.
+ *  By default, it will be displayed as a span, when the user clicks over the span
+ *  it will copy the text provided
+ *
+ * Examples of usage:
+ * <CopyToClipboard
+ *   as={MyCustomComponent}
+ *   text={`some usefull text ${var}`}
+ *   onSuccess={(e) => console.log("Success event:", e)}
+ *   onError={(e) => console.log("Error event:", e)}
+ * >
+ *   Some content
+ * </CopyToClipboard>
+ */
+export default class CopyToClipboard extends Component<PropsType, StateType> {
+  triggerRef: RefObject<HTMLSpanElement>;
+
+  state: StateType = {
+    clipboard: undefined,
+    success: false,
+  };
+
+  constructor(props: PropsType) {
+    super(props);
+    this.triggerRef = React.createRef();
+  }
+
+  componentDidMount() {
+    const trigger = this.triggerRef.current;
+    if (!trigger) {
+      console.error("Couldn't mount clipboardjs on wrapper component");
+      return;
+    }
+    const clipboard = new ClipboardJS(trigger, {
+      text: () => {
+        return this.props.text;
+      },
+    });
+
+    clipboard.on("success", (e) => {
+      this.setState({ success: true });
+      this.props.onSuccess && this.props.onSuccess(e);
+      setTimeout(() => {
+        this.setState({ success: false });
+      }, 2000);
+    });
+
+    this.props.onError && clipboard.on("error", this.props.onError);
+
+    this.setState({ clipboard });
+  }
+
+  componentWillUnmount() {
+    if (this.state.clipboard && this.state.clipboard.destroy) {
+      this.state.clipboard.destroy();
+    }
+  }
+
+  render() {
+    return (
+      <Tooltip
+        title={
+          <div
+            style={{
+              fontFamily: "Work Sans, sans-serif",
+              fontSize: "12px",
+              fontWeight: "normal",
+              padding: "5px 6px",
+            }}
+          >
+            Copied to clipboard
+          </div>
+        }
+        open={this.state.success}
+        placement="bottom"
+        arrow
+      >
+        <DynamicSpanComponent
+          as={this.props.as || "span"}
+          ref={this.triggerRef}
+          {...(this.props.wrapperProps || {})}
+        >
+          {this.props.children}
+        </DynamicSpanComponent>
+      </Tooltip>
+    );
+  }
+}
+
+const DynamicSpanComponent = styled.span``;

+ 0 - 0
dashboard/src/components/Helper.tsx


+ 189 - 0
dashboard/src/components/PageNotFound.tsx

@@ -0,0 +1,189 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { pushFiltered } from "shared/routing";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class PageNotFound extends Component<PropsType, StateType> {
+  state = {};
+
+  render() {
+    let { pathname } = this.props.location;
+    let params = this.props.match.params as any;
+    let { baseRoute } = params;
+    if (baseRoute === "applications") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Application Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="140px"
+              onClick={() =>
+                pushFiltered(this.props, "/applications", ["project_id"])
+              }
+            >
+              <i className="material-icons">arrow_back</i>
+              Applications
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    } else if (baseRoute === "jobs") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Job Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="90px"
+              onClick={() => pushFiltered(this.props, "/jobs", ["project_id"])}
+            >
+              <i className="material-icons">arrow_back</i>
+              Jobs
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    }
+    return (
+      <StyledPageNotFound>
+        <Mega>
+          404
+          <Inside>Page Not Found</Inside>
+        </Mega>
+        <Flex>
+          <BackButton
+            width="145px"
+            onClick={() =>
+              pushFiltered(this.props, "/dashboard", ["project_id"])
+            }
+          >
+            <i className="material-icons">home</i>
+            Return Home
+          </BackButton>
+          {pathname && (
+            <>
+              <Splitter>|</Splitter>
+              <Helper>Could not find "{pathname}"</Helper>
+            </>
+          )}
+        </Flex>
+      </StyledPageNotFound>
+    );
+  }
+}
+
+export default withRouter(PageNotFound);
+
+const Splitter = styled.div`
+  margin: 0 20px;
+  font-size: 27px;
+  font-weight: 200;
+  color: #ffffff15;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Helper = styled.div`
+  font-size: 15px;
+  max-width: 550px;
+  margin-right: -50px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const StyledPageNotFound = 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;
+  }
+`;

+ 53 - 1
dashboard/src/components/Selector.tsx

@@ -1,9 +1,12 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 
 type PropsType = {
 type PropsType = {
   activeValue: string;
   activeValue: string;
+  refreshOptions?: () => void;
   options: { value: string; label: string }[];
   options: { value: string; label: string }[];
+  addButton?: boolean;
   setActiveValue: (x: string) => void;
   setActiveValue: (x: string) => void;
   width: string;
   width: string;
   height?: string;
   height?: string;
@@ -76,6 +79,21 @@ export default class Selector extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  renderAddButton = () => {
+    if (this.props.addButton) {
+      return (
+        <NewOption
+          onClick={() => {
+            this.context.setCurrentModal("NamespaceModal", this.props.options);
+          }}
+        >
+          <Plus>+</Plus>
+          Add Namespace
+        </NewOption>
+      );
+    }
+  };
+
   renderDropdown = () => {
   renderDropdown = () => {
     if (this.state.expanded) {
     if (this.state.expanded) {
       return (
       return (
@@ -91,6 +109,7 @@ export default class Selector extends Component<PropsType, StateType> {
         >
         >
           {this.renderDropdownLabel()}
           {this.renderDropdownLabel()}
           {this.renderOptionList()}
           {this.renderOptionList()}
+          {this.renderAddButton()}
         </Dropdown>
         </Dropdown>
       );
       );
     }
     }
@@ -107,11 +126,17 @@ export default class Selector extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { activeValue } = this.props;
     let { activeValue } = this.props;
+
     return (
     return (
       <StyledSelector width={this.props.width}>
       <StyledSelector width={this.props.width}>
         <MainSelector
         <MainSelector
           ref={this.parentRef}
           ref={this.parentRef}
-          onClick={() => this.setState({ expanded: !this.state.expanded })}
+          onClick={() => {
+            if (this.props.refreshOptions) {
+              this.props.refreshOptions();
+            }
+            this.setState({ expanded: !this.state.expanded });
+          }}
           expanded={this.state.expanded}
           expanded={this.state.expanded}
           width={this.props.width}
           width={this.props.width}
           height={this.props.height}
           height={this.props.height}
@@ -127,6 +152,13 @@ export default class Selector extends Component<PropsType, StateType> {
   }
   }
 }
 }
 
 
+Selector.contextType = Context;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
 const TextWrap = styled.div`
 const TextWrap = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
@@ -141,6 +173,26 @@ const DropdownLabel = styled.div`
   margin: 10px 13px;
   margin: 10px 13px;
 `;
 `;
 
 
+const NewOption = styled.div`
+  display: flex;
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff00;
+  height: 37px;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
 const Option = styled.div`
 const Option = styled.div`
   width: 100%;
   width: 100%;
   border-top: 1px solid #00000000;
   border-top: 1px solid #00000000;

+ 208 - 0
dashboard/src/components/Table.tsx

@@ -0,0 +1,208 @@
+import React from "react";
+import styled from "styled-components";
+import { Column, Row, useGlobalFilter, useTable } from "react-table";
+import InputRow from "./values-form/InputRow";
+import Loading from "components/Loading";
+
+const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRow>
+      <i className="material-icons">search</i>
+      <SearchInput
+        value={value}
+        onChange={(e: any) => {
+          onChange(e.target.value);
+        }}
+        placeholder="Search"
+      />
+    </SearchRow>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+};
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    rows,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter
+  );
+
+  const renderRows = () => {
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!rows.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {rows.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              {...row.getRowProps()}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {row.cells.map((cell) => (
+                <StyledTd {...cell.getCellProps()}>
+                  {cell.render("Cell")}
+                </StyledTd>
+              ))}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 20px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+  margin-bottom: 7px;
+  margin-top: 7px;
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;

+ 3 - 7
dashboard/src/components/image-selector/ImageList.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
   setClickedImage: (x: ImageType) => void;
+  disableImageSelect?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -162,11 +163,6 @@ export default class ImageList extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
   renderImageList = () => {
     let { images, loading, error } = this.state;
     let { images, loading, error } = this.state;
 
 
@@ -206,8 +202,8 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
   };
 
 
   renderBackButton = () => {
   renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.props.clickedImage) {
+    let { setSelectedImageUrl, clickedImage, disableImageSelect } = this.props;
+    if (clickedImage && !disableImageSelect) {
       return (
       return (
         <BackButton
         <BackButton
           width="175px"
           width="175px"

+ 5 - 101
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setSelectedTag: (x: string) => void;
   noTagSelection?: boolean;
   noTagSelection?: boolean;
+  disableImageSelect?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -36,87 +37,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
     clickedImage: null as ImageType | null,
   };
   };
 
 
-  // componentDidMount() {
-  //   const { currentProject, setCurrentError } = this.context;
-  //   let images = [] as ImageType[];
-  //   let errors = [] as number[];
-  //   api
-  //     .getProjectRegistries("<token>", {}, { id: currentProject.id })
-  //     .then(async (res) => {
-  //       let registries = res.data;
-  //       if (registries.length === 0) {
-  //         this.setState({ loading: false });
-  //       }
-
-  //       // Loop over connected image registries
-  //       registries.forEach(async (registry: any, i: number) => {
-  //         await new Promise((nextController: (res?: any) => void) => {
-  //           api
-  //             .getImageRepos(
-  //               "<token>",
-  //               {},
-  //               {
-  //                 project_id: currentProject.id,
-  //                 registry_id: registry.id,
-  //               }
-  //             )
-  //             .then((res) => {
-  //               res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-  //               // Loop over found image repositories
-  //               let newImg = res.data.map((img: any) => {
-  //                 if (this.props.selectedImageUrl === img.uri) {
-  //                   this.setState({
-  //                     clickedImage: {
-  //                       kind: registry.service,
-  //                       source: img.uri,
-  //                       name: img.name,
-  //                       registryId: registry.id,
-  //                     },
-  //                   });
-  //                 }
-  //                 return {
-  //                   kind: registry.service,
-  //                   source: img.uri,
-  //                   name: img.name,
-  //                   registryId: registry.id,
-  //                 };
-  //               });
-  //               images.push(...newImg);
-  //               errors.push(0);
-  //             })
-  //             .catch(() => errors.push(1))
-  //             .finally(() => {
-  //               if (i == registries.length - 1) {
-  //                 let error =
-  //                   errors.reduce((a, b) => {
-  //                     return a + b;
-  //                   }) == registries.length
-  //                     ? true
-  //                     : false;
-
-  //                 this.setState({
-  //                   images,
-  //                   loading: false,
-  //                   error,
-  //                 });
-  //               }
-
-  //               nextController();
-  //             });
-  //         });
-  //       });
-  //     })
-  //     .catch((err) => {
-  //       console.log(err);
-  //       this.setState({ error: true });
-  //     });
-  // }
-
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
   renderImageList = () => {
     let { images, loading, error } = this.state;
     let { images, loading, error } = this.state;
 
 
@@ -155,24 +75,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
     });
   };
   };
 
 
-  renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.state.clickedImage) {
-      return (
-        <BackButton
-          width="175px"
-          onClick={() => {
-            setSelectedImageUrl("");
-            this.setState({ clickedImage: null });
-          }}
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Image Repo
-        </BackButton>
-      );
-    }
-  };
-
   renderSelected = () => {
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
     let { clickedImage } = this.state;
@@ -192,6 +94,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
       <Label>
         <img src={icon} />
         <img src={icon} />
         <Input
         <Input
+          disabled={this.props.disableImageSelect}
           autoFocus={true}
           autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           value={selectedImageUrl}
@@ -233,6 +136,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
 
         {this.state.isExpanded ? (
         {this.state.isExpanded ? (
           <ImageList
           <ImageList
+            disableImageSelect={this.props.disableImageSelect}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
             clickedImage={this.state.clickedImage}
@@ -284,13 +188,13 @@ const BackButton = styled.div`
   }
   }
 `;
 `;
 
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean }>`
   outline: 0;
   outline: 0;
   background: none;
   background: none;
   border: 0;
   border: 0;
   font-size: 13px;
   font-size: 13px;
   width: calc(100% - 60px);
   width: calc(100% - 60px);
-  color: white;
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
 `;
 `;
 
 
 const ImageItem = styled.div`
 const ImageItem = styled.div`

+ 54 - 3
dashboard/src/components/image-selector/TagList.tsx

@@ -32,7 +32,8 @@ export default class TagList extends Component<PropsType, StateType> {
     currentTag: this.props.selectedTag,
     currentTag: this.props.selectedTag,
   };
   };
 
 
-  componentDidMount() {
+  refreshTagList = () => {
+    this.setState({ loading: true });
     const { currentProject } = this.context;
     const { currentProject } = this.context;
 
 
     let splits = this.props.selectedImageUrl.split("/");
     let splits = this.props.selectedImageUrl.split("/");
@@ -55,6 +56,14 @@ export default class TagList extends Component<PropsType, StateType> {
         }
         }
       )
       )
       .then((res) => {
       .then((res) => {
+        // Sort if timestamp is available
+        if (res.data.length > 0 && res.data[0].pushed_at) {
+          res.data.sort((a: any, b: any) => {
+            let d1 = new Date(a.pushed_at);
+            let d2 = new Date(b.pushed_at);
+            return d2.getTime() - d1.getTime();
+          });
+        }
         let tags = res.data.map((tag: any, i: number) => {
         let tags = res.data.map((tag: any, i: number) => {
           return tag.tag;
           return tag.tag;
         });
         });
@@ -64,6 +73,10 @@ export default class TagList extends Component<PropsType, StateType> {
         console.log(err);
         console.log(err);
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       });
       });
+  };
+
+  componentDidMount() {
+    this.refreshTagList();
   }
   }
 
 
   setTag = (tag: string) => {
   setTag = (tag: string) => {
@@ -105,7 +118,12 @@ export default class TagList extends Component<PropsType, StateType> {
     return (
     return (
       <>
       <>
         <TagNameAlt>
         <TagNameAlt>
-          <img src={info} /> Select Image Tag
+          <Label>
+            <img src={info} /> Select Image Tag
+          </Label>
+          <Refresh onClick={this.refreshTagList}>
+            <i className="material-icons">autorenew</i> Refresh
+          </Refresh>
         </TagNameAlt>
         </TagNameAlt>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
       </>
       </>
@@ -115,6 +133,36 @@ export default class TagList extends Component<PropsType, StateType> {
 
 
 TagList.contextType = Context;
 TagList.contextType = Context;
 
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const Refresh = styled.div`
+  margin-right: 10px;
+  cursor: pointer;
+  color: #949eff;
+  display: flex;
+  align-items: center;
+  font-weight: 500;
+  border-radius: 3px;
+  padding: 2px 3px;
+  padding-right: 7px;
+  > i {
+    font-size: 17px;
+    margin-right: 6px;
+  }
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const StyledTagList = styled.div`
 const StyledTagList = styled.div`
   max-height: 175px;
   max-height: 175px;
   position: relative;
   position: relative;
@@ -152,10 +200,13 @@ const TagName = styled.div`
 `;
 `;
 
 
 const TagNameAlt = styled(TagName)`
 const TagNameAlt = styled(TagName)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #ffffff55;
   color: #ffffff55;
   cursor: default;
   cursor: default;
   :hover {
   :hover {
-    background: #ffffff11;
+    background: none;
     > i {
     > i {
       background: none;
       background: none;
     }
     }

+ 11 - 4
dashboard/src/components/values-form/InputRow.tsx

@@ -12,6 +12,7 @@ type PropsType = {
   width?: string;
   width?: string;
   disabled?: boolean;
   disabled?: boolean;
   isRequired?: boolean;
   isRequired?: boolean;
+  className?: string;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -34,7 +35,7 @@ export default class InputRow extends Component<PropsType, StateType> {
   render() {
   render() {
     let { label, value, type, unit, placeholder, width, info } = this.props;
     let { label, value, type, unit, placeholder, width, info } = this.props;
     return (
     return (
-      <StyledInputRow>
+      <StyledInputRow className={this.props.className}>
         {(label || info) && (
         {(label || info) && (
           <Label>
           <Label>
             {label}
             {label}
@@ -66,13 +67,21 @@ const Required = styled.div`
 `;
 `;
 
 
 const Unit = styled.div`
 const Unit = styled.div`
-  margin-left: 8px;
+  padding: 0 10px;
+  background: #ffffff05;
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-left: 1px solid #ffffff55;
 `;
 `;
 
 
 const InputWrapper = styled.div`
 const InputWrapper = styled.div`
   display: flex;
   display: flex;
   margin-bottom: -1px;
   margin-bottom: -1px;
   align-items: center;
   align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
 `;
 `;
 
 
 const Input = styled.input<{ disabled: boolean; width: string }>`
 const Input = styled.input<{ disabled: boolean; width: string }>`
@@ -80,9 +89,7 @@ const Input = styled.input<{ disabled: boolean; width: string }>`
   border: none;
   border: none;
   font-size: 13px;
   font-size: 13px;
   background: #ffffff11;
   background: #ffffff11;
-  border: 1px solid #ffffff55;
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
-  border-radius: 3px;
   width: ${(props) => (props.width ? props.width : "270px")};
   width: ${(props) => (props.width ? props.width : "270px")};
   color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   padding: 5px 10px;
   padding: 5px 10px;

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

@@ -35,7 +35,6 @@ export default class UploadArea extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { label, placeholder } = this.props;
     let { label, placeholder } = this.props;
-    console.log(this.state.fileName);
     if (this.state.fileName) {
     if (this.state.fileName) {
       placeholder = `Uploaded ${this.state.fileName}`;
       placeholder = `Uploaded ${this.state.fileName}`;
     }
     }

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

@@ -98,6 +98,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <KeyValueArray
             <KeyValueArray
               key={key}
               key={key}
+              width="100%"
               envLoader={true}
               envLoader={true}
               externalValues={this.props.externalValues}
               externalValues={this.props.externalValues}
               values={this.props.metaState[key]?.value}
               values={this.props.metaState[key]?.value}
@@ -121,6 +122,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <KeyValueArray
             <KeyValueArray
               key={key}
               key={key}
+              width="100%"
               externalValues={this.props.externalValues}
               externalValues={this.props.externalValues}
               values={this.props.metaState[key]?.value}
               values={this.props.metaState[key]?.value}
               setValues={(x: any) => this.props.setMetaState(key, x)}
               setValues={(x: any) => this.props.setMetaState(key, x)}
@@ -132,6 +134,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <InputArray
             <InputArray
               key={key}
               key={key}
+              width="100%"
               values={this.props.metaState[key]?.value}
               values={this.props.metaState[key]?.value}
               setValues={(x: string[]) => {
               setValues={(x: string[]) => {
                 this.props.setMetaState(key, x);
                 this.props.setMetaState(key, x);
@@ -145,6 +148,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <InputRow
             <InputRow
               key={key}
               key={key}
+              width="100%"
               placeholder={item.placeholder}
               placeholder={item.placeholder}
               isRequired={item.required}
               isRequired={item.required}
               type="text"
               type="text"
@@ -170,6 +174,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <InputRow
             <InputRow
               key={key}
               key={key}
+              width="100%"
               isRequired={item.required}
               isRequired={item.required}
               type="password"
               type="password"
               value={this.getInputValue(item)}
               value={this.getInputValue(item)}
@@ -193,6 +198,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <InputRow
             <InputRow
               key={key}
               key={key}
+              width="100%"
               isRequired={item.required}
               isRequired={item.required}
               placeholder={item.placeholder}
               placeholder={item.placeholder}
               type="number"
               type="number"
@@ -252,6 +258,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
           return (
             <Base64InputRow
             <Base64InputRow
               key={key}
               key={key}
+              width="100%"
               isRequired={item.required}
               isRequired={item.required}
               type="text"
               type="text"
               value={this.getInputValue(item)}
               value={this.getInputValue(item)}

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

@@ -180,7 +180,7 @@ export default class Main extends Component<PropsType, StateType> {
           }}
           }}
         />
         />
         <Route
         <Route
-          path={`/:baseRoute`}
+          path={`/:baseRoute/:cluster?/:namespace?`}
           render={(routeProps) => {
           render={(routeProps) => {
             const baseRoute = routeProps.match.params.baseRoute;
             const baseRoute = routeProps.match.params.baseRoute;
             if (
             if (
@@ -210,7 +210,7 @@ export default class Main extends Component<PropsType, StateType> {
     return (
     return (
       <StyledMain>
       <StyledMain>
         <GlobalStyle />
         <GlobalStyle />
-        <BrowserRouter>{this.renderMain()}</BrowserRouter>
+        {this.renderMain()}
         <CurrentError currentError={this.context.currentError} />
         <CurrentError currentError={this.context.currentError} />
       </StyledMain>
       </StyledMain>
     );
     );

+ 23 - 0
dashboard/src/main/MainWrapper.tsx

@@ -0,0 +1,23 @@
+import React, { Component } from "react";
+import { BrowserRouter } from "react-router-dom";
+
+import { ContextProvider } from "../shared/Context";
+import Main from "./Main";
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class MainWrapper extends Component<PropsType, StateType> {
+  render() {
+    let { history, location } = this.props;
+    return (
+      <ContextProvider history={history} location={location}>
+        <Main />
+      </ContextProvider>
+    );
+  }
+}
+
+export default withRouter(MainWrapper);

+ 62 - 71
dashboard/src/main/home/Home.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import api from "shared/api";
 import api from "shared/api";
 import { H } from "highlight.run";
 import { H } from "highlight.run";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import { PorterUrl } from "shared/routing";
+import { PorterUrl, pushQueryParams, pushFiltered } from "shared/routing";
 import { ClusterType, ProjectType } from "shared/types";
 import { ClusterType, ProjectType } from "shared/types";
 
 
 import ConfirmOverlay from "components/ConfirmOverlay";
 import ConfirmOverlay from "components/ConfirmOverlay";
@@ -19,10 +19,13 @@ import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModa
 import IntegrationsModal from "./modals/IntegrationsModal";
 import IntegrationsModal from "./modals/IntegrationsModal";
 import Modal from "./modals/Modal";
 import Modal from "./modals/Modal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
 import Navbar from "./navbar/Navbar";
 import Navbar from "./navbar/Navbar";
 import NewProject from "./new-project/NewProject";
 import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import Sidebar from "./sidebar/Sidebar";
+import PageNotFound from "components/PageNotFound";
+import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   logOut: () => void;
   logOut: () => void;
@@ -43,6 +46,7 @@ type StateType = {
 };
 };
 
 
 // TODO: Handle cluster connected but with some failed infras (no successful set)
 // TODO: Handle cluster connected but with some failed infras (no successful set)
+// TODO: Set up current view / sidebar tab as dynamic Routes
 class Home extends Component<PropsType, StateType> {
 class Home extends Component<PropsType, StateType> {
   state = {
   state = {
     forceSidebar: true,
     forceSidebar: true,
@@ -75,9 +79,11 @@ class Home extends Component<PropsType, StateType> {
           creating = res.data[i].status === "creating";
           creating = res.data[i].status === "creating";
         }
         }
         if (creating) {
         if (creating) {
-          this.props.history.push("dashboard?tab=provisioner");
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "provisioner",
+          });
         } else if (this.state.ghRedirect) {
         } else if (this.state.ghRedirect) {
-          this.props.history.push("integrations");
+          pushFiltered(this.props, "/integrations", ["project_id"]);
           this.setState({ ghRedirect: false });
           this.setState({ ghRedirect: false });
         }
         }
       });
       });
@@ -98,14 +104,21 @@ class Home extends Component<PropsType, StateType> {
   };
   };
 
 
   getProjects = (id?: number) => {
   getProjects = (id?: number) => {
-    let { user, setProjects } = this.context;
+    let { user, setProjects, setCurrentProject } = this.context;
     let { currentProject } = this.props;
     let { currentProject } = this.props;
+    let queryString = window.location.search;
+    let urlParams = new URLSearchParams(queryString);
+    let projectId = urlParams.get("project_id");
+    if (!projectId && currentProject?.id) {
+      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
+    }
+
     api
     api
       .getProjects("<token>", {}, { id: user.userId })
       .getProjects("<token>", {}, { id: user.userId })
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
           if (res.data.length === 0) {
           if (res.data.length === 0) {
-            this.props.history.push("new-project");
+            pushFiltered(this.props, "/new-project", ["project_id"]);
           } else if (res.data.length > 0 && !currentProject) {
           } else if (res.data.length > 0 && !currentProject) {
             setProjects(res.data);
             setProjects(res.data);
 
 
@@ -116,7 +129,7 @@ class Home extends Component<PropsType, StateType> {
                   foundProject = project;
                   foundProject = project;
                 }
                 }
               });
               });
-              this.context.setCurrentProject(foundProject);
+              setCurrentProject(foundProject || res.data[0]);
             }
             }
             if (!foundProject) {
             if (!foundProject) {
               res.data.forEach((project: ProjectType, i: number) => {
               res.data.forEach((project: ProjectType, i: number) => {
@@ -127,10 +140,9 @@ class Home extends Component<PropsType, StateType> {
                   foundProject = project;
                   foundProject = project;
                 }
                 }
               });
               });
-              this.context.setCurrentProject(
-                foundProject ? foundProject : res.data[0]
+              setCurrentProject(foundProject || res.data[0], () =>
+                this.initializeView()
               );
               );
-              this.initializeView();
             }
             }
           }
           }
         }
         }
@@ -175,7 +187,9 @@ class Home extends Component<PropsType, StateType> {
         project_id: this.props.currentProject.id,
         project_id: this.props.currentProject.id,
       }
       }
     );
     );
-    return this.props.history.push("dashboard?tab=provisioner");
+    return pushFiltered(this.props, "/dashboard", ["project_id"], {
+      tab: "provisioner",
+    });
   };
   };
 
 
   checkDO = () => {
   checkDO = () => {
@@ -205,7 +219,9 @@ class Home extends Component<PropsType, StateType> {
             });
             });
           } else if (infras[0] === "docr") {
           } else if (infras[0] === "docr") {
             this.provisionDOCR(tgtIntegration.id, tier, () => {
             this.provisionDOCR(tgtIntegration.id, tier, () => {
-              this.props.history.push("dashboard?tab=provisioner");
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "provisioner",
+              });
             });
             });
           } else {
           } else {
             this.provisionDOKS(tgtIntegration.id, region, clusterName);
             this.provisionDOKS(tgtIntegration.id, region, clusterName);
@@ -217,6 +233,11 @@ class Home extends Component<PropsType, StateType> {
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
+    let { match } = this.props;
+    let params = match.params as any;
+    let { cluster } = params;
+    console.log("cluster is", cluster);
+
     let { user } = this.context;
     let { user } = this.context;
 
 
     // Initialize Highlight
     // Initialize Highlight
@@ -238,9 +259,8 @@ class Home extends Component<PropsType, StateType> {
     }
     }
 
 
     let provision = urlParams.get("provision");
     let provision = urlParams.get("provision");
-    let defaultProjectId = null;
+    let defaultProjectId = parseInt(urlParams.get("project_id"));
     if (provision === "do") {
     if (provision === "do") {
-      defaultProjectId = parseInt(urlParams.get("project_id"));
       this.setState({ handleDO: true });
       this.setState({ handleDO: true });
       this.checkDO();
       this.checkDO();
     }
     }
@@ -271,43 +291,16 @@ class Home extends Component<PropsType, StateType> {
 
 
   // TODO: move into ClusterDashboard
   // TODO: move into ClusterDashboard
   renderDashboard = () => {
   renderDashboard = () => {
-    let { currentCluster, setCurrentModal } = this.context;
-    if (currentCluster && !currentCluster.name) {
+    let { currentCluster } = this.context;
+    if (currentCluster?.id === -1) {
+      return <Loading />;
+    } else if (!currentCluster || !currentCluster.name) {
       return (
       return (
         <DashboardWrapper>
         <DashboardWrapper>
-          <Placeholder>
-            <Bold>Porter - Getting</Bold>
-            <br />
-            <br />
-            1. Navigate to{" "}
-            <A onClick={() => setCurrentModal("ClusterConfigModal")}>
-              + Add a Cluster
-            </A>{" "}
-            and provide a kubeconfig. *<br />
-            <br />
-            2. Choose which contexts you would like to use from the{" "}
-            <A
-              onClick={() => {
-                setCurrentModal("ClusterConfigModal", { currentTab: "select" });
-              }}
-            >
-              Select Clusters
-            </A>{" "}
-            tab.
-            <br />
-            <br />
-            3. For additional information, please refer to our <A>docs</A>.
-            <br />
-            <br />
-            <br />* Make sure all fields are explicitly declared (e.g., certs
-            and keys).
-          </Placeholder>
+          <PageNotFound />
         </DashboardWrapper>
         </DashboardWrapper>
       );
       );
-    } else if (!currentCluster) {
-      return <Loading />;
     }
     }
-
     return (
     return (
       <DashboardWrapper>
       <DashboardWrapper>
         <ClusterDashboard
         <ClusterDashboard
@@ -371,17 +364,18 @@ class Home extends Component<PropsType, StateType> {
   };
   };
 
 
   projectOverlayCall = () => {
   projectOverlayCall = () => {
-    let { user, setProjects } = this.context;
+    let { user, setProjects, setCurrentProject } = this.context;
     api
     api
       .getProjects("<token>", {}, { id: user.userId })
       .getProjects("<token>", {}, { id: user.userId })
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
           setProjects(res.data);
           setProjects(res.data);
           if (res.data.length > 0) {
           if (res.data.length > 0) {
-            this.context.setCurrentProject(res.data[0]);
+            setCurrentProject(res.data[0]);
           } else {
           } else {
-            this.context.setCurrentProject(null);
-            this.props.history.push("new-project");
+            setCurrentProject(null, () =>
+              pushFiltered(this.props, "/new-project", ["project_id"])
+            );
           }
           }
           this.context.setCurrentModal(null, null);
           this.context.setCurrentModal(null, null);
         }
         }
@@ -460,7 +454,7 @@ class Home extends Component<PropsType, StateType> {
       })
       })
       .catch(console.log);
       .catch(console.log);
     setCurrentModal(null, null);
     setCurrentModal(null, null);
-    this.props.history.push("dashboard?tab=overview");
+    pushFiltered(this.props, "/dashboard", []);
   };
   };
 
 
   render() {
   render() {
@@ -508,6 +502,24 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
             <IntegrationsInstructionsModal />
           </Modal>
           </Modal>
         )}
         )}
+        {currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
+        {currentModal === "DeleteNamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+          >
+            <DeleteNamespaceModal />
+          </Modal>
+        )}
 
 
         {this.renderSidebar()}
         {this.renderSidebar()}
 
 
@@ -557,27 +569,6 @@ const DashboardWrapper = styled.div`
   padding-bottom: 120px;
   padding-bottom: 120px;
 `;
 `;
 
 
-const A = styled.a`
-  color: #ffffff;
-  text-decoration: underline;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-`;
-
-const Placeholder = styled.div`
-  font-family: "Work Sans", sans-serif;
-  color: #6f6f6f;
-  font-size: 16px;
-  margin-left: 20px;
-  margin-top: 24vh;
-  user-select: none;
-`;
-
-const Bold = styled.div`
-  font-weight: bold;
-  font-size: 20px;
-`;
-
 const StyledHome = styled.div`
 const StyledHome = styled.div`
   width: 100vw;
   width: 100vw;
   height: 100vh;
   height: 100vh;

+ 98 - 0
dashboard/src/main/home/NoClusterPlaceholder.tsx

@@ -0,0 +1,98 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { pushFiltered } from "shared/routing";
+
+import { Context } from "shared/Context";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class NoClusterPlaceholder extends Component<PropsType, StateType> {
+  state = {};
+
+  render() {
+    let { setCurrentModal, currentProject } = this.context;
+
+    return (
+      <StyledNoClusterPlaceholder>
+        <Bold>
+          <i className="material-icons">tips_and_updates</i>
+          Porter - Getting Started
+        </Bold>
+        <br />
+        <br />
+        1. If you're deploying from a repo{" "}
+        <A
+          onClick={() =>
+            window.open(`/api/oauth/projects/${currentProject.id}/github`)
+          }
+        >
+          link your GitHub account
+        </A>
+        <br />
+        <br />
+        2.{" "}
+        <A
+          onClick={() =>
+            pushFiltered(this.props, "/dashboard", ["project_id"], {
+              tab: "create-cluster",
+            })
+          }
+        >
+          Create a new cluster
+        </A>{" "}
+        or{" "}
+        <A onClick={() => setCurrentModal("ClusterInstructionsModal")}>
+          add an existing cluster
+        </A>{" "}
+        *
+        <br />
+        <br />
+        3. To receive community updates{" "}
+        <A onClick={() => window.open("https://discord.gg/34n7NN7FJ7")}>
+          join our official Discord
+        </A>
+        <br />
+        <br />
+        <br />* Required. For more information{" "}
+        <A onClick={() => window.open("https://docs.getporter.dev/docs")}>
+          refer to our docs
+        </A>
+      </StyledNoClusterPlaceholder>
+    );
+  }
+}
+
+NoClusterPlaceholder.contextType = Context;
+
+export default withRouter(NoClusterPlaceholder);
+
+const A = styled.a`
+  color: #ffffff;
+  text-decoration: underline;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+`;
+
+const StyledNoClusterPlaceholder = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  margin-top: 12px;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: bold;
+  font-size: 20px;
+  display: flex;
+  align-items: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 78 - 48
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -2,20 +2,27 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import monojob from "assets/monojob.png";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import monoweb from "assets/monoweb.png";
+import { Switch, Route } from "react-router-dom";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
 import { ChartType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
+import {
+  getQueryParam,
+  PorterUrl,
+  pushFiltered,
+  pushQueryParams,
+} from "shared/routing";
 
 
 import ChartList from "./chart/ChartList";
 import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
-import ExpandedJobChart from "./expanded-chart/ExpandedJobChart";
+import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
 import api from "shared/api";
 import api from "shared/api";
+import { Dashboard } from "./dashboard/Dashboard";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -30,9 +37,10 @@ type StateType = {
   isMetricsInstalled: boolean;
   isMetricsInstalled: boolean;
 };
 };
 
 
+// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
 class ClusterDashboard extends Component<PropsType, StateType> {
 class ClusterDashboard extends Component<PropsType, StateType> {
   state = {
   state = {
-    namespace: "default",
+    namespace: null as string,
     sortType: localStorage.getItem("SortType")
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
       : "Newest",
@@ -41,14 +49,21 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathClusterName = params.cluster;
+    // Don't add cluster as query param if present in path
+    if (!pathClusterName) {
+      pushQueryParams(this.props, { cluster: currentCluster.name });
+    }
     api
     api
       .getPrometheusIsInstalled(
       .getPrometheusIsInstalled(
         "<token>",
         "<token>",
         {
         {
-          cluster_id: this.context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         },
         {
         {
-          id: this.context.currentProject.id,
+          id: currentProject.id,
         }
         }
       )
       )
       .then((res) => {
       .then((res) => {
@@ -62,21 +77,36 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
     // Reset namespace filter and close expanded chart on cluster change
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
     if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState({
-        namespace: "default",
-        sortType: localStorage.getItem("SortType")
-          ? localStorage.getItem("SortType")
-          : "Newest",
-        currentChart: null,
-      });
+      this.setState(
+        {
+          namespace: "default",
+          sortType: localStorage.getItem("SortType")
+            ? localStorage.getItem("SortType")
+            : "Newest",
+          currentChart: null,
+        },
+        () => pushQueryParams(this.props, { namespace: "default" })
+      );
     }
     }
 
 
     if (prevProps.currentView !== this.props.currentView) {
     if (prevProps.currentView !== this.props.currentView) {
-      this.setState({
-        namespace: "default",
-        sortType: "Newest",
-        currentChart: null,
-      });
+      let params = this.props.match.params as any;
+      let currentNamespace = params.namespace;
+      if (!currentNamespace) {
+        currentNamespace = getQueryParam(this.props, "namespace");
+      }
+      this.setState(
+        {
+          sortType: "Newest",
+          currentChart: null,
+          namespace: currentNamespace || "default",
+        },
+        () =>
+          pushQueryParams(this.props, {
+            namespace:
+              this.state.namespace === null ? "default" : this.state.namespace,
+          })
+      );
     }
     }
   }
   }
 
 
@@ -97,11 +127,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   };
   };
 
 
   renderBody = () => {
   renderBody = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
+    let { currentCluster, currentView } = this.props;
     return (
     return (
       <>
       <>
         <ControlRow>
         <ControlRow>
-          <Button onClick={() => this.props.history.push("launch")}>
+          <Button
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
+          >
             <i className="material-icons">add</i> Launch Template
             <i className="material-icons">add</i> Launch Template
           </Button>
           </Button>
           <SortFilterWrapper>
           <SortFilterWrapper>
@@ -110,7 +142,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               sortType={this.state.sortType}
               sortType={this.state.sortType}
             />
             />
             <NamespaceSelector
             <NamespaceSelector
-              setNamespace={(namespace) => this.setState({ namespace })}
+              setNamespace={(namespace) =>
+                this.setState({ namespace }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
               namespace={this.state.namespace}
               namespace={this.state.namespace}
             />
             />
           </SortFilterWrapper>
           </SortFilterWrapper>
@@ -121,9 +159,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           currentCluster={currentCluster}
           currentCluster={currentCluster}
           namespace={this.state.namespace}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
           sortType={this.state.sortType}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
         />
         />
       </>
       </>
     );
     );
@@ -131,33 +166,12 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 
   renderContents = () => {
   renderContents = () => {
     let { currentCluster, setSidebar, currentView } = this.props;
     let { currentCluster, setSidebar, currentView } = this.props;
-    if (this.state.currentChart && currentView === "jobs") {
-      return (
-        <ExpandedJobChart
-          namespace={this.state.namespace}
-          currentCluster={this.props.currentCluster}
-          currentChart={this.state.currentChart}
-          closeChart={() => this.setState({ currentChart: null })}
-          setSidebar={setSidebar}
-        />
-      );
-    } else if (this.state.currentChart) {
-      return (
-        <ExpandedChart
-          namespace={this.state.namespace}
-          currentCluster={this.props.currentCluster}
-          currentChart={this.state.currentChart}
-          closeChart={() => this.setState({ currentChart: null })}
-          isMetricsInstalled={this.state.isMetricsInstalled}
-          setSidebar={setSidebar}
-        />
-      );
-    } else if (currentView === "env-groups") {
+    if (currentView === "env-groups") {
       return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
       return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
     }
     }
 
 
     return (
     return (
-      <div>
+      <>
         <TitleSection>
         <TitleSection>
           {this.renderDashboardIcon()}
           {this.renderDashboardIcon()}
           <Title>{currentView}</Title>
           <Title>{currentView}</Title>
@@ -175,12 +189,28 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
         <LineBreak />
 
 
         {this.renderBody()}
         {this.renderBody()}
-      </div>
+      </>
     );
     );
   };
   };
 
 
   render() {
   render() {
-    return <div>{this.renderContents()}</div>;
+    let { setSidebar } = this.props;
+    return (
+      <Switch>
+        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
+          <ExpandedChartWrapper
+            setSidebar={setSidebar}
+            isMetricsInstalled={this.state.isMetricsInstalled}
+          />
+        </Route>
+        <Route path={["/jobs", "/applications", "/env-groups"]}>
+          {this.renderContents()}
+        </Route>
+        <Route path={["/cluster-dashboard"]}>
+          <Dashboard />
+        </Route>
+      </Switch>
+    );
   }
   }
 }
 }
 
 

+ 27 - 3
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -39,15 +39,39 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           let namespaceOptions: { label: string; value: string }[] = [
           let namespaceOptions: { label: string; value: string }[] = [
             { label: "All", value: "" },
             { label: "All", value: "" },
           ];
           ];
-          res.data.items.forEach(
+
+          // Set namespace from URL if specified
+          let queryString = window.location.search;
+          let urlParams = new URLSearchParams(queryString);
+          let urlNamespace = urlParams.get("namespace");
+          if (urlNamespace === "ALL") {
+            urlNamespace = "";
+          }
+
+          let defaultNamespace = "default";
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          availableNamespaces.forEach(
             (x: { metadata: { name: string } }, i: number) => {
             (x: { metadata: { name: string } }, i: number) => {
               namespaceOptions.push({
               namespaceOptions.push({
                 label: x.metadata.name,
                 label: x.metadata.name,
                 value: x.metadata.name,
                 value: x.metadata.name,
               });
               });
+              if (x.metadata.name === urlNamespace) {
+                defaultNamespace = urlNamespace;
+              }
             }
             }
           );
           );
-          this.setState({ namespaceOptions });
+          this.setState({ namespaceOptions }, () => {
+            if (urlNamespace === "" || defaultNamespace === "") {
+              this.props.setNamespace("");
+            } else if (this.props.namespace !== defaultNamespace) {
+              this.props.setNamespace(defaultNamespace);
+            }
+          });
         }
         }
       })
       })
       .catch((err) => {
       .catch((err) => {
@@ -63,7 +87,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
+    if (prevProps.namespace !== this.props.namespace) {
       this.updateOptions();
       this.updateOptions();
     }
     }
   }
   }

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

@@ -1,13 +1,14 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
-import { ChartType, StorageType } from "shared/types";
+import { ChartType } from "shared/types";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import StatusIndicator from "components/StatusIndicator";
+import { pushFiltered, pushQueryParams } from "shared/routing";
+import { RouteComponentProps, withRouter } from "react-router";
 
 
-type PropsType = {
+type PropsType = RouteComponentProps & {
   chart: ChartType;
   chart: ChartType;
-  setCurrentChart: (c: ChartType) => void;
   controllers: Record<string, any>;
   controllers: Record<string, any>;
 };
 };
 
 
@@ -16,7 +17,7 @@ type StateType = {
   update: any[];
   update: any[];
 };
 };
 
 
-export default class Chart extends Component<PropsType, StateType> {
+class Chart extends Component<PropsType, StateType> {
   state = {
   state = {
     expand: false,
     expand: false,
     update: [] as any[],
     update: [] as any[],
@@ -43,14 +44,20 @@ export default class Chart extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   render() {
-    let { chart, setCurrentChart } = this.props;
+    let { chart } = this.props;
 
 
     return (
     return (
       <StyledChart
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
         onMouseEnter={() => this.setState({ expand: true })}
         onMouseLeave={() => this.setState({ expand: false })}
         onMouseLeave={() => this.setState({ expand: false })}
         expand={this.state.expand}
         expand={this.state.expand}
-        onClick={() => setCurrentChart(chart)}
+        onClick={() => {
+          let { location, match } = this.props;
+          let urlParams = new URLSearchParams(location.search);
+          let cluster = urlParams.get("cluster");
+          let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
+          pushFiltered(this.props, route, ["project_id"]);
+        }}
       >
       >
         <Title>
         <Title>
           <IconWrapper>{this.renderIcon()}</IconWrapper>
           <IconWrapper>{this.renderIcon()}</IconWrapper>
@@ -84,6 +91,8 @@ export default class Chart extends Component<PropsType, StateType> {
 
 
 Chart.contextType = Context;
 Chart.contextType = Context;
 
 
+export default withRouter(Chart);
+
 const BottomWrapper = styled.div`
 const BottomWrapper = styled.div`
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;

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

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -12,40 +12,37 @@ import Loading from "components/Loading";
 type PropsType = {
 type PropsType = {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
   namespace: string;
   namespace: string;
+  // TODO Convert to enum
   sortType: string;
   sortType: string;
-  setCurrentChart: (c: ChartType) => void;
   currentView: PorterUrl;
   currentView: PorterUrl;
 };
 };
 
 
-type StateType = {
-  charts: ChartType[];
-  chartLookupTable: Record<string, string>;
-  controllers: Record<string, Record<string, any>>;
-  loading: boolean;
-  error: boolean;
-  websockets: Record<string, any>;
-};
-
-export default class ChartList extends Component<PropsType, StateType> {
-  state = {
-    charts: [] as ChartType[],
-    chartLookupTable: {} as Record<string, string>,
-    controllers: {} as Record<string, Record<string, any>>,
-    loading: false,
-    error: false,
-    websockets: {} as Record<string, any>,
-  };
-
-  // TODO: promisify
-  updateCharts = (callback: Function) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    this.setState({ loading: true });
-
-    api
-      .getCharts(
+const ChartList: React.FunctionComponent<PropsType> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [chartLookupTable, setChartLookupTable] = useState<
+    Record<string, string>
+  >({});
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [websockets, setWebsockets] = useState<WebSocket[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
         "<token>",
         "<token>",
         {
         {
-          namespace: this.props.namespace,
+          namespace: namespace,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
           limit: 50,
           limit: 50,
@@ -63,51 +60,50 @@ export default class ChartList extends Component<PropsType, StateType> {
           ],
           ],
         },
         },
         { id: currentProject.id }
         { id: currentProject.id }
-      )
-      .then((res) => {
-        let charts = res.data || [];
-
-        // filter charts based on the current view
-        let { currentView } = this.props;
-
-        charts = charts.filter((chart: ChartType) => {
-          return (
-            (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-            ((currentView == "applications" ||
-              currentView == "cluster-dashboard") &&
-              chart.chart.metadata.name != "job")
-          );
-        });
-
-        if (this.props.sortType == "Newest") {
-          charts.sort((a: any, b: any) =>
-            Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-              ? -1
-              : 1
-          );
-        } else if (this.props.sortType == "Oldest") {
-          charts.sort((a: any, b: any) =>
-            Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-              ? 1
-              : -1
-          );
-        } else if (this.props.sortType == "Alphabetical") {
-          charts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-        }
-        this.setState({ charts }, () => {
-          this.setState({ loading: false, error: false });
-        });
-        callback(charts);
-      })
-      .catch((err) => {
-        console.log(err);
-        setCurrentError(JSON.stringify(err));
-        this.setState({ loading: false, error: true });
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
       });
       });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    } finally {
+      setIsLoading(false);
+    }
   };
   };
 
 
-  setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = this.context;
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
     let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let protocol = window.location.protocol == "https:" ? "wss" : "ws";
 
 
     let ws = new WebSocket(
     let ws = new WebSocket(
@@ -121,22 +117,20 @@ export default class ChartList extends Component<PropsType, StateType> {
       let event = JSON.parse(evt.data);
       let event = JSON.parse(evt.data);
       let object = event.Object;
       let object = event.Object;
       object.metadata.kind = event.Kind;
       object.metadata.kind = event.Kind;
-      let chartKey = this.state.chartLookupTable[object.metadata.uid];
+      let chartKey = chartLookupTable[object.metadata.uid];
 
 
       // ignore if updated object does not belong to any chart in the list.
       // ignore if updated object does not belong to any chart in the list.
       if (!chartKey) {
       if (!chartKey) {
         return;
         return;
       }
       }
 
 
-      let chartControllers = this.state.controllers[chartKey];
+      let chartControllers = controllers[chartKey];
       chartControllers[object.metadata.uid] = object;
       chartControllers[object.metadata.uid] = object;
 
 
-      this.setState({
-        controllers: {
-          ...this.state.controllers,
-          [chartKey]: chartControllers,
-        },
-      });
+      setControllers((oldControllers) => ({
+        ...oldControllers,
+        [chartKey]: chartControllers,
+      }));
     };
     };
 
 
     ws.onclose = () => {
     ws.onclose = () => {
@@ -151,112 +145,103 @@ export default class ChartList extends Component<PropsType, StateType> {
     return ws;
     return ws;
   };
   };
 
 
-  setControllerWebsockets = (controllers: any[]) => {
+  const setControllerWebsockets = (controllers: any[]) => {
     let websockets = controllers.map((kind: string) => {
     let websockets = controllers.map((kind: string) => {
-      return this.setupWebsocket(kind);
+      return setupWebsocket(kind);
     });
     });
-    this.setState({ websockets });
+    setWebsockets(websockets);
   };
   };
 
 
-  getControllers = (charts: any[]) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+  const getControllerForChart = async (chart: ChartType) => {
+    try {
+      const { currentCluster, currentProject } = context;
+      const res = await api.getChartControllers(
+        "<token>",
+        {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version,
+        }
+      );
+
+      let chartControllers = {} as Record<string, Record<string, any>>;
+
+      res.data.forEach((c: any) => {
+        c.metadata.kind = c.kind;
+        chartControllers[c.metadata.uid] = c;
+      });
+
+      res.data.forEach(async (c: any) => {
+        setChartLookupTable((oldChartLookupTable) => ({
+          ...oldChartLookupTable,
+          [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
+        }));
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [`${chart.namespace}-${chart.name}`]: chartControllers,
+        }));
+      });
+    } catch (error) {
+      context.setCurrentError(JSON.stringify(error));
+    }
+  };
 
 
+  const getControllers = (charts: any[]) => {
     charts.forEach(async (chart: any) => {
     charts.forEach(async (chart: any) => {
       // don't retrieve controllers for chart that failed to even deploy.
       // don't retrieve controllers for chart that failed to even deploy.
       if (chart.info.status == "failed") return;
       if (chart.info.status == "failed") return;
-
-      await new Promise((next: (res?: any) => void) => {
-        api
-          .getChartControllers(
-            "<token>",
-            {
-              namespace: chart.namespace,
-              cluster_id: currentCluster.id,
-              storage: StorageType.Secret,
-            },
-            {
-              id: currentProject.id,
-              name: chart.name,
-              revision: chart.version,
-            }
-          )
-          .then((res) => {
-            // transform controller array into hash table for easy lookup during updates.
-            let chartControllers = {} as Record<string, Record<string, any>>;
-            res.data.forEach((c: any) => {
-              c.metadata.kind = c.kind;
-              chartControllers[c.metadata.uid] = c;
-            });
-
-            res.data.forEach(async (c: any) => {
-              await new Promise((nextController: (res?: any) => void) => {
-                this.setState(
-                  {
-                    chartLookupTable: {
-                      ...this.state.chartLookupTable,
-                      [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
-                    },
-                    controllers: {
-                      ...this.state.controllers,
-                      [`${chart.namespace}-${chart.name}`]: chartControllers,
-                    },
-                  },
-                  () => {
-                    nextController();
-                  }
-                );
-              });
-            });
-            next();
-          })
-          .catch((err) => {
-            setCurrentError(JSON.stringify(err));
-            return;
-          });
-      });
+      await getControllerForChart(chart);
     });
     });
   };
   };
 
 
-  componentDidMount() {
-    this.updateCharts(this.getControllers);
-    this.setControllerWebsockets([
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
       "deployment",
       "deployment",
       "statefulset",
       "statefulset",
       "daemonset",
       "daemonset",
       "replicaset",
       "replicaset",
     ]);
     ]);
-  }
+  }, []);
+
+  // Close Websockets on unmount
+  useEffect(() => {
+    return () => {
+      if (websockets.length) {
+        websockets.forEach((ws) => {
+          ws.close();
+        });
+      }
+    };
+  }, [websockets]);
 
 
-  componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close();
-      });
-    }
-  }
+  useEffect(() => {
+    let isSubscribed = true;
 
 
-  componentDidUpdate(prevProps: PropsType) {
-    // Ret2: Prevents reload when opening ClusterConfigModal
-    if (
-      prevProps.currentCluster !== this.props.currentCluster ||
-      prevProps.namespace !== this.props.namespace ||
-      prevProps.sortType !== this.props.sortType ||
-      prevProps.currentView !== this.props.currentView
-    ) {
-      this.updateCharts(this.getControllers);
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          getControllers(charts);
+        }
+      });
     }
     }
-  }
-
-  renderChartList = () => {
-    let { loading, error, charts } = this.state;
+    return () => (isSubscribed = false);
+  }, [namespace]);
 
 
-    if (loading) {
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
       return (
       return (
         <LoadingWrapper>
         <LoadingWrapper>
           <Loading />
           <Loading />
         </LoadingWrapper>
         </LoadingWrapper>
       );
       );
-    } else if (error) {
+    } else if (isError) {
       return (
       return (
         <Placeholder>
         <Placeholder>
           <i className="material-icons">error</i> Error connecting to cluster.
           <i className="material-icons">error</i> Error connecting to cluster.
@@ -266,20 +251,19 @@ export default class ChartList extends Component<PropsType, StateType> {
       return (
       return (
         <Placeholder>
         <Placeholder>
           <i className="material-icons">category</i> No
           <i className="material-icons">category</i> No
-          {this.props.currentView === "jobs" ? ` jobs` : ` charts`} found in
-          this namespace.
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
         </Placeholder>
         </Placeholder>
       );
       );
     }
     }
 
 
-    return this.state.charts.map((chart: ChartType, i: number) => {
+    return charts.map((chart: ChartType, i: number) => {
       return (
       return (
         <Chart
         <Chart
           key={`${chart.namespace}-${chart.name}`}
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
           chart={chart}
-          setCurrentChart={this.props.setCurrentChart}
           controllers={
           controllers={
-            this.state.controllers[`${chart.namespace}-${chart.name}`] ||
+            controllers[`${chart.namespace}-${chart.name}`] ||
             ({} as Record<string, any>)
             ({} as Record<string, any>)
           }
           }
         />
         />
@@ -287,12 +271,10 @@ export default class ChartList extends Component<PropsType, StateType> {
     });
     });
   };
   };
 
 
-  render() {
-    return <StyledChartList>{this.renderChartList()}</StyledChartList>;
-  }
-}
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
 
 
-ChartList.contextType = Context;
+export default ChartList;
 
 
 const Placeholder = styled.div`
 const Placeholder = styled.div`
   width: 100%;
   width: 100%;

+ 182 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -0,0 +1,182 @@
+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 { Context } from "shared/Context";
+import api from "shared/api";
+
+const ClusterSettings: React.FC = () => {
+  const context = useContext(Context);
+  const [accessKeyId, setAccessKeyId] = useState<string>("");
+  const [secretKey, setSecretKey] = useState<string>("");
+  const [startRotateCreds, setStartRotateCreds] = useState<boolean>(false);
+  const [successfulRotate, setSuccessfulRotate] = useState<boolean>(false);
+
+  let rotateCredentials = () => {
+    api
+      .overwriteAWSIntegration(
+        "<token>",
+        {
+          aws_access_key_id: accessKeyId,
+          aws_secret_access_key: secretKey,
+        },
+        {
+          projectID: context.currentProject.id,
+          awsIntegrationID: context.currentCluster.aws_integration_id,
+          cluster_id: context.currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setSuccessfulRotate(true);
+      })
+      .catch(() => {
+        setSuccessfulRotate(false);
+      });
+  };
+
+  let helperText = (
+    <Helper>
+      Delete this cluster and underlying infrastructure. To ensure that
+      everything has been properly destroyed, please visit your cloud provider's
+      console. Instructions to properly delete all resources can be found
+      <a
+        target="none"
+        href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+      >
+        {" "}
+        here
+      </a>
+      .
+    </Helper>
+  );
+
+  if (!context.currentCluster?.infra_id || !context.currentCluster?.service) {
+    helperText = (
+      <Helper>
+        Remove this cluster from Porter. Since this cluster was not provisioned
+        by Porter, deleting the cluster will only detach this cluster from your
+        project. To delete the cluster itself, you must do so manually. This
+        operation cannot be undone.
+      </Helper>
+    );
+  }
+
+  let keyRotationSection = null;
+
+  if (
+    context.currentCluster?.aws_integration_id &&
+    context.currentCluster?.aws_integration_id != 0
+  ) {
+    if (successfulRotate) {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>Successfully rotated credentials!</Helper>
+        </div>
+      );
+    } else if (startRotateCreds) {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>Input the new credentials for the EKS cluster.</Helper>
+          <InputRow
+            type="text"
+            value={accessKeyId}
+            setValue={(x: string) => setAccessKeyId(x)}
+            label="👤 AWS Access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            width="100%"
+            isRequired={true}
+          />
+          <InputRow
+            type="password"
+            value={secretKey}
+            setValue={(x: string) => setSecretKey(x)}
+            label="🔒 AWS Secret Key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            width="100%"
+            isRequired={true}
+          />
+          <Button color="#616FEEcc" onClick={rotateCredentials}>
+            Submit
+          </Button>
+        </div>
+      );
+    } else {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>
+            Rotate the credentials that Porter uses to connect to the cluster.
+          </Helper>
+          <Button color="#616FEEcc" onClick={() => setStartRotateCreds(true)}>
+            Rotate Credentials
+          </Button>
+        </div>
+      );
+    }
+  }
+
+  return (
+    <div>
+      <StyledSettingsSection showSource={false}>
+        {keyRotationSection}
+        <Heading>Delete Cluster</Heading>
+        {helperText}
+        <Button
+          color="#b91133"
+          onClick={() => context.setCurrentModal("UpdateClusterModal")}
+        >
+          Delete Cluster
+        </Button>
+      </StyledSettingsSection>
+    </div>
+  );
+};
+
+export default ClusterSettings;
+
+const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+  margin-top: 35px;
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 50px;
+  position: relative;
+  border-radius: 5px;
+  overflow: auto;
+  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+`;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 6px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-bottom: 20px;
+`;

+ 149 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -0,0 +1,149 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import TabSelector from "components/TabSelector";
+
+import NodeList from "./NodeList";
+
+import { NamespaceList } from "./NamespaceList";
+import ClusterSettings from "./ClusterSettings";
+
+type TabEnum = "nodes" | "settings" | "namespaces";
+
+const tabOptions: {
+  label: string;
+  value: TabEnum;
+}[] = [
+  { label: "Nodes", value: "nodes" },
+  { label: "Namespaces", value: "namespaces" },
+  { label: "Settings", value: "settings" },
+];
+
+export const Dashboard: React.FunctionComponent = () => {
+  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const context = useContext(Context);
+  const renderTab = () => {
+    switch (currentTab) {
+      case "settings":
+        return <ClusterSettings />;
+      case "namespaces":
+        return <NamespaceList />;
+      case "nodes":
+      default:
+        return <NodeList />;
+    }
+  };
+
+  return (
+    <>
+      <TitleSection>
+        <DashboardIcon>
+          <i className="material-icons">device_hub</i>
+        </DashboardIcon>
+        <Title>{context.currentCluster.name}</Title>
+      </TitleSection>
+
+      <InfoSection>
+        <TopRow>
+          <InfoLabel>
+            <i className="material-icons">info</i> Info
+          </InfoLabel>
+        </TopRow>
+        <Description>
+          Cluster dashboard for {context.currentCluster.name}
+        </Description>
+      </InfoSection>
+
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+      />
+
+      {renderTab()}
+    </>
+  );
+};
+
+const DashboardIcon = styled.div`
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: "Work Sans", sans-serif;
+  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;
+  }
+`;

+ 387 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -0,0 +1,387 @@
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { ClusterType, ProjectType } from "shared/types";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
+
+const OptionsDropdown: React.FC = ({ children }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const useWebsocket = (
+  currentProject: ProjectType,
+  currentCluster: ClusterType
+) => {
+  const wsRef = useRef<WebSocket | undefined>(undefined);
+
+  useEffect(() => {
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    wsRef.current = new WebSocket(
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/namespace/status?cluster_id=${currentCluster.id}`
+    );
+
+    wsRef.current.onopen = () => {
+      console.log("Connected to websocket");
+    };
+
+    wsRef.current.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    return () => {
+      wsRef.current.close();
+    };
+  }, []);
+
+  return wsRef;
+};
+
+export const NamespaceList: React.FunctionComponent = () => {
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+  } = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+  const [namespaces, setNamespaces] = useState([]);
+  const websocket = useWebsocket(currentProject, currentCluster);
+  const onDelete = (namespace: any) => {
+    setCurrentModal("DeleteNamespaceModal", namespace);
+  };
+
+  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)
+    return !/(^default$)|(^kube-.*)/.test(namespaceName);
+  };
+
+  useEffect(() => {
+    if (!websocket) {
+      return;
+    }
+
+    websocket.current.onerror = (err: ErrorEvent) => {
+      setCurrentError(err.message);
+      websocket.current.close();
+    };
+
+    websocket.current.onmessage = (evt: MessageEvent) => {
+      const data = JSON.parse(evt.data);
+      if (data.Kind !== "namespace") {
+        return;
+      }
+      if (data.event_type === "ADD") {
+        setNamespaces((oldNamespaces) => [...oldNamespaces, data.Object]);
+      }
+
+      if (data.event_type === "DELETE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1);
+          return [...oldNamespaces];
+        });
+      }
+
+      if (data.event_type === "UPDATE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
+          return oldNamespaces;
+        });
+      }
+    };
+  }, [websocket]);
+
+  const sortAlphabetically = (prev: any, current: any) => {
+    return prev.metadata.name > current.metadata.name ? 1 : -1;
+  };
+
+  const sortedNamespaces = useMemo<any[]>(() => {
+    const nonDeletableNamespaces = namespaces
+      .filter((namespace) => !isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+    const deletableNamespaces = namespaces
+      .filter((namespace) => isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+
+    return [...deletableNamespaces, ...nonDeletableNamespaces];
+  }, [namespaces]);
+
+  return (
+    <NamespaceListWrapper>
+      <ControlRow>
+        <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) => {
+          return (
+            <StyledCard
+              key={namespace?.metadata?.name}
+              onClick={() =>
+                pushFiltered({ location, history }, `/applications`, [], {
+                  cluster: currentCluster.name,
+                  namespace: namespace.metadata.name,
+                })
+              }
+            >
+              <ContentContainer>
+                <Title>{namespace?.metadata?.name}</Title>
+                <Status margin_left={"0px"}>
+                  <StatusColor status={namespace.status.phase} />
+                  {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>
+              )}
+            </StyledCard>
+          );
+        })}
+      </NamespacesGrid>
+    </NamespaceListWrapper>
+  );
+};
+
+const NamespaceListWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const NamespacesGrid = styled.div`
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(2, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "Active"
+      ? "#4797ff"
+      : props.status === "Terminating"
+      ? "#ed5f85"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  margin-left: ${(props: { margin_left: string }) => props.margin_left};
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+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;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  min-height: 80px;
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border: 1px solid #26282f;
+  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 5px;
+  padding: 14px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+
+  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+  :hover {
+    transform: scale(1.05);
+    box-shadow: 0 8px 20px 0px #00000030;
+    cursor: pointer;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  height: 100%;
+`;
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 5px;
+    font-size: 16px;
+  }
+`;

+ 175 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -0,0 +1,175 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+
+import Table from "components/Table";
+import { Column } from "react-table";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NodeStatusModal } from "./NodeStatusModal";
+
+const NodeList: React.FC = () => {
+  const context = useContext(Context);
+  const [nodeList, setNodeList] = useState([]);
+  const [loading, setLoading] = useState<boolean>(false);
+  const [selectedNode, setSelectedNode] = useState<any>(undefined);
+
+  const triggerPopUp = (node?: any) => {
+    if (node) {
+      setSelectedNode(node);
+      return;
+    }
+
+    setSelectedNode(undefined);
+  };
+
+  const columns = useMemo<Column<any>[]>(
+    () => [
+      {
+        Header: "Node name",
+        accessor: "name",
+      },
+      {
+        Header: "CPU Usage",
+        accessor: "cpu_usage",
+      },
+      {
+        Header: "RAM Usage",
+        accessor: "ram_usage",
+      },
+      {
+        Header: () => <StatusHeader>Node Condition</StatusHeader>,
+        accessor: "is_node_healthy",
+        Cell: ({ row }) => {
+          return (
+            <StatusButtonWrapper>
+              <StatusButton
+                success={row.values.is_node_healthy}
+                onClick={() => triggerPopUp(row.original)}
+              >
+                {row.values.is_node_healthy ? "Healthy" : "Unhealthy"}
+              </StatusButton>
+            </StatusButtonWrapper>
+          );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const percentFormatter = (number: number) =>
+      `${Number(number).toFixed(2)}%`;
+
+    return nodeList
+      .map((node) => {
+        return {
+          name: node.name,
+          cpu_usage: percentFormatter(node.cpu_reqs),
+          ram_usage: percentFormatter(node.memory_reqs),
+          node_conditions: node.node_conditions,
+          is_node_healthy: node.node_conditions.reduce(
+            (prevValue: boolean, current: any) => {
+              if (current.type !== "Ready" && current.status !== "False") {
+                return false;
+              }
+              if (current.type === "Ready" && current.status !== "True") {
+                return false;
+              }
+              return prevValue;
+            },
+            true
+          ),
+        };
+      })
+      .sort((firstEl, secondElement) =>
+        firstEl.is_node_healthy === secondElement.is_node_healthy
+          ? 0
+          : firstEl.is_node_healthy
+          ? 1
+          : -1
+      );
+  }, [nodeList]);
+
+  useEffect(() => {
+    const { currentCluster, currentProject } = context;
+    setLoading(true);
+    api
+      .getClusterNodes(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        if (data) {
+          setNodeList(data);
+        }
+      })
+      .catch(() => {
+        console.log({ error: true });
+      })
+      .finally(() => setLoading(false));
+  }, [context, setNodeList]);
+
+  return (
+    <NodeListWrapper>
+      <StyledChart>
+        <Table columns={columns} data={data} isLoading={loading} />
+      </StyledChart>
+      {selectedNode && (
+        <NodeStatusModal node={selectedNode} onClose={() => triggerPopUp()} />
+      )}
+    </NodeListWrapper>
+  );
+};
+
+export default NodeList;
+
+const NodeListWrapper = styled.div`
+  margin-top: 35px;
+`;
+
+const StyledChart = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const StatusHeader = styled.div`
+  width: 100%;
+  text-align: center;
+`;
+
+const StatusButtonWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+`;
+
+const StatusButton = styled.div`
+  cursor: pointer;
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { success: boolean }) =>
+    props.success ? "#616FEEcc" : "#ed5f85"};
+  :hover {
+    background: ${(props: { success: boolean }) =>
+      props.success ? "#405eddbb" : "#e83162"};
+  }
+`;

+ 65 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx

@@ -0,0 +1,65 @@
+import React, { useMemo } from "react";
+import Modal from "../../modals/Modal";
+import Table from "components/Table";
+import { Column } from "react-table";
+import styled from "styled-components";
+
+type NodeStatusModalProps = {
+  onClose: () => void;
+  node: any;
+  width?: string;
+  height?: string;
+};
+
+export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
+  onClose,
+  node,
+  width = "800px",
+  height = "min-content",
+}) => {
+  const columns = useMemo<Column<any>[]>(
+    () => [
+      {
+        Header: "Type",
+        accessor: "type",
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+      },
+      {
+        Header: "Reason",
+        accessor: "reason",
+      },
+      {
+        Header: "Message",
+        accessor: "message",
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    return node?.node_conditions || [];
+  }, [node]);
+
+  return (
+    <div>
+      <Modal onRequestClose={onClose} width={width} height={height}>
+        Node {node?.name} conditions:
+        <TableWrapper>
+          <Table
+            columns={columns}
+            data={data}
+            isLoading={false}
+            disableGlobalFilter={true}
+          />
+        </TableWrapper>
+      </Modal>
+    </div>
+  );
+};
+
+const TableWrapper = styled.div`
+  margin-top: 14px;
+`;

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

@@ -122,12 +122,17 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
               return { label: x.metadata.name, value: x.metadata.name };
             }
             }
           );
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
             this.setState({ namespaceOptions });
           }
           }
         }
         }

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

@@ -3,9 +3,7 @@ import styled from "styled-components";
 
 
 import key from "assets/key.svg";
 import key from "assets/key.svg";
 
 
-import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import StatusIndicator from "components/StatusIndicator";
 
 
 type PropsType = {
 type PropsType = {
   envGroup: any;
   envGroup: any;

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

@@ -13,6 +13,7 @@ import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
 import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushQueryParams } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -31,7 +32,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
   state = {
   state = {
     expand: false,
     expand: false,
     update: [] as any[],
     update: [] as any[],
-    namespace: "default",
+    namespace: null as string,
     expandedEnvGroup: null as any,
     expandedEnvGroup: null as any,
     createEnvMode: false,
     createEnvMode: false,
     sortType: localStorage.getItem("SortType")
     sortType: localStorage.getItem("SortType")
@@ -74,7 +75,13 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
                 sortType={this.state.sortType}
                 sortType={this.state.sortType}
               />
               />
               <NamespaceSelector
               <NamespaceSelector
-                setNamespace={(namespace) => this.setState({ namespace })}
+                setNamespace={(namespace) =>
+                  this.setState({ namespace }, () => {
+                    pushQueryParams(this.props, {
+                      namespace: this.state.namespace || "ALL",
+                    });
+                  })
+                }
                 namespace={this.state.namespace}
                 namespace={this.state.namespace}
               />
               />
             </SortFilterWrapper>
             </SortFilterWrapper>

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

@@ -61,7 +61,6 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
             sortedGroups.sort((a: any, b: any) =>
             sortedGroups.sort((a: any, b: any) =>
               a.metadata.name > b.metadata.name ? 1 : -1
               a.metadata.name > b.metadata.name ? 1 : -1
             );
             );
-            console.log(sortedGroups);
             break;
             break;
           default:
           default:
             sortedGroups.sort((a: any, b: any) =>
             sortedGroups.sort((a: any, b: any) =>
@@ -90,14 +89,15 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
       prevProps.namespace !== this.props.namespace ||
       prevProps.namespace !== this.props.namespace ||
       prevProps.sortType !== this.props.sortType
       prevProps.sortType !== this.props.sortType
     ) {
     ) {
-      this.updateEnvGroups();
+      (this.props.namespace || this.props.namespace === "") &&
+        this.updateEnvGroups();
     }
     }
   }
   }
 
 
   renderEnvGroupList = () => {
   renderEnvGroupList = () => {
     let { loading, error, envGroups } = this.state;
     let { loading, error, envGroups } = this.state;
 
 
-    if (loading) {
+    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
       return (
       return (
         <LoadingWrapper>
         <LoadingWrapper>
           <Loading />
           <Loading />

+ 143 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -0,0 +1,143 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import {
+  ResourceType,
+  ChartType,
+  StorageType,
+  ClusterType,
+} from "shared/types";
+import api from "shared/api";
+import { PorterUrl, pushQueryParams, pushFiltered } from "shared/routing";
+import ExpandedJobChart from "./ExpandedJobChart";
+import ExpandedChart from "./ExpandedChart";
+import Loading from "components/Loading";
+import PageNotFound from "components/PageNotFound";
+
+type PropsType = RouteComponentProps & {
+  setSidebar: (x: boolean) => void;
+  isMetricsInstalled: boolean;
+};
+
+type StateType = {
+  loading: boolean;
+  currentChart: ChartType;
+};
+
+class ExpandedChartWrapper extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    currentChart: null as ChartType,
+  };
+
+  // Retrieve full chart data (includes form and values)
+  getChartData = () => {
+    let { match } = this.props;
+    let { namespace, chartName } = match.params as any;
+    let { currentProject, currentCluster } = this.context;
+    if (currentProject && currentCluster) {
+      // TODO: add query for retrieving max revision #
+      api
+        .getRevisions(
+          "<token>",
+          {
+            namespace: namespace,
+            cluster_id: currentCluster.id,
+            storage: StorageType.Secret,
+          },
+          { id: currentProject.id, name: chartName }
+        )
+        .then((res) => {
+          res.data.sort((a: ChartType, b: ChartType) => {
+            return -(a.version - b.version);
+          });
+          let maxVersion = res.data[0].version;
+          api
+            .getChart(
+              "<token>",
+              {
+                namespace: namespace,
+                cluster_id: currentCluster.id,
+                storage: StorageType.Secret,
+              },
+              {
+                name: chartName,
+                revision: maxVersion,
+                id: currentProject.id,
+              }
+            )
+            .then((res) => {
+              this.setState({ currentChart: res.data, loading: false });
+            })
+            .catch((err) => {
+              console.log("err", err.response.data);
+              this.setState({ loading: false });
+            });
+        })
+        .catch((err) => {
+          console.log("err", err.response.data);
+          this.setState({ loading: false });
+        });
+    }
+  };
+
+  componentDidMount() {
+    this.setState({ loading: true });
+    this.getChartData();
+  }
+
+  render() {
+    let { setSidebar, location, match } = this.props;
+    let { baseRoute, namespace } = match.params as any;
+    let { loading, currentChart } = this.state;
+    if (loading) {
+      return <Loading />;
+    } else if (currentChart && baseRoute === "jobs") {
+      return (
+        <ExpandedJobChart
+          namespace={namespace}
+          currentChart={currentChart}
+          currentCluster={this.context.currentCluster}
+          closeChart={() =>
+            pushFiltered(this.props, "/jobs", ["project_id"], {
+              cluster: this.context.currentCluster.name,
+              namespace: namespace,
+            })
+          }
+          setSidebar={setSidebar}
+        />
+      );
+    } else if (currentChart && baseRoute === "applications") {
+      return (
+        <ExpandedChart
+          namespace={namespace}
+          isMetricsInstalled={this.props.isMetricsInstalled}
+          currentChart={currentChart}
+          currentCluster={this.context.currentCluster}
+          closeChart={() =>
+            pushFiltered(this.props, "/applications", ["project_id"], {
+              cluster: this.context.currentCluster.name,
+              namespace: namespace,
+            })
+          }
+          setSidebar={setSidebar}
+        />
+      );
+    }
+    return <PageNotFound />;
+  }
+}
+
+ExpandedChartWrapper.contextType = Context;
+
+export default withRouter(ExpandedChartWrapper);
+
+const NotFoundPlaceholder = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+`;

+ 29 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -141,6 +141,14 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     this.sortJobsAndSave(jobs);
     this.sortJobsAndSave(jobs);
   };
   };
 
 
+  removeJob = (deletedJob: any) => {
+    let jobs = this.state.jobs.filter((job) => {
+      return deletedJob.metadata?.name !== job.metadata?.name;
+    });
+
+    this.sortJobsAndSave(jobs);
+  };
+
   setupJobWebsocket = (chart: ChartType) => {
   setupJobWebsocket = (chart: ChartType) => {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
 
@@ -173,6 +181,20 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         ) {
         ) {
           this.mergeNewJob(event.Object);
           this.mergeNewJob(event.Object);
         }
         }
+      } else if (event.event_type == "DELETE") {
+        // filter job belonging to chart
+        let chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
+        let releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (
+          chartLabel &&
+          releaseLabel &&
+          chartLabel == chartVersion &&
+          releaseLabel == chart.name
+        ) {
+          this.removeJob(event.Object);
+        }
       }
       }
     };
     };
 
 
@@ -409,7 +431,12 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         }
         return (
         return (
           <TabWrapper>
           <TabWrapper>
-            <JobList jobs={this.state.jobs} />
+            <JobList
+              jobs={this.state.jobs}
+              setJobs={(jobs: any) => {
+                this.setState({ jobs });
+              }}
+            />
             <SaveButton
             <SaveButton
               text="Rerun Job"
               text="Rerun Job"
               onClick={() => this.handleSaveValues(submitValues)}
               onClick={() => this.handleSaveValues(submitValues)}
@@ -421,6 +448,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       case "settings":
       case "settings":
         return (
         return (
           <SettingsSection
           <SettingsSection
+            showSource={true}
             currentChart={this.state.currentChart}
             currentChart={this.state.currentChart}
             refreshChart={() => this.refreshChart(0)}
             refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
             setShowDeleteOverlay={(x: boolean) =>

+ 83 - 57
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -17,11 +17,13 @@ import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 import Helper from "components/values-form/Helper";
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
 import _ from "lodash";
 import _ from "lodash";
+import CopyToClipboard from "components/CopyToClipboard";
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
   refreshChart: () => void;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
   setShowDeleteOverlay: (x: boolean) => void;
+  showSource?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -80,36 +82,78 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       .catch(console.log);
       .catch(console.log);
   }
   }
 
 
-  redeployWithNewImage = (img: string, tag: string) => {
-    this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+  renderWebhookSection = () => {
+    if (!this.props.currentChart?.form?.hasSource) {
+      return;
+    }
 
 
-    // If tag is explicitly declared, parse tag
-    let imgSplits = img.split(":");
-    let parsedTag = null;
-    if (imgSplits.length > 1) {
-      img = imgSplits[0];
-      parsedTag = imgSplits[1];
+    if (true || this.state.webhookToken) {
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
+      return (
+        <>
+          {this.props.showSource && (
+            <>
+              <Heading>Source Settings</Heading>
+              <Helper>Specify an image tag to use.</Helper>
+              <ImageSelector
+                selectedTag={this.state.selectedTag}
+                selectedImageUrl={this.state.selectedImageUrl}
+                setSelectedImageUrl={(x: string) =>
+                  this.setState({ selectedImageUrl: x })
+                }
+                setSelectedTag={(x: string) =>
+                  this.setState({ selectedTag: x })
+                }
+                forceExpanded={true}
+                disableImageSelect={true}
+              />
+              <Br />
+            </>
+          )}
+          <Heading>Redeploy Webhook</Heading>
+          <Helper>
+            Programmatically deploy by calling this secret webhook.
+          </Helper>
+          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
+            <div>{webhookText}</div>
+            <CopyToClipboard
+              as="i"
+              text={webhookText}
+              onSuccess={() => this.setState({ highlightCopyButton: true })}
+              wrapperProps={{
+                className: "material-icons",
+                onMouseLeave: () =>
+                  this.setState({ highlightCopyButton: false }),
+              }}
+            >
+              content_copy
+            </CopyToClipboard>
+          </Webhook>
+        </>
+      );
     }
     }
+  };
 
 
-    let image = {
-      image: {
-        repository: img,
-        tag: parsedTag || tag,
-      },
-    };
+  handleSubmit = () => {
+    let { currentCluster, setCurrentError, currentProject } = this.context;
+    this.setState({ saveValuesStatus: "loading" });
+
+    console.log(this.state.selectedImageUrl);
 
 
     let values = {};
     let values = {};
-    let rawValues = this.props.currentChart.config;
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
+    if (this.state.selectedTag) {
+      _.set(values, "image.repository", this.state.selectedImageUrl);
+      _.set(values, "image.tag", this.state.selectedTag);
     }
     }
 
 
     // Weave in preexisting values and convert to yaml
     // Weave in preexisting values and convert to yaml
-    let valuesYaml = yaml.dump({
-      ...values,
-      ...image,
-    });
+    let conf = yaml.dump(
+      {
+        ...(this.props.currentChart.config as Object),
+        ...values,
+      },
+      { forceQuotes: true }
+    );
 
 
     api
     api
       .upgradeChartValues(
       .upgradeChartValues(
@@ -117,7 +161,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         {
         {
           namespace: this.props.currentChart.namespace,
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
-          values: valuesYaml,
+          values: conf,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
@@ -145,41 +189,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       });
       });
   };
   };
 
 
-  renderWebhookSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
-
-    if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
-      return (
-        <>
-          <Heading>Redeploy Webhook</Heading>
-          <Helper>
-            Programmatically deploy by calling this secret webhook.
-          </Helper>
-          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
-            <div>{webhookText}</div>
-            <i
-              className="material-icons"
-              onClick={() => {
-                navigator.clipboard.writeText(webhookText);
-                this.setState({ highlightCopyButton: true });
-              }}
-              onMouseLeave={() => this.setState({ highlightCopyButton: false })}
-            >
-              content_copy
-            </i>
-          </Webhook>
-        </>
-      );
-    }
-  };
-
   render() {
   render() {
     return (
     return (
       <Wrapper>
       <Wrapper>
-        <StyledSettingsSection>
+        <StyledSettingsSection showSource={this.props.showSource}>
           {this.renderWebhookSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Heading>Additional Settings</Heading>
           <Button
           <Button
@@ -189,6 +202,14 @@ export default class SettingsSection extends Component<PropsType, StateType> {
             Delete {this.props.currentChart.name}
             Delete {this.props.currentChart.name}
           </Button>
           </Button>
         </StyledSettingsSection>
         </StyledSettingsSection>
+        {this.props.showSource && (
+          <SaveButton
+            text="Deploy"
+            status={this.state.saveValuesStatus}
+            onClick={this.handleSubmit}
+            makeFlush={true}
+          />
+        )}
       </Wrapper>
       </Wrapper>
     );
     );
   }
   }
@@ -196,6 +217,11 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
 
 SettingsSection.contextType = Context;
 SettingsSection.contextType = Context;
 
 
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Button = styled.button`
 const Button = styled.button`
   height: 35px;
   height: 35px;
   font-size: 13px;
   font-size: 13px;
@@ -280,15 +306,15 @@ const Wrapper = styled.div`
   height: 100%;
   height: 100%;
 `;
 `;
 
 
-const StyledSettingsSection = styled.div`
+const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   width: 100%;
   width: 100%;
-  height: calc(100%);
   background: #ffffff11;
   background: #ffffff11;
   padding: 0 35px;
   padding: 0 35px;
   padding-bottom: 50px;
   padding-bottom: 50px;
   position: relative;
   position: relative;
   border-radius: 5px;
   border-radius: 5px;
   overflow: auto;
   overflow: auto;
+  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
 `;
 `;
 
 
 const Holder = styled.div`
 const Holder = styled.div`

+ 65 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -1,17 +1,28 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
+import api from "shared/api";
 import _ from "lodash";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
 import JobResource from "./JobResource";
+import ConfirmOverlay from "components/ConfirmOverlay";
 
 
 type PropsType = {
 type PropsType = {
   jobs: any[];
   jobs: any[];
+  setJobs: (job: any) => void;
 };
 };
 
 
-type StateType = {};
+type StateType = {
+  deletionCandidate: any;
+  deletionJob: any;
+};
 
 
 export default class JobList extends Component<PropsType, StateType> {
 export default class JobList extends Component<PropsType, StateType> {
+  state = {
+    deletionCandidate: null as any,
+    deletionJob: null as any,
+  };
+
   renderJobList = () => {
   renderJobList = () => {
     if (this.props.jobs.length === 0) {
     if (this.props.jobs.length === 0) {
       return (
       return (
@@ -24,15 +35,66 @@ export default class JobList extends Component<PropsType, StateType> {
       return (
       return (
         <>
         <>
           {this.props.jobs.map((job: any, i: number) => {
           {this.props.jobs.map((job: any, i: number) => {
-            return <JobResource key={job?.metadata?.name} job={job} />;
+            return (
+              <JobResource
+                key={job?.metadata?.name}
+                job={job}
+                handleDelete={() => this.setState({ deletionCandidate: job })}
+                deleting={
+                  this.state.deletionJob?.metadata?.name == job.metadata?.name
+                }
+              />
+            );
           })}
           })}
         </>
         </>
       );
       );
     }
     }
   };
   };
 
 
+  deleteJob = () => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let job = this.state.deletionCandidate;
+
+    api
+      .deleteJob(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: job.metadata?.name,
+          namespace: job.metadata?.namespace,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          deletionJob: this.state.deletionCandidate,
+          deletionCandidate: null,
+        });
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
+  };
+
   render() {
   render() {
-    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
+    return (
+      <>
+        <ConfirmOverlay
+          show={this.state.deletionCandidate}
+          message={`Are you sure you want to delete this job run?`}
+          onYes={this.deleteJob}
+          onNo={() => this.setState({ deletionCandidate: null })}
+        />
+        <JobListWrapper>{this.renderJobList()}</JobListWrapper>
+      </>
+    );
   }
   }
 }
 }
 
 

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

@@ -7,10 +7,13 @@ import api from "shared/api";
 import Logs from "../status/Logs";
 import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import closeRounded from "assets/close-rounded.png";
+import trash from "assets/trash.png";
 import KeyValueArray from "components/values-form/KeyValueArray";
 import KeyValueArray from "components/values-form/KeyValueArray";
 
 
 type PropsType = {
 type PropsType = {
   job: any;
   job: any;
+  handleDelete: () => void;
+  deleting: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -55,7 +58,14 @@ export default class JobResource extends Component<PropsType, StateType> {
         }
         }
       )
       )
       .then((res) => {})
       .then((res) => {})
-      .catch((err) => setCurrentError(JSON.stringify(err)));
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
   };
   };
 
 
   getPods = (callback: () => void) => {
   getPods = (callback: () => void) => {
@@ -148,6 +158,7 @@ export default class JobResource extends Component<PropsType, StateType> {
         </ExpandConfigBar>
         </ExpandConfigBar>
       );
       );
     } else {
     } else {
+      let tag = job.spec.template.spec.containers[0].image.split(":")[1];
       return (
       return (
         <>
         <>
           <ExpandConfigBar
           <ExpandConfigBar
@@ -164,6 +175,9 @@ export default class JobResource extends Component<PropsType, StateType> {
             ) : (
             ) : (
               <DarkMatter size="-18px" />
               <DarkMatter size="-18px" />
             )}
             )}
+            <Row>
+              Image Tag: <Command>{tag}</Command>
+            </Row>
             {!_.isEmpty(envObject) && (
             {!_.isEmpty(envObject) && (
               <>
               <>
                 <KeyValueArray
                 <KeyValueArray
@@ -213,6 +227,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
   };
 
 
   renderStatus = () => {
   renderStatus = () => {
+    if (this.props.deleting) {
+      return <Status color="#cc3d42">Deleting</Status>;
+    }
+
     if (this.props.job.status?.succeeded >= 1) {
     if (this.props.job.status?.succeeded >= 1) {
       return <Status color="#38a88a">Succeeded</Status>;
       return <Status color="#38a88a">Succeeded</Status>;
     }
     }
@@ -245,36 +263,52 @@ export default class JobResource extends Component<PropsType, StateType> {
     );
     );
 
 
     return (
     return (
-      <StyledJob>
-        <MainRow onClick={this.expandJob}>
-          <Flex>
-            <Icon src={icon && icon} />
-            <Description>
-              <Label>
-                Started at {this.readableDate(this.props.job.status?.startTime)}
-              </Label>
-              <Subtitle>{this.getSubtitle()}</Subtitle>
-            </Description>
-          </Flex>
-          <EndWrapper>
-            <CommandString>{commandString}</CommandString>
-            {this.renderStatus()}
-            <MaterialIconTray disabled={false}>
-              {this.renderStopButton()}
-              <i className="material-icons" onClick={this.expandJob}>
-                {this.state.expanded ? "expand_less" : "expand_more"}
-              </i>
-            </MaterialIconTray>
-          </EndWrapper>
-        </MainRow>
-        {this.renderLogsSection()}
-      </StyledJob>
+      <>
+        <StyledJob>
+          <MainRow onClick={this.expandJob}>
+            <Flex>
+              <Icon src={icon && icon} />
+              <Description>
+                <Label>
+                  Started at{" "}
+                  {this.readableDate(this.props.job.status?.startTime)}
+                </Label>
+                <Subtitle>{this.getSubtitle()}</Subtitle>
+              </Description>
+            </Flex>
+            <EndWrapper>
+              <CommandString>{commandString}</CommandString>
+              {this.renderStatus()}
+              <MaterialIconTray disabled={false}>
+                {this.renderStopButton()}
+                <i
+                  className="material-icons"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    this.props.handleDelete();
+                  }}
+                >
+                  delete
+                </i>
+                <i className="material-icons" onClick={this.expandJob}>
+                  {this.state.expanded ? "expand_less" : "expand_more"}
+                </i>
+              </MaterialIconTray>
+            </EndWrapper>
+          </MainRow>
+          {this.renderLogsSection()}
+        </StyledJob>
+      </>
     );
     );
   }
   }
 }
 }
 
 
 JobResource.contextType = Context;
 JobResource.contextType = Context;
 
 
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
 const DarkMatter = styled.div<{ size?: string }>`
 const DarkMatter = styled.div<{ size?: string }>`
   width: 100%;
   width: 100%;
   margin-bottom: ${(props) => props.size || "-13px"};
   margin-bottom: ${(props) => props.size || "-13px"};

+ 105 - 22
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-
+import { ChartType } from "shared/types";
 import ResourceTab from "components/ResourceTab";
 import ResourceTab from "components/ResourceTab";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import ConfirmOverlay from "components/ConfirmOverlay";
 
 
@@ -21,6 +21,10 @@ type StateType = {
   raw: any[];
   raw: any[];
   showTooltip: boolean[];
   showTooltip: boolean[];
   podPendingDelete: any;
   podPendingDelete: any;
+  websockets: Record<string, any>;
+  selectors: string[];
+  available: number;
+  total: number;
 };
 };
 
 
 // Controller tab in log section that displays list of pods on click.
 // Controller tab in log section that displays list of pods on click.
@@ -30,37 +34,23 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     raw: [] as any[],
     raw: [] as any[],
     showTooltip: [] as boolean[],
     showTooltip: [] as boolean[],
     podPendingDelete: null as any,
     podPendingDelete: null as any,
+    websockets: {} as Record<string, any>,
+    selectors: [] as string[],
+    available: null as number,
+    total: null as number,
   };
   };
 
 
   updatePods = () => {
   updatePods = () => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { controller, selectPod, isFirst } = this.props;
     let { controller, selectPod, isFirst } = this.props;
 
 
-    let selectors = [] as string[];
-    let ml =
-      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
-    let i = 1;
-    let selector = "";
-    for (var key in ml) {
-      selector += key + "=" + ml[key];
-      if (i != Object.keys(ml).length) {
-        selector += ",";
-      }
-      i += 1;
-    }
-    selectors.push(selector);
-
-    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
-      selectors = this.props.selectors;
-    }
-
     api
     api
       .getMatchingPods(
       .getMatchingPods(
         "<token>",
         "<token>",
         {
         {
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
           namespace: controller?.metadata?.namespace,
           namespace: controller?.metadata?.namespace,
-          selectors,
+          selectors: this.state.selectors,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
@@ -97,10 +87,103 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       });
       });
   };
   };
 
 
+  getPodSelectors = (callback: () => void) => {
+    let { controller } = this.props;
+
+    let selectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    selectors.push(selector);
+    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
+      selectors = this.props.selectors;
+    }
+
+    this.setState({ selectors }, () => {
+      callback();
+    });
+  };
+
   componentDidMount() {
   componentDidMount() {
-    this.updatePods();
+    this.getPodSelectors(() => {
+      this.updatePods();
+      this.setControllerWebsockets([this.props.controller.kind, "pod"]);
+    });
+  }
+
+  componentWillUnmount() {
+    if (this.state.websockets) {
+      this.state.websockets.forEach((ws: WebSocket) => {
+        ws.close();
+      });
+    }
   }
   }
 
 
+  setControllerWebsockets = (controller_types: any[]) => {
+    let websockets = controller_types.map((kind: string) => {
+      return this.setupWebsocket(kind);
+    });
+    this.setState({ websockets });
+  };
+
+  setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = this.context;
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    let connString = `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    if (kind == "pod" && this.state.selectors) {
+      connString += `&selectors=${this.state.selectors[0]}`;
+    }
+    let ws = new WebSocket(connString);
+
+    ws.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (
+        event.Kind != "pod" &&
+        object.metadata.uid != this.props.controller.metadata.uid
+      )
+        return;
+
+      if (event.Kind != "pod") {
+        let [available, total] = this.getAvailability(
+          object.metadata.kind,
+          object
+        );
+        this.setState({ available, total });
+      }
+
+      this.updatePods();
+    };
+
+    ws.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
+
+    return ws;
+  };
+
   getAvailability = (kind: string, c: any) => {
   getAvailability = (kind: string, c: any) => {
     switch (kind?.toLowerCase()) {
     switch (kind?.toLowerCase()) {
       case "deployment":
       case "deployment":
@@ -196,7 +279,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
     let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
-    let [available, total] = this.getAvailability(controller.kind, controller);
+    let { available, total } = this.state;
     let status = available == total ? "running" : "waiting";
     let status = available == total ? "running" : "waiting";
 
 
     controller?.status?.conditions?.forEach((condition: any) => {
     controller?.status?.conditions?.forEach((condition: any) => {

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

@@ -130,8 +130,9 @@ export default class Logs extends Component<PropsType, StateType> {
       this.ws = null;
       this.ws = null;
       this.setState({ logs: [] });
       this.setState({ logs: [] });
       this.setupWebsocket();
       this.setupWebsocket();
+    } else if (this.state.currentTab == "System") {
+      this.retrieveEvents(selectedPod);
     }
     }
-    this.retrieveEvents(selectedPod);
   };
   };
 
 
   componentDidUpdate = (prevProps: any, prevState: any) => {
   componentDidUpdate = (prevProps: any, prevState: any) => {

+ 219 - 34
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -3,12 +3,20 @@ import styled from "styled-components";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
-import { ClusterType } from "shared/types";
+import {
+  ClusterType,
+  DetailedClusterType,
+  DetailedIngressError,
+} from "shared/types";
 import Helper from "components/values-form/Helper";
 import Helper from "components/values-form/Helper";
+import { pushFiltered } from "shared/routing";
 
 
-import Loading from "components/Loading";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
+import CopyToClipboard from "components/CopyToClipboard";
+import Loading from "components/Loading";
+import Modal from "../modals/Modal";
+
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
 };
 };
@@ -16,14 +24,19 @@ type PropsType = RouteComponentProps & {
 type StateType = {
 type StateType = {
   loading: boolean;
   loading: boolean;
   error: string;
   error: string;
-  clusters: ClusterType[];
+  clusters: DetailedClusterType[];
+  showErrorModal?: {
+    clusterId: number;
+    show: boolean;
+  };
 };
 };
 
 
 class Templates extends Component<PropsType, StateType> {
 class Templates extends Component<PropsType, StateType> {
-  state = {
+  state: StateType = {
     loading: true,
     loading: true,
     error: "",
     error: "",
-    clusters: [] as ClusterType[],
+    clusters: [],
+    showErrorModal: undefined,
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -36,17 +49,50 @@ class Templates extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
-  updateClusterList = () => {
-    api
-      .getClusters("<token>", {}, { id: this.context.currentProject.id })
-      .then((res) => {
-        if (res.data) {
-          this.setState({ clusters: res.data, loading: false, error: "" });
-        } else {
-          this.setState({ loading: false, error: "Response data missing" });
-        }
-      })
-      .catch((err) => this.setState(err));
+  updateClusterList = async () => {
+    try {
+      const res = await api.getClusters(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      );
+
+      if (res.data) {
+        this.setState({ clusters: res.data, loading: false, error: "" });
+
+        this.state.clusters.forEach((cluster) => {
+          this.updateClusterWithDetailedData(cluster.id);
+        });
+      } else {
+        this.setState({ loading: false, error: "Response data missing" });
+      }
+    } catch (err) {
+      this.setState(err);
+    }
+  };
+
+  updateClusterWithDetailedData = async (clusterId: number) => {
+    try {
+      const currentClusterIndex = this.state.clusters.findIndex(
+        (cluster) => cluster.id === clusterId
+      );
+      const res = await api.getCluster(
+        "<token>",
+        {},
+        { project_id: this.context.currentProject.id, cluster_id: clusterId }
+      );
+      if (res.data) {
+        this.setState((prevState) => {
+          const currentCluster = prevState.clusters[currentClusterIndex];
+          prevState.clusters.splice(currentClusterIndex, 1, {
+            ...currentCluster,
+            ingress_ip: res.data.ingress_ip,
+            ingress_error: res.data.ingress_error,
+          });
+          return prevState;
+        });
+      }
+    } catch (error) {}
   };
   };
 
 
   renderIcon = () => {
   renderIcon = () => {
@@ -57,21 +103,107 @@ class Templates extends Component<PropsType, StateType> {
     );
     );
   };
   };
 
 
-  renderClusters = () => {
-    return this.state.clusters.map((cluster: ClusterType, i: number) => {
+  renderIngressIp = (
+    clusterId: number,
+    ingressIp: string | undefined,
+    ingressError: DetailedIngressError
+  ) => {
+    if (typeof ingressIp !== "string") {
       return (
       return (
-        <TemplateBlock
-          onClick={() => {
-            this.context.setCurrentCluster(cluster);
-            this.props.history.push("applications");
-          }}
-          key={i}
-        >
-          {this.renderIcon()}
-          <TemplateTitle>{cluster.name}</TemplateTitle>
-        </TemplateBlock>
+        <Url onClick={(e) => e.preventDefault()}>
+          <Loading />
+        </Url>
       );
       );
-    });
+    }
+
+    if (!ingressIp.length && ingressError) {
+      return (
+        <>
+          <Url
+            onClick={(e) => {
+              e.stopPropagation();
+              this.setState({ showErrorModal: { clusterId, show: true } });
+            }}
+          >
+            <Bolded>Ingress IP:</Bolded>
+            <span>{ingressError.message}</span>
+            <i className="material-icons">launch</i>
+          </Url>
+        </>
+      );
+    }
+
+    if (!ingressIp.length) {
+      return (
+        <Url>
+          <Bolded>Ingress IP:</Bolded>
+          <span>Ingress IP not available</span>
+        </Url>
+      );
+    }
+
+    return (
+      <CopyToClipboard
+        as={Url}
+        text={ingressIp}
+        wrapperProps={{ onClick: (e: any) => e.stopPropagation() }}
+      >
+        <Bolded>Ingress IP:</Bolded>
+        <span>{ingressIp}</span>
+        <i className="material-icons-outlined">content_copy</i>
+      </CopyToClipboard>
+    );
+  };
+
+  renderClusters = () => {
+    return this.state.clusters.map(
+      (cluster: DetailedClusterType, i: number) => {
+        return (
+          <TemplateBlock
+            onClick={() => {
+              this.context.setCurrentCluster(cluster);
+              pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+                cluster: cluster.name,
+              });
+            }}
+            key={i}
+          >
+            <TitleContainer>
+              {this.renderIcon()}
+              <TemplateTitle>{cluster.name}</TemplateTitle>
+            </TitleContainer>
+            {this.renderIngressIp(
+              cluster.id,
+              cluster.ingress_ip,
+              cluster.ingress_error
+            )}
+          </TemplateBlock>
+        );
+      }
+    );
+  };
+
+  renderErrorModal = () => {
+    const clusterError =
+      this.state.showErrorModal?.show &&
+      this.state.clusters.find(
+        (c) => c.id === this.state.showErrorModal?.clusterId
+      );
+    const ingressError = clusterError?.ingress_error;
+    return (
+      <>
+        {clusterError && (
+          <Modal
+            onRequestClose={() => this.setState({ showErrorModal: undefined })}
+            width="665px"
+            height="min-content"
+          >
+            Porter encountered an error. Full error log:
+            <CodeBlock>{ingressError.error}</CodeBlock>
+          </Modal>
+        )}
+      </>
+    );
   };
   };
 
 
   render() {
   render() {
@@ -79,6 +211,7 @@ class Templates extends Component<PropsType, StateType> {
       <StyledClusterList>
       <StyledClusterList>
         <Helper>Clusters connected to this project:</Helper>
         <Helper>Clusters connected to this project:</Helper>
         <TemplateList>{this.renderClusters()}</TemplateList>
         <TemplateList>{this.renderClusters()}</TemplateList>
+        {this.renderErrorModal()}
       </StyledClusterList>
       </StyledClusterList>
     );
     );
   }
   }
@@ -88,11 +221,34 @@ Templates.contextType = Context;
 
 
 export default withRouter(Templates);
 export default withRouter(Templates);
 
 
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
+`;
+
 const StyledClusterList = styled.div`
 const StyledClusterList = styled.div`
   margin-top: -17px;
   margin-top: -17px;
   padding-left: 2px;
   padding-left: 2px;
 `;
 `;
 
 
+const TitleContainer = styled.div`
+  display: flex;
+  width: 100%;
+  flex-direction: column;
+  align-items: center;
+`;
 const DashboardIcon = styled.div`
 const DashboardIcon = styled.div`
   position: relative;
   position: relative;
   height: 45px;
   height: 45px;
@@ -104,19 +260,21 @@ const DashboardIcon = styled.div`
   justify-content: center;
   justify-content: center;
   background: #676c7c;
   background: #676c7c;
   border: 2px solid #8e94aa;
   border: 2px solid #8e94aa;
-
+  margin-bottom: 10px;
   > i {
   > i {
     font-size: 22px;
     font-size: 22px;
   }
   }
 `;
 `;
 
 
 const TemplateTitle = styled.div`
 const TemplateTitle = styled.div`
-  margin-bottom: 26px;
-  width: 80%;
+  margin-bottom: 0px;
+  margin-top: 13px;
+  width: 100%;
   text-align: center;
   text-align: center;
   font-size: 14px;
   font-size: 14px;
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
+  white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
 `;
 `;
 
 
@@ -128,11 +286,11 @@ const TemplateBlock = styled.div`
   display: flex;
   display: flex;
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
-  padding: 35px 10px 12px;
+  padding: 35px;
   flex-direction: column;
   flex-direction: column;
   align-item: center;
   align-item: center;
   justify-content: space-between;
   justify-content: space-between;
-  height: 165px;
+  height: 192px;
   cursor: pointer;
   cursor: pointer;
   color: #ffffff;
   color: #ffffff;
   position: relative;
   position: relative;
@@ -200,3 +358,30 @@ const TemplatesWrapper = styled.div`
   min-width: 300px;
   min-width: 300px;
   padding-top: 50px;
   padding-top: 50px;
 `;
 `;
+
+const Url = styled.a`
+  width: 100%;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+`;
+
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+  white-space: nowrap;
+`;

+ 3 - 13
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -6,6 +6,7 @@ import { ClusterType } from "shared/types";
 
 
 import ClusterList from "./ClusterList";
 import ClusterList from "./ClusterList";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
+import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
 
 type PropsType = {
 type PropsType = {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -36,25 +37,14 @@ export default class ClusterPlaceholder extends Component<
   }
   }
 
 
   render() {
   render() {
-    if (this.state.loading) {
+    if (this.state.loading || this.props.currentCluster?.id === -1) {
       return (
       return (
         <LoadingWrapper>
         <LoadingWrapper>
           <Loading />
           <Loading />
         </LoadingWrapper>
         </LoadingWrapper>
       );
       );
     } else if (!this.props.currentCluster) {
     } else if (!this.props.currentCluster) {
-      return (
-        <StyledStatusPlaceholder>
-          You need to connect a cluster to use Porter.
-          <Highlight
-            onClick={() => {
-              this.context.setCurrentModal("ClusterInstructionsModal", {});
-            }}
-          >
-            + Connect an existing cluster
-          </Highlight>
-        </StyledStatusPlaceholder>
-      );
+      return <NoClusterPlaceholder />;
     } else {
     } else {
       return <ClusterList currentCluster={this.props.currentCluster} />;
       return <ClusterList currentCluster={this.props.currentCluster} />;
     }
     }

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

@@ -13,19 +13,13 @@ import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/values-form/FormDebugger";
 import FormDebugger from "components/values-form/FormDebugger";
 
 
-import { setSearchParam } from "shared/routing";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   projectId: number | null;
   projectId: number | null;
   setRefreshClusters: (x: boolean) => void;
   setRefreshClusters: (x: boolean) => void;
 };
 };
 
 
-const tabOptions = [
-  { label: "Project Overview", value: "overview" },
-  { label: "Create a Cluster", value: "create-cluster" },
-  { label: "Provisioner Status", value: "provisioner" },
-];
-
 // TODO: rethink this list, should be coupled with tabOptions
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 
 
@@ -101,7 +95,7 @@ class Dashboard extends Component<PropsType, StateType> {
   }
   }
 
 
   onShowProjectSettings = () => {
   onShowProjectSettings = () => {
-    this.props.history.push("project-settings");
+    pushFiltered(this.props, "/project-settings", ["project_id"]);
   };
   };
 
 
   currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
   currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
@@ -125,7 +119,7 @@ class Dashboard extends Component<PropsType, StateType> {
   };
   };
 
 
   setCurrentTab = (x: string) => {
   setCurrentTab = (x: string) => {
-    this.props.history.push(setSearchParam(this.props.location, "tab", x));
+    pushQueryParams(this.props, { tab: x });
   };
   };
 
 
   render() {
   render() {

+ 11 - 4
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -7,6 +7,7 @@ import { integrationList } from "shared/common";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import IntegrationList from "./IntegrationList";
 import api from "shared/api";
 import api from "shared/api";
+import { pushFiltered } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   category: string;
   category: string;
@@ -129,7 +130,9 @@ class IntegrationCategories extends Component<PropsType, StateType> {
             <Flex>
             <Flex>
               <i
               <i
                 className="material-icons"
                 className="material-icons"
-                onClick={() => this.props.history.push("/integrations")}
+                onClick={() =>
+                  pushFiltered(this.props, "/integrations", ["project_id"])
+                }
               >
               >
                 keyboard_backspace
                 keyboard_backspace
               </i>
               </i>
@@ -141,8 +144,10 @@ class IntegrationCategories extends Component<PropsType, StateType> {
                 this.context.setCurrentModal("IntegrationsModal", {
                 this.context.setCurrentModal("IntegrationsModal", {
                   category: currentCategory,
                   category: currentCategory,
                   setCurrentIntegration: (x: string) =>
                   setCurrentIntegration: (x: string) =>
-                    this.props.history.push(
-                      `/integrations/${this.props.category}/create/${x}`
+                    pushFiltered(
+                      this.props,
+                      `/integrations/${this.props.category}/create/${x}`,
+                      ["project_id"]
                     ),
                     ),
                 })
                 })
               }
               }
@@ -172,7 +177,9 @@ class IntegrationCategories extends Component<PropsType, StateType> {
             <Flex>
             <Flex>
               <i
               <i
                 className="material-icons"
                 className="material-icons"
-                onClick={() => this.props.history.push("/integrations")}
+                onClick={() =>
+                  pushFiltered(this.props, "/integrations", ["project_id"])
+                }
               >
               >
                 keyboard_backspace
                 keyboard_backspace
               </i>
               </i>

+ 12 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -3,6 +3,7 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 
 
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import styled from "styled-components";
 import styled from "styled-components";
+import { pushFiltered } from "shared/routing";
 
 
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";
 import IntegrationCategories from "./IntegrationCategories";
@@ -29,7 +30,7 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
           render={(rp) => {
             const { integration, category } = rp.match.params;
             const { integration, category } = rp.match.params;
             if (!IntegrationCategoryStrings.includes(category)) {
             if (!IntegrationCategoryStrings.includes(category)) {
-              this.props.history.push("/integrations");
+              pushFiltered(this.props, "/integrations", ["project_id"]);
             }
             }
             let icon =
             let icon =
               integrationList[integration] && integrationList[integration].icon;
               integrationList[integration] && integrationList[integration].icon;
@@ -40,7 +41,9 @@ class Integrations extends Component<PropsType, StateType> {
                     <i
                     <i
                       className="material-icons"
                       className="material-icons"
                       onClick={() =>
                       onClick={() =>
-                        this.props.history.push(`/integrations/${category}`)
+                        pushFiltered(this.props, `/integrations/${category}`, [
+                          "project_id",
+                        ])
                       }
                       }
                     >
                     >
                       keyboard_backspace
                       keyboard_backspace
@@ -52,7 +55,9 @@ class Integrations extends Component<PropsType, StateType> {
                 <CreateIntegrationForm
                 <CreateIntegrationForm
                   integrationName={integration}
                   integrationName={integration}
                   closeForm={() => {
                   closeForm={() => {
-                    this.props.history.push(`/integrations/${category}`);
+                    pushFiltered(this.props, `/integrations/${category}`, [
+                      "project_id",
+                    ]);
                   }}
                   }}
                 />
                 />
                 <Br />
                 <Br />
@@ -65,7 +70,7 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
           render={(rp) => {
             const currentCategory = rp.match.params.category;
             const currentCategory = rp.match.params.category;
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
-              this.props.history.push("/integrations");
+              pushFiltered(this.props, "/integrations", ["project_id"]);
             }
             }
             return (
             return (
               <IntegrationCategories
               <IntegrationCategories
@@ -83,7 +88,9 @@ class Integrations extends Component<PropsType, StateType> {
             <IntegrationList
             <IntegrationList
               currentCategory={""}
               currentCategory={""}
               integrations={["kubernetes", "registry", "repo"]}
               integrations={["kubernetes", "registry", "repo"]}
-              setCurrent={(x) => this.props.history.push(`/integrations/${x}`)}
+              setCurrent={(x) =>
+                pushFiltered(this.props, `/integrations/${x}`, ["project_id"])
+              }
               isCategory={true}
               isCategory={true}
               updateIntegrationList={() => {}}
               updateIntegrationList={() => {}}
             />
             />

+ 35 - 36
dashboard/src/main/home/launch/Launch.tsx

@@ -9,6 +9,7 @@ import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
 import LaunchFlow from "./launch-flow/LaunchFlow";
+import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
 
 import hardcodedNames from "./hardcodedNameDict";
 import hardcodedNames from "./hardcodedNameDict";
 import semver from "semver";
 import semver from "semver";
@@ -197,6 +198,38 @@ export default class Templates extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  renderContents = () => {
+    if (this.context.currentCluster) {
+      return (
+        <>
+          <TabSelector
+            options={tabOptions}
+            currentTab={this.state.currentTab}
+            setCurrentTab={(value: string) =>
+              this.setState({
+                currentTab: value,
+                currentTemplate: null,
+              })
+            }
+          />
+          {this.renderTabContents()}
+        </>
+      );
+    } else if (this.context.currentCluster?.id === -1) {
+      return <Loading />;
+    } else if (!this.context.currentCluster) {
+      return (
+        <>
+          <Banner>
+            <i className="material-icons">error_outline</i>
+            No cluster connected to this project.
+          </Banner>
+          <NoClusterPlaceholder />
+        </>
+      );
+    }
+  };
+
   render() {
   render() {
     if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
     if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
       return (
       return (
@@ -207,41 +240,7 @@ export default class Templates extends Component<PropsType, StateType> {
               <i className="material-icons">help_outline</i>
               <i className="material-icons">help_outline</i>
             </a>
             </a>
           </TitleSection>
           </TitleSection>
-          {this.context.currentCluster ? (
-            <>
-              <TabSelector
-                options={tabOptions}
-                currentTab={this.state.currentTab}
-                setCurrentTab={(value: string) =>
-                  this.setState({
-                    currentTab: value,
-                    currentTemplate: null,
-                  })
-                }
-              />
-              {this.renderTabContents()}
-            </>
-          ) : (
-            <>
-              <Banner>
-                <i className="material-icons">error_outline</i>
-                No cluster connected to this project.
-              </Banner>
-              <StyledStatusPlaceholder>
-                You need to connect a cluster to use Porter.
-                <Highlight
-                  onClick={() => {
-                    this.context.setCurrentModal(
-                      "ClusterInstructionsModal",
-                      {}
-                    );
-                  }}
-                >
-                  + Connect an existing cluster
-                </Highlight>
-              </StyledStatusPlaceholder>
-            </>
-          )}
+          {this.renderContents()}
         </TemplatesWrapper>
         </TemplatesWrapper>
       );
       );
     } else {
     } else {
@@ -277,7 +276,7 @@ const Placeholder = styled.div`
 const Banner = styled.div`
 const Banner = styled.div`
   height: 40px;
   height: 40px;
   width: 100%;
   width: 100%;
-  margin: 30px 0 30px;
+  margin: 30px 0 38px;
   font-size: 13px;
   font-size: 13px;
   display: flex;
   display: flex;
   border-radius: 5px;
   border-radius: 5px;

+ 0 - 14
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -5,9 +5,7 @@ import { PorterTemplate } from "shared/types";
 import api from "shared/api";
 import api from "shared/api";
 
 
 import TemplateInfo from "./TemplateInfo";
 import TemplateInfo from "./TemplateInfo";
-import LaunchTemplate from "./LaunchTemplate";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import { template } from "lodash";
 
 
 type PropsType = {
 type PropsType = {
   currentTemplate: PorterTemplate;
   currentTemplate: PorterTemplate;
@@ -88,18 +86,6 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
         </LoadingWrapper>
         </LoadingWrapper>
       );
       );
     }
     }
-    if (this.props.skipDescription || this.state.showLaunchTemplate) {
-      return (
-        <LaunchTemplate
-          currentTab={this.props.currentTab}
-          currentTemplate={this.props.currentTemplate}
-          hideLaunch={() => this.setState({ showLaunchTemplate: false })}
-          hideBackButton={this.props.skipDescription}
-          values={this.state.values}
-          form={this.state.form}
-        />
-      );
-    }
 
 
     return (
     return (
       <FadeWrapper>
       <FadeWrapper>

+ 42 - 37
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -4,6 +4,7 @@ import randomWords from "random-words";
 import _ from "lodash";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
+import { pushFiltered } from "shared/routing";
 import close from "assets/close.png";
 import close from "assets/close.png";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
@@ -162,11 +163,15 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       .then((_) => {
       .then((_) => {
         // this.props.setCurrentView('cluster-dashboard');
         // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
         this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard
+          // TODO: redirect to appropriate cluster if not current context
           let dst =
           let dst =
-            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+            this.props.currentTemplate.name === "job"
+              ? "/jobs"
+              : "/applications";
           setTimeout(() => {
           setTimeout(() => {
-            this.props.history.push(dst);
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 500);
           }, 500);
           window.analytics.track("Deployed Add-on", {
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
             name: this.props.currentTemplate.name,
@@ -176,8 +181,18 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         });
         });
       })
       })
       .catch((err) => {
       .catch((err) => {
-        this.setState({ saveValuesStatus: "error" });
-        setCurrentError(err.response.data.errors[0]);
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(err);
+
         window.analytics.track("Failed to Deploy Add-on", {
         window.analytics.track("Failed to Deploy Add-on", {
           name: this.props.currentTemplate.name,
           name: this.props.currentTemplate.name,
           namespace: this.state.selectedNamespace,
           namespace: this.state.selectedNamespace,
@@ -301,39 +316,16 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           setTimeout(() => {
           setTimeout(() => {
             let dst =
             let dst =
               this.props.currentTemplate.name === "job"
               this.props.currentTemplate.name === "job"
-                ? "jobs"
-                : "applications";
-            this.props.history.push(dst);
+                ? "/jobs"
+                : "/applications";
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 1000);
           }, 1000);
         });
         });
-        /*
-        try {
-          window.analytics.track("Deployed Application", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            sourceType: this.state.sourceType,
-            values: values,
-          });
-        } catch (error) {
-          console.log(error);
-        }
-        */
       })
       })
       .catch((err) => {
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
         this.setState({ saveValuesStatus: "error" });
-        /*
-        try {
-          window.analytics.track("Failed to Deploy Application", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            sourceType: this.state.sourceType,
-            values: values,
-            error: err,
-          });
-        } catch (error) {
-          console.log(error);
-        }
-        */
       });
       });
   };
   };
 
 
@@ -452,12 +444,17 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
               return { label: x.metadata.name, value: x.metadata.name };
             }
             }
           );
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
             this.setState({ namespaceOptions });
           }
           }
         }
         }
@@ -584,7 +581,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             Specify the container image you would like to connect to this
             Specify the container image you would like to connect to this
             template.
             template.
             <Highlight
             <Highlight
-              onClick={() => this.props.history.push("integrations/registry")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/registry", [
+                  "project_id",
+                ])
+              }
             >
             >
               Manage Docker registries
               Manage Docker registries
             </Highlight>
             </Highlight>
@@ -610,7 +611,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           <Subtitle>
           <Subtitle>
             Provide a repo folder to use as source.
             Provide a repo folder to use as source.
             <Highlight
             <Highlight
-              onClick={() => this.props.history.push("integrations/repo")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/repo", ["project_id"])
+              }
             >
             >
               Manage Git repos
               Manage Git repos
             </Highlight>
             </Highlight>
@@ -686,6 +689,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   render() {
+    console.log("RENDERING");
     let { name, icon } = this.props.currentTemplate;
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
     let { currentTemplate } = this.props;
 
 
@@ -755,6 +759,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(namespace: string) =>
             setActiveValue={(namespace: string) =>
               this.setState({ selectedNamespace: namespace })
               this.setState({ selectedNamespace: namespace })
             }
             }
+            addButton={true}
             options={this.state.namespaceOptions}
             options={this.state.namespaceOptions}
             width="250px"
             width="250px"
             dropdownWidth="335px"
             dropdownWidth="335px"

+ 14 - 8
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -6,6 +6,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
 
 
 import hardcodedNames from "../hardcodedNameDict";
 import hardcodedNames from "../hardcodedNameDict";
 import SourcePage from "./SourcePage";
 import SourcePage from "./SourcePage";
@@ -163,9 +164,13 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({ saveValuesStatus: "successful" }, () => {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
           // redirect to dashboard
           let dst =
           let dst =
-            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+            this.props.currentTemplate.name === "job"
+              ? "/jobs"
+              : "/applications";
           setTimeout(() => {
           setTimeout(() => {
-            this.props.history.push(dst);
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 500);
           }, 500);
           window.analytics.track("Deployed Add-on", {
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
             name: this.props.currentTemplate.name,
@@ -183,7 +188,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
         this.setState({
           saveValuesStatus: parsedErr,
           saveValuesStatus: parsedErr,
         });
         });
-        setCurrentError(err.response.data.errors[0]);
+        setCurrentError(err);
         window.analytics.track("Failed to Deploy Add-on", {
         window.analytics.track("Failed to Deploy Add-on", {
           name: this.props.currentTemplate.name,
           name: this.props.currentTemplate.name,
           namespace: selectedNamespace,
           namespace: selectedNamespace,
@@ -269,7 +274,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
               }
               }
             )
             )
             .then((res) => {
             .then((res) => {
-              resolve(res.data?.external_url);
+              resolve(res?.data?.external_url);
             })
             })
             .catch((err) => {
             .catch((err) => {
               let parsedErr =
               let parsedErr =
@@ -311,7 +316,6 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .then((res: any) => {
       .then((res: any) => {
         if (sourceType === "repo") {
         if (sourceType === "repo") {
           let env = rawValues["container.env.normal"];
           let env = rawValues["container.env.normal"];
-          console.log(env);
           this.createGHAction(name, selectedNamespace, env);
           this.createGHAction(name, selectedNamespace, env);
         }
         }
         // this.props.setCurrentView('cluster-dashboard');
         // this.props.setCurrentView('cluster-dashboard');
@@ -320,9 +324,11 @@ class LaunchFlow extends Component<PropsType, StateType> {
           setTimeout(() => {
           setTimeout(() => {
             let dst =
             let dst =
               this.props.currentTemplate.name === "job"
               this.props.currentTemplate.name === "job"
-                ? "jobs"
-                : "applications";
-            this.props.history.push(dst);
+                ? "/jobs"
+                : "/applications";
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 1000);
           }, 1000);
         });
         });
       })
       })

+ 11 - 2
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -101,12 +101,17 @@ export default class SettingsPage extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
               return { label: x.metadata.name, value: x.metadata.name };
             }
             }
           );
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
             this.setState({ namespaceOptions });
           }
           }
         }
         }
@@ -253,6 +258,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
             </NamespaceLabel>
             </NamespaceLabel>
             <Selector
             <Selector
               key={"namespace"}
               key={"namespace"}
+              refreshOptions={() => {
+                this.updateNamespaces(this.context.currentCluster.id);
+              }}
+              addButton={true}
               activeValue={selectedNamespace}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}
               options={this.state.namespaceOptions}

+ 11 - 2
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -5,6 +5,7 @@ import { Context } from "shared/Context";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import close from "assets/close.png";
 import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
+import { pushFiltered } from "shared/routing";
 
 
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Helper from "components/values-form/Helper";
@@ -99,7 +100,11 @@ class SourcePage extends Component<PropsType, StateType> {
             Specify the container image you would like to connect to this
             Specify the container image you would like to connect to this
             template.
             template.
             <Highlight
             <Highlight
-              onClick={() => this.props.history.push("integrations/registry")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/registry", [
+                  "project_id",
+                ])
+              }
             >
             >
               Manage Docker registries
               Manage Docker registries
             </Highlight>
             </Highlight>
@@ -145,7 +150,11 @@ class SourcePage extends Component<PropsType, StateType> {
         </CloseButton>
         </CloseButton>
         <Subtitle>
         <Subtitle>
           Provide a repo folder to use as source.
           Provide a repo folder to use as source.
-          <Highlight onClick={() => history.push("integrations/repo")}>
+          <Highlight
+            onClick={() =>
+              pushFiltered(this.props, "/integrations/repo", ["project_id"])
+            }
+          >
             Manage Git repos
             Manage Git repos
           </Highlight>
           </Highlight>
           <Required>*</Required>
           <Required>*</Required>

+ 200 - 0
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -0,0 +1,200 @@
+import React, { Component, useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+const DeleteNamespaceModal = () => {
+  const {
+    currentModalData,
+    currentCluster,
+    currentProject,
+    setCurrentError,
+    setCurrentModal,
+  } = useContext(Context);
+  const [namespaceNameForDelition, setNamespaceNameForDelition] = useState("");
+  const [status, setStatus] = useState<string>(null as string);
+  const deleteNamespace = () => {
+    if (namespaceNameForDelition !== currentModalData.metadata.name) {
+      setStatus("Please insert the name of the namespace to confirm deletion");
+      return;
+    }
+
+    api
+      .deleteNamespace(
+        "<token>",
+        { name: currentModalData.metadata.name, cluster_id: currentCluster.id },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (res.status === 200) {
+          setStatus("successful");
+          setTimeout(() => {
+            setCurrentModal(null, null);
+          }, 1000);
+        }
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <StyledUpdateProjectModal>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+
+      <ModalTitle>Delete Namespace</ModalTitle>
+      <Subtitle>
+        Please insert the name of the namespace to delete it:
+        <DangerText>{" " + currentModalData.metadata.name}</DangerText>
+      </Subtitle>
+
+      <InputWrapper>
+        <DashboardIcon>
+          <i className="material-icons">warning</i>
+        </DashboardIcon>
+        <InputRow
+          type="string"
+          value={namespaceNameForDelition}
+          setValue={(x: string) => setNamespaceNameForDelition(x)}
+          placeholder={currentModalData.metadata.name}
+          width="480px"
+        />
+      </InputWrapper>
+      <Warning highlight={true}>
+        ⚠️ Deleting this namespace will remove all resources attached to this
+        namespace.
+      </Warning>
+      <SaveButton
+        text="Delete Namespace"
+        color="#e62659"
+        onClick={() => deleteNamespace()}
+        status={status}
+      />
+    </StyledUpdateProjectModal>
+  );
+};
+
+export default DeleteNamespaceModal;
+
+const DangerText = styled.span`
+  color: #ed5f85;
+`;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  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 StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 10px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;

+ 0 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -55,7 +55,6 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           envGroups: res?.data?.items as any[],
           envGroups: res?.data?.items as any[],
           loading: false,
           loading: false,
         });
         });
-        console.log(res.data.items);
       })
       })
       .catch((err) => {
       .catch((err) => {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });

+ 208 - 0
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -0,0 +1,208 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+type PropsType = {};
+
+type StateType = {
+  namespaceName: string;
+  status: string | null;
+};
+
+export default class NamespaceModal extends Component<PropsType, StateType> {
+  state = {
+    namespaceName: "",
+    status: null as string | null,
+  };
+
+  isValidName = (namespaceName: string) =>
+    !/(^default$)|(^kube-.*)/.test(namespaceName);
+
+  hasInvalidCharacters = (namespaceName: string) =>
+    !/([a-z0-9]|\-)+/.test(namespaceName);
+
+  createNamespace = () => {
+    if (!this.isValidName(this.state.namespaceName)) {
+      this.setState({
+        status: "The name cannot be default or start with kube-",
+      });
+      return;
+    }
+
+    if (!this.hasInvalidCharacters(this.state.namespaceName)) {
+      this.setState({
+        status: "Only lowercase, numbers or dash (-) are allowed",
+      });
+      return;
+    }
+
+    const namespaceExists = this.context.currentModalData?.find(
+      (namespace: any) => {
+        return namespace?.value === this.state.namespaceName;
+      }
+    );
+
+    if (namespaceExists) {
+      this.setState({
+        status: "Namespace already exist, choose another name",
+      });
+      return;
+    }
+
+    api
+      .createNamespace(
+        "<token>",
+        {
+          name: this.state.namespaceName,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.context.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ status: "successful" }, () => {
+          setTimeout(() => {
+            this.context.setCurrentModal(null, null);
+          }, 1000);
+        });
+      })
+      .catch((err) => {
+        this.setState({ status: "Could not create" });
+      });
+  };
+
+  render() {
+    return (
+      <StyledUpdateProjectModal>
+        <CloseButton
+          onClick={() => {
+            this.context.setCurrentModal(null, null);
+          }}
+        >
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Add Namespace</ModalTitle>
+        <Subtitle>Name</Subtitle>
+
+        <InputWrapper>
+          <DashboardIcon>
+            <i className="material-icons">space_dashboard</i>
+          </DashboardIcon>
+          <InputRow
+            type="string"
+            value={this.state.namespaceName}
+            setValue={(x: string) =>
+              this.setState({ namespaceName: x, status: null })
+            }
+            placeholder="ex: porter-workers"
+            width="480px"
+          />
+        </InputWrapper>
+
+        <SaveButton
+          text="Create Namespace"
+          color="#616FEEcc"
+          onClick={() => this.createNamespace()}
+          status={this.state.status}
+        />
+      </StyledUpdateProjectModal>
+    );
+  }
+}
+
+NamespaceModal.contextType = Context;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  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 StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 4 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -4,6 +4,7 @@ import close from "assets/close.png";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
 
 
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
@@ -51,7 +52,9 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
           this.props.setRefreshClusters(true);
           this.props.setRefreshClusters(true);
           this.setState({ status: "successful", showDeleteOverlay: false });
           this.setState({ status: "successful", showDeleteOverlay: false });
           this.context.setCurrentModal(null, null);
           this.context.setCurrentModal(null, null);
-          this.props.history.push("dashboard?tab=overview");
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "overview",
+          });
           return;
           return;
         }
         }
 
 

+ 13 - 17
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -9,6 +9,7 @@ import Loading from "components/Loading";
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import Heading from "components/values-form/Heading";
+import CopyToClipboard from "components/CopyToClipboard";
 
 
 type PropsType = {};
 type PropsType = {};
 
 
@@ -113,22 +114,13 @@ export default class InviteList extends Component<PropsType, StateType> {
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
   };
   };
 
 
-  copyToClip = (index: number) => {
+  getInviteUrl = (index: number) => {
     let { currentProject } = this.context;
     let { currentProject } = this.context;
-    navigator.clipboard
-      .writeText(
-        `${this.state.isHTTPS ? "https://" : ""}${
-          window.location.host
-        }/api/projects/${currentProject.id}/invites/${
-          this.state.invites[index].token
-        }`
-      )
-      .then(
-        function () {},
-        function () {
-          console.log("couldn't copy link to clipboard");
-        }
-      );
+    return `${this.state.isHTTPS ? "https://" : ""}${
+      window.location.host
+    }/api/projects/${currentProject.id}/invites/${
+      this.state.invites[index].token
+    }`;
   };
   };
 
 
   renderInvitations = () => {
   renderInvitations = () => {
@@ -188,9 +180,13 @@ export default class InviteList extends Component<PropsType, StateType> {
                     }`}
                     }`}
                     placeholder="Unable to retrieve link"
                     placeholder="Unable to retrieve link"
                   />
                   />
-                  <CopyButton onClick={() => this.copyToClip(i)}>
+                  <CopyToClipboard
+                    as={CopyButton}
+                    text={this.getInviteUrl(i)}
+                    onError={() => console.log("Couldn't copy to clipboard")}
+                  >
                     Copy Link
                     Copy Link
-                  </CopyButton>
+                  </CopyToClipboard>
                 </Rower>
                 </Rower>
               </LinkTd>
               </LinkTd>
               <Td isTop={i === 0}>
               <Td isTop={i === 0}>

+ 17 - 9
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 
 import SelectRow from "components/values-form/SelectRow";
 import SelectRow from "components/values-form/SelectRow";
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
@@ -172,9 +173,8 @@ class AWSFormSection extends Component<PropsType, StateType> {
   // Step 1: Create a project
   // Step 1: Create a project
   // TODO: promisify this function
   // TODO: promisify this function
   createProject = (callback?: any) => {
   createProject = (callback?: any) => {
-    console.log("Creating project");
-    let { projectName, handleError } = this.props;
-    let { user, setProjects, setCurrentProject, currentProject } = this.context;
+    let { projectName } = this.props;
+    let { user, setProjects, setCurrentProject } = this.context;
 
 
     api
     api
       .createProject("<token>", { name: projectName }, {})
       .createProject("<token>", { name: projectName }, {})
@@ -261,7 +261,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
           { id: currentProject.id }
           { id: currentProject.id }
         )
         )
       )
       )
-      .then(() => this.props.history.push("dashboard?tab=provisioner"))
+      .then(() =>
+        pushFiltered(this.props, "/dashboard", ["project_id"], {
+          tab: "provisioner",
+        })
+      )
       .catch(this.catchError);
       .catch(this.catchError);
   };
   };
 
 
@@ -278,7 +282,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
       } else if (selectedInfras[0].value === "ecr") {
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project exists, only provision ECR
         // Case: project exists, only provision ECR
         this.provisionECR().then(() =>
         this.provisionECR().then(() =>
-          this.props.history.push("dashboard?tab=provisioner")
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "provisioner",
+          })
         );
         );
       } else {
       } else {
         // Case: project exists, only provision EKS
         // Case: project exists, only provision EKS
@@ -291,9 +297,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
       } else if (selectedInfras[0].value === "ecr") {
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project DNE, only provision ECR
         // Case: project DNE, only provision ECR
         this.createProject(() =>
         this.createProject(() =>
-          this.provisionECR().then(() => {
-            this.props.history.push("dashboard?tab=provisioner");
-          })
+          this.provisionECR().then(() =>
+            pushFiltered(this.props, "/dashboard", ["project_id"], {
+              tab: "provisioner",
+            })
+          )
         );
         );
       } else {
       } else {
         // Case: project DNE, only provision EKS
         // Case: project DNE, only provision EKS

+ 3 - 4
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams } from "shared/routing";
 
 
 import InputRow from "components/values-form/InputRow";
 import InputRow from "components/values-form/InputRow";
 import CheckboxRow from "components/values-form/CheckboxRow";
 import CheckboxRow from "components/values-form/CheckboxRow";
@@ -133,7 +134,6 @@ export default class DOFormSection extends Component<PropsType, StateType> {
 
 
   // Step 1: Create a project
   // Step 1: Create a project
   createProject = (callback?: any) => {
   createProject = (callback?: any) => {
-    console.log("Creating project");
     let { projectName } = this.props;
     let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
     let { user, setProjects, setCurrentProject } = this.context;
 
 
@@ -152,8 +152,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           }
           }
         );
         );
         setProjects(res_1.data);
         setProjects(res_1.data);
-        setCurrentProject(proj);
-        callback && callback(proj.id);
+        setCurrentProject(proj, () => callback && callback(proj.id));
       })
       })
       .catch(this.catchError);
       .catch(this.catchError);
   };
   };

+ 7 - 3
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -5,6 +5,7 @@ import api from "shared/api";
 import { ProjectType } from "shared/types";
 import { ProjectType } from "shared/types";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
@@ -45,8 +46,11 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
             let proj = res.data.find((el: ProjectType) => {
             let proj = res.data.find((el: ProjectType) => {
               return el.name === projectName;
               return el.name === projectName;
             });
             });
-            setCurrentProject(proj);
-            this.props.history.push("dashboard?tab=overview");
+            setCurrentProject(proj, () =>
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "overview",
+              })
+            );
           }
           }
         }
         }
       })
       })
@@ -94,7 +98,7 @@ const Placeholder = styled.div`
   background: #26282f;
   background: #26282f;
   margin-bottom: 27px;
   margin-bottom: 27px;
   border-radius: 5px;
   border-radius: 5px;
-  height: 170px;
+  height: 230px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;

+ 12 - 11
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 
 import UploadArea from "components/values-form/UploadArea";
 import UploadArea from "components/values-form/UploadArea";
 import SelectRow from "components/values-form/SelectRow";
 import SelectRow from "components/values-form/SelectRow";
@@ -164,8 +165,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
 
   // Step 1: Create a project
   // Step 1: Create a project
   createProject = (callback?: any) => {
   createProject = (callback?: any) => {
-    console.log("Creating project");
-    let { projectName, handleError } = this.props;
+    let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
     let { user, setProjects, setCurrentProject } = this.context;
 
 
     api
     api
@@ -185,8 +185,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
           )
           )
           .then((res) => {
           .then((res) => {
             setProjects(res.data);
             setProjects(res.data);
-            setCurrentProject(proj);
-            callback && callback();
+            setCurrentProject(proj, () => callback && callback());
           })
           })
           .catch(this.catchError);
           .catch(this.catchError);
       })
       })
@@ -222,9 +221,11 @@ class GCPFormSection extends Component<PropsType, StateType> {
         },
         },
         { project_id: currentProject.id }
         { project_id: currentProject.id }
       )
       )
-      .then((res) => {
-        this.props.history.push("dashboard?tab=provisioner");
-      })
+      .then((res) =>
+        pushFiltered(this.props, "/dashboard", ["project_id"], {
+          tab: "provisioner",
+        })
+      )
       .catch(this.catchError);
       .catch(this.catchError);
   };
   };
 
 
@@ -243,7 +244,6 @@ class GCPFormSection extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         if (res?.data) {
         if (res?.data) {
-          console.log("gcp provisioned with response: ", res.data);
           let { id } = res.data;
           let { id } = res.data;
 
 
           if (selectedInfras.length === 2) {
           if (selectedInfras.length === 2) {
@@ -252,7 +252,9 @@ class GCPFormSection extends Component<PropsType, StateType> {
           } else if (selectedInfras[0].value === "gcr") {
           } else if (selectedInfras[0].value === "gcr") {
             // Case: project exists, only provision GCR
             // Case: project exists, only provision GCR
             this.provisionGCR(id).then(() =>
             this.provisionGCR(id).then(() =>
-              this.props.history.push("dashboard?tab=provisioner")
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "provisioner",
+              })
             );
             );
           } else {
           } else {
             // Case: project exists, only provision GKE
             // Case: project exists, only provision GKE
@@ -319,7 +321,6 @@ class GCPFormSection extends Component<PropsType, StateType> {
   render() {
   render() {
     let { setSelectedProvisioner } = this.props;
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-    console.log("gcpkeydata", gcpKeyData);
     return (
     return (
       <StyledGCPFormSection>
       <StyledGCPFormSection>
         <FormSection>
         <FormSection>

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

@@ -12,6 +12,7 @@ import DOFormSection from "./DOFormSection";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
 import ExistingClusterSection from "./ExistingClusterSection";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   isInNewProject?: boolean;
   isInNewProject?: boolean;
@@ -40,7 +41,7 @@ class NewProject extends Component<PropsType, StateType> {
     setCurrentError(
     setCurrentError(
       "Provisioning failed. Check your credentials and try again."
       "Provisioning failed. Check your credentials and try again."
     );
     );
-    this.props.history.push("dashboard?tab=overview");
+    pushFiltered(this.props, "/dashboard", ["project_id"], { tab: "overview" });
   };
   };
 
 
   renderSelectedProvider = (override?: string) => {
   renderSelectedProvider = (override?: string) => {

+ 32 - 7
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -8,6 +8,7 @@ import { ClusterType } from "shared/types";
 
 
 import Drawer from "./Drawer";
 import Drawer from "./Drawer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   forceCloseDrawer: boolean;
   forceCloseDrawer: boolean;
@@ -38,7 +39,12 @@ class ClusterSection extends Component<PropsType, StateType> {
   };
   };
 
 
   updateClusters = () => {
   updateClusters = () => {
-    let { user, currentProject, setCurrentCluster } = this.context;
+    let {
+      user,
+      currentProject,
+      setCurrentCluster,
+      currentCluster,
+    } = this.context;
 
 
     // TODO: query with selected filter once implemented
     // TODO: query with selected filter once implemented
     api
     api
@@ -55,11 +61,31 @@ class ClusterSection extends Component<PropsType, StateType> {
           let clusters = res.data;
           let clusters = res.data;
           clusters.sort((a: any, b: any) => a.id - b.id);
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
           if (clusters.length > 0) {
+            let queryString = window.location.search;
+            let urlParams = new URLSearchParams(queryString);
+            let paramClusterName = urlParams.get("cluster");
+            let params = this.props.match.params as any;
+            let pathClusterName = params.cluster;
+
+            // Set cluster from URL if in path or params
+            let defaultCluster = null as ClusterType;
+            if (paramClusterName || pathClusterName) {
+              clusters.forEach((cluster: ClusterType) => {
+                if (!defaultCluster) {
+                  if (cluster.name === pathClusterName) {
+                    defaultCluster = cluster;
+                  } else if (cluster.name === paramClusterName) {
+                    defaultCluster = cluster;
+                  }
+                }
+              });
+            }
+
             this.setState({ clusters });
             this.setState({ clusters });
             let saved = JSON.parse(
             let saved = JSON.parse(
               localStorage.getItem(currentProject.id + "-cluster")
               localStorage.getItem(currentProject.id + "-cluster")
             );
             );
-            if (saved && saved !== "null") {
+            if (!defaultCluster && saved && saved !== "null") {
               // Ensures currentCluster isn't prematurely set (causes issues downstream)
               // Ensures currentCluster isn't prematurely set (causes issues downstream)
               let loaded = false;
               let loaded = false;
               for (let i = 0; i < clusters.length; i++) {
               for (let i = 0; i < clusters.length; i++) {
@@ -77,7 +103,7 @@ class ClusterSection extends Component<PropsType, StateType> {
                 setCurrentCluster(clusters[0]);
                 setCurrentCluster(clusters[0]);
               }
               }
             } else {
             } else {
-              setCurrentCluster(clusters[0]);
+              setCurrentCluster(defaultCluster || clusters[0]);
             }
             }
           } else if (
           } else if (
             this.props.currentView !== "provisioner" &&
             this.props.currentView !== "provisioner" &&
@@ -85,7 +111,6 @@ class ClusterSection extends Component<PropsType, StateType> {
           ) {
           ) {
             this.setState({ clusters: [] });
             this.setState({ clusters: [] });
             setCurrentCluster(null);
             setCurrentCluster(null);
-            // this.props.history.push("dashboard?tab=overview");
           }
           }
         }
         }
       })
       })
@@ -148,7 +173,7 @@ class ClusterSection extends Component<PropsType, StateType> {
       return (
       return (
         <ClusterSelector isSelected={false}>
         <ClusterSelector isSelected={false}>
           <LinkWrapper
           <LinkWrapper
-            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
+            onClick={() => pushFiltered(this.props, "/cluster-dashboard", [])}
           >
           >
             <ClusterIcon>
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
               <i className="material-icons">device_hub</i>
@@ -251,7 +276,7 @@ const ClusterName = styled.div`
   width: 130px;
   width: 130px;
   margin-left: 3px;
   margin-left: 3px;
   font-weight: 400;
   font-weight: 400;
-  color: #ffffff44;
+  color: #ffffff;
 `;
 `;
 
 
 const DropdownIcon = styled.span`
 const DropdownIcon = styled.span`
@@ -295,7 +320,7 @@ const ClusterIcon = styled.div`
     margin-bottom: 0px;
     margin-bottom: 0px;
     margin-left: 17px;
     margin-left: 17px;
     margin-right: 10px;
     margin-right: 10px;
-    color: #ffffff44;
+    color: #ffffff;
   }
   }
 `;
 `;
 
 

+ 6 - 2
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -5,6 +5,7 @@ import close from "assets/close.png";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 import { ClusterType } from "shared/types";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   toggleDrawer: () => void;
   toggleDrawer: () => void;
@@ -33,8 +34,11 @@ class Drawer extends Component<PropsType, StateType> {
             key={i}
             key={i}
             active={cluster.name === currentCluster.name}
             active={cluster.name === currentCluster.name}
             onClick={() => {
             onClick={() => {
-              setCurrentCluster(cluster);
-              this.props.history.push("applications");
+              setCurrentCluster(cluster, () => {
+                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              });
             }}
             }}
           >
           >
             <ClusterIcon>
             <ClusterIcon>

+ 10 - 7
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -4,6 +4,7 @@ import gradient from "assets/gradient.png";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ProjectType } from "shared/types";
 import { ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
@@ -45,7 +46,6 @@ class ProjectSection extends Component<PropsType, StateType> {
 
 
   renderOptionList = () => {
   renderOptionList = () => {
     let { setCurrentProject } = this.context;
     let { setCurrentProject } = this.context;
-
     return this.props.projects.map((project: ProjectType, i: number) => {
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
       return (
         <Option
         <Option
@@ -53,8 +53,9 @@ class ProjectSection extends Component<PropsType, StateType> {
           selected={project.name === this.props.currentProject.name}
           selected={project.name === this.props.currentProject.name}
           onClick={() => {
           onClick={() => {
             this.setState({ expanded: false });
             this.setState({ expanded: false });
-            setCurrentProject(project);
-            this.props.history.push("dashboard");
+            setCurrentProject(project, () =>
+              pushFiltered(this.props, "/dashboard", ["project_id"])
+            );
           }}
           }}
         >
         >
           <ProjectIcon>
           <ProjectIcon>
@@ -76,9 +77,9 @@ class ProjectSection extends Component<PropsType, StateType> {
             <Option
             <Option
               selected={false}
               selected={false}
               lastItem={true}
               lastItem={true}
-              onClick={() => {
-                this.props.history.push("new-project");
-              }}
+              onClick={() =>
+                pushFiltered(this.props, "/new-project", ["project_id"])
+              }
             >
             >
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectLabel>Create a Project</ProjectLabel>
               <ProjectLabel>Create a Project</ProjectLabel>
@@ -114,7 +115,9 @@ class ProjectSection extends Component<PropsType, StateType> {
       );
       );
     }
     }
     return (
     return (
-      <InitializeButton onClick={() => this.props.history.push("new-project")}>
+      <InitializeButton
+        onClick={() => pushFiltered(this.props, "new-project", ["project_id"])}
+      >
         <Plus>+</Plus> Create a Project
         <Plus>+</Plus> Create a Project
       </InitializeButton>
       </InitializeButton>
     );
     );

+ 82 - 13
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -15,6 +15,7 @@ import ClusterSection from "./ClusterSection";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered, pushQueryParams } from "shared/routing";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   forceSidebar: boolean;
   forceSidebar: boolean;
@@ -106,7 +107,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
           <NavButton
             selected={currentView === "applications"}
             selected={currentView === "applications"}
             onClick={() => {
             onClick={() => {
-              this.props.history.push("/applications");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/applications",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/applications",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
             }}
           >
           >
             <Img src={monoweb} />
             <Img src={monoweb} />
@@ -115,7 +139,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
           <NavButton
             selected={currentView === "jobs"}
             selected={currentView === "jobs"}
             onClick={() => {
             onClick={() => {
-              this.props.history.push("/jobs");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/jobs",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/jobs",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
             }}
           >
           >
             <Img src={monojob} />
             <Img src={monojob} />
@@ -124,7 +171,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
           <NavButton
             selected={currentView === "env-groups"}
             selected={currentView === "env-groups"}
             onClick={() => {
             onClick={() => {
-              this.props.history.push("/env-groups");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/env-groups",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/env-groups",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
             }}
           >
           >
             <Img src={sliders} />
             <Img src={sliders} />
@@ -136,7 +206,7 @@ class Sidebar extends Component<PropsType, StateType> {
   };
   };
 
 
   renderProjectContents = () => {
   renderProjectContents = () => {
-    let { currentView } = this.props;
+    let { currentView, history, location } = this.props;
     let { currentProject, setCurrentModal } = this.context;
     let { currentProject, setCurrentModal } = this.context;
     if (currentProject) {
     if (currentProject) {
       return (
       return (
@@ -145,7 +215,7 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
           <NavButton
             onClick={() =>
             onClick={() =>
               currentView !== "provisioner" &&
               currentView !== "provisioner" &&
-              this.props.history.push("/dashboard?tab=overview")
+              pushFiltered(this.props, "/dashboard", ["project_id"])
             }
             }
             selected={
             selected={
               currentView === "dashboard" || currentView === "provisioner"
               currentView === "dashboard" || currentView === "provisioner"
@@ -155,7 +225,7 @@ class Sidebar extends Component<PropsType, StateType> {
             Dashboard
             Dashboard
           </NavButton>
           </NavButton>
           <NavButton
           <NavButton
-            onClick={() => this.props.history.push("/launch")}
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
             selected={currentView === "launch"}
             selected={currentView === "launch"}
           >
           >
             <Img src={rocket} />
             <Img src={rocket} />
@@ -163,12 +233,9 @@ class Sidebar extends Component<PropsType, StateType> {
           </NavButton>
           </NavButton>
           <NavButton
           <NavButton
             selected={currentView === "integrations"}
             selected={currentView === "integrations"}
-            onClick={() => {
-              this.props.history.push("/integrations");
-            }}
-            // onClick={() => {
-            //   setCurrentModal("IntegrationsInstructionsModal", {});
-            // }}
+            onClick={() =>
+              pushFiltered(this.props, "/integrations", ["project_id"])
+            }
           >
           >
             <Img src={integrations} />
             <Img src={integrations} />
             Integrations
             Integrations
@@ -177,7 +244,9 @@ class Sidebar extends Component<PropsType, StateType> {
             return obj.user_id === this.context.user.userId;
             return obj.user_id === this.context.user.userId;
           })[0].kind === "admin" && (
           })[0].kind === "admin" && (
             <NavButton
             <NavButton
-              onClick={() => this.props.history.push("/project-settings")}
+              onClick={() =>
+                pushFiltered(this.props, "/project-settings", ["project_id"])
+              }
               selected={this.props.currentView === "project-settings"}
               selected={this.props.currentView === "project-settings"}
             >
             >
               <Img enlarge={true} src={settings} />
               <Img enlarge={true} src={settings} />

+ 51 - 10
dashboard/src/shared/Context.tsx

@@ -7,11 +7,44 @@ import {
   ContextProps,
   ContextProps,
 } from "shared/types";
 } from "shared/types";
 
 
+import { pushQueryParams } from "shared/routing";
+
 const Context = React.createContext<Partial<ContextProps>>(null);
 const Context = React.createContext<Partial<ContextProps>>(null);
 
 
 const { Provider } = Context;
 const { Provider } = Context;
 const ContextConsumer = Context.Consumer;
 const ContextConsumer = Context.Consumer;
 
 
+type PropsType = {
+  history: any;
+  location: any;
+};
+
+type StateType = GlobalContextType;
+
+export interface GlobalContextType {
+  currentModal: string;
+  currentModalData: any;
+  setCurrentModal: (currentModal: string, currentModalData?: any) => void;
+  currentError: string | null;
+  setCurrentError: (currentError: string) => void;
+  currentCluster: ClusterType;
+  setCurrentCluster: (currentCluster: ClusterType, callback?: any) => void;
+  currentProject: ProjectType | null;
+  setCurrentProject: (
+    currentProject: ProjectType,
+    callback?: () => void
+  ) => void;
+  projects: ProjectType[];
+  setProjects: (projects: ProjectType[]) => void;
+  user: any;
+  setUser: (userId: number, email: string) => void;
+  devOpsMode: boolean;
+  setDevOpsMode: (devOpsMode: boolean) => void;
+  capabilities: CapabilityType;
+  setCapabilities: (capabilities: CapabilityType) => void;
+  clearContext: () => void;
+}
+
 /**
 /**
  * Component managing a universal (application-wide) data store.
  * Component managing a universal (application-wide) data store.
  *
  *
@@ -23,18 +56,25 @@ const ContextConsumer = Context.Consumer;
  *    components consuming Context)
  *    components consuming Context)
  * 4) As a rule of thumb, Context should not be used for UI-related state
  * 4) As a rule of thumb, Context should not be used for UI-related state
  */
  */
-class ContextProvider extends Component {
-  state = {
-    currentModal: null as string | null,
-    currentModalData: null as any,
+class ContextProvider extends Component<PropsType, StateType> {
+  state: GlobalContextType = {
+    currentModal: null,
+    currentModalData: null,
     setCurrentModal: (currentModal: string, currentModalData?: any) => {
     setCurrentModal: (currentModal: string, currentModalData?: any) => {
       this.setState({ currentModal, currentModalData });
       this.setState({ currentModal, currentModalData });
     },
     },
-    currentError: null as string | null,
+    currentError: null,
     setCurrentError: (currentError: string) => {
     setCurrentError: (currentError: string) => {
       this.setState({ currentError });
       this.setState({ currentError });
     },
     },
-    currentCluster: null as ClusterType | null,
+    currentCluster: {
+      id: -1,
+      name: "",
+      server: "",
+      service_account_id: -1,
+      infra_id: -1,
+      service: "",
+    },
     setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
     setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
       localStorage.setItem(
       localStorage.setItem(
         this.state.currentProject.id + "-cluster",
         this.state.currentProject.id + "-cluster",
@@ -44,8 +84,9 @@ class ContextProvider extends Component {
         callback && callback();
         callback && callback();
       });
       });
     },
     },
-    currentProject: null as ProjectType | null,
+    currentProject: null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
+      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
       if (currentProject) {
         localStorage.setItem("currentProject", currentProject.id.toString());
         localStorage.setItem("currentProject", currentProject.id.toString());
       } else {
       } else {
@@ -55,12 +96,12 @@ class ContextProvider extends Component {
         callback && callback();
         callback && callback();
       });
       });
     },
     },
-    projects: [] as ProjectType[],
+    projects: [],
     setProjects: (projects: ProjectType[]) => {
     setProjects: (projects: ProjectType[]) => {
       projects.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
       projects.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
       this.setState({ projects });
       this.setState({ projects });
     },
     },
-    user: null as any,
+    user: null,
     setUser: (userId: number, email: string) => {
     setUser: (userId: number, email: string) => {
       this.setState({ user: { userId, email } });
       this.setState({ user: { userId, email } });
     },
     },
@@ -68,7 +109,7 @@ class ContextProvider extends Component {
     setDevOpsMode: (devOpsMode: boolean) => {
     setDevOpsMode: (devOpsMode: boolean) => {
       this.setState({ devOpsMode });
       this.setState({ devOpsMode });
     },
     },
-    capabilities: null as CapabilityType,
+    capabilities: null,
     setCapabilities: (capabilities: CapabilityType) => {
     setCapabilities: (capabilities: CapabilityType) => {
       this.setState({ capabilities });
       this.setState({ capabilities });
     },
     },

+ 69 - 0
dashboard/src/shared/api.tsx

@@ -45,6 +45,20 @@ const createAWSIntegration = baseApi<
   return `/api/projects/${pathParams.id}/integrations/aws`;
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 });
 
 
+const overwriteAWSIntegration = baseApi<
+  {
+    aws_access_key_id: string;
+    aws_secret_access_key: string;
+  },
+  {
+    projectID: number;
+    awsIntegrationID: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.projectID}/integrations/aws/${pathParams.awsIntegrationID}/overwrite?cluster_id=${pathParams.cluster_id}`;
+});
+
 const createDOCR = baseApi<
 const createDOCR = baseApi<
   {
   {
     do_integration_id: number;
     do_integration_id: number;
@@ -403,6 +417,26 @@ const getClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/clusters`;
   return `/api/projects/${pathParams.id}/clusters`;
 });
 });
 
 
+const getCluster = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
+});
+
+const getClusterNodes = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`;
+});
+
 const getGitRepoList = baseApi<
 const getGitRepoList = baseApi<
   {},
   {},
   {
   {
@@ -812,6 +846,35 @@ const deleteConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
 });
 });
 
 
+const createNamespace = baseApi<
+  {
+    name: string;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/create?cluster_id=${cluster_id}`;
+});
+
+const deleteNamespace = baseApi<
+  {
+    name: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  let { id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/delete`;
+});
+
+const deleteJob = baseApi<
+  { cluster_id: number },
+  { name: string; namespace: string; id: number }
+>("DELETE", (pathParams) => {
+  let { id, name, namespace } = pathParams;
+  return `/api/projects/${id}/k8s/jobs/${namespace}/${name}`;
+});
+
 const stopJob = baseApi<
 const stopJob = baseApi<
   {},
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -826,6 +889,7 @@ export default {
   connectECRRegistry,
   connectECRRegistry,
   connectGCRRegistry,
   connectGCRRegistry,
   createAWSIntegration,
   createAWSIntegration,
+  overwriteAWSIntegration,
   createDOCR,
   createDOCR,
   createDOKS,
   createDOKS,
   createEmailVerification,
   createEmailVerification,
@@ -834,6 +898,7 @@ export default {
   createGHAction,
   createGHAction,
   createGKE,
   createGKE,
   createInvite,
   createInvite,
+  createNamespace,
   createPasswordReset,
   createPasswordReset,
   createPasswordResetVerify,
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createPasswordResetFinalize,
@@ -843,6 +908,7 @@ export default {
   deleteConfigMap,
   deleteConfigMap,
   deleteGitRepoIntegration,
   deleteGitRepoIntegration,
   deleteInvite,
   deleteInvite,
+  deleteNamespace,
   deletePod,
   deletePod,
   deleteProject,
   deleteProject,
   deleteRegistryIntegration,
   deleteRegistryIntegration,
@@ -861,6 +927,8 @@ export default {
   getChartControllers,
   getChartControllers,
   getClusterIntegrations,
   getClusterIntegrations,
   getClusters,
   getClusters,
+  getCluster,
+  getClusterNodes,
   getConfigMap,
   getConfigMap,
   getGitRepoList,
   getGitRepoList,
   getGitRepos,
   getGitRepos,
@@ -903,5 +971,6 @@ export default {
   updateUser,
   updateUser,
   updateConfigMap,
   updateConfigMap,
   upgradeChartValues,
   upgradeChartValues,
+  deleteJob,
   stopJob,
   stopJob,
 };
 };

+ 43 - 8
dashboard/src/shared/routing.tsx

@@ -23,15 +23,50 @@ export const PorterUrls = [
   "jobs",
   "jobs",
 ];
 ];
 
 
-export const setSearchParam = (
-  location: Location<any>,
-  key: string,
-  value: string
-) => {
+// TODO: consolidate with pushFiltered
+export const pushQueryParams = (props: any, params: any) => {
+  let { location, history } = props;
   const urlParams = new URLSearchParams(location.search);
   const urlParams = new URLSearchParams(location.search);
-  urlParams.set(key, value);
-  return {
+  Object.keys(params)?.forEach((key: string) => {
+    params[key] && urlParams.set(key, params[key]);
+  });
+  history.push({
     pathname: location.pathname,
     pathname: location.pathname,
     search: urlParams.toString(),
     search: urlParams.toString(),
-  };
+  });
+};
+
+export const pushFiltered = (
+  props: any, // Props for retrieving history and location
+  pathname: string, // Path to redirect to
+  keys: string[], // Query params to preserve during redirect
+  params?: any
+) => {
+  let { location, history } = props;
+  let urlParams = new URLSearchParams(location.search);
+  let newUrlParams = new URLSearchParams("");
+  keys?.forEach((key: string) => {
+    let value = urlParams.get(key);
+    value && newUrlParams.set(key, value);
+  });
+  params &&
+    Object.keys(params)?.forEach((key: string) => {
+      params[key] && newUrlParams.set(key, params[key]);
+    });
+  history.push({
+    pathname,
+    search: newUrlParams.toString(),
+  });
+};
+
+export const getQueryParams = (props: any) => {
+  const searchParams = props.location.search;
+  if (searchParams) {
+    return new URLSearchParams(searchParams);
+  }
+};
+
+export const getQueryParam = (props: any, paramName: string) => {
+  const searchParams = getQueryParams(props);
+  return searchParams?.get(paramName);
 };
 };

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

@@ -5,6 +5,17 @@ export interface ClusterType {
   service_account_id: number;
   service_account_id: number;
   infra_id?: number;
   infra_id?: number;
   service?: string;
   service?: string;
+  aws_integration_id?: number;
+}
+
+export interface DetailedClusterType extends ClusterType {
+  ingress_ip?: string;
+  ingress_error?: DetailedIngressError;
+}
+
+export interface DetailedIngressError {
+  message: string;
+  error: string;
 }
 }
 
 
 export interface ChartType {
 export interface ChartType {

+ 2 - 2
docs/deploy/applications/deploying-from-docker-registry.md

@@ -21,6 +21,6 @@ Let's get started!
 
 
 5. To programmatically redeploy your service (for instance, from a CI pipeline), you will need to call your service's custom webhook. You can find your webhook by expanding your deployed service and going to the **Settings** tab.
 5. To programmatically redeploy your service (for instance, from a CI pipeline), you will need to call your service's custom webhook. You can find your webhook by expanding your deployed service and going to the **Settings** tab.
 
 
-![Webhook](https://files.readme.io/23e217a-Screen_Shot_2021-03-18_at_11.29.16_AM.png "Screen Shot 2021-03-18 at 11.29.16 AM.png")
+![Webhook](https://user-images.githubusercontent.com/11699655/120046959-ac25c480-c013-11eb-8b2f-e6bfd704d7fc.png "webhook in the settings tab")
 
 
-Make sure to replace the `YOUR_COMMIT_HASH` and `IMAGE_REPOSITORY_URL` fields in the generated webhook.
+Make sure to replace the `YOUR_COMMIT_HASH` field with the tag of your Docker image.

+ 27 - 0
docs/guides/advanced-nginx-settings.md

@@ -0,0 +1,27 @@
+> 🚧
+>
+> **Note:** these advanced configuration will only work if you've set up a [custom domain](https://docs.getporter.dev/docs/https-and-custom-domains). They will not work on `*.porter.run` domains. 
+
+Every cluster provisioned by Porter by default uses an NGINX [ingress controller](https://kubernetes.github.io/ingress-nginx) to connect your web applications to the internet. There are different options for customizing the NGINX configuration that a specific application uses. 
+
+Most of the time, you can customize the NGINX configuration by adding an "Ingress Annotation" when deploying a web service. This can be found in the "Advanced" tab of the web template:
+
+![Advanced tab ingress](https://files.readme.io/fcfa8a2-Screen_Shot_2021-06-02_at_5.15.26_PM.png "Screen Shot 2021-06-02 at 5.15.26 PM.png")
+
+To add an annotation, simply click "Add row" and add key-value pairs for the annotation. 
+
+> 📘
+>
+> **Note:** this document attempts to cover the most common use-cases. To view the full list of annotations, [go here](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations).
+
+# NGINX Settings Options
+
+## Client Max Body Size 
+
+If you are getting undesired `413 Request Entity Too Large` errors, you can increase the maximum size of the client request by setting the field [client_max_body_size](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size). You can do this by adding the following annotation:
+
+```yaml
+nginx.ingress.kubernetes.io/proxy-body-size: 8m
+```
+
+This will set the maximum client request body size to 8 megabytes. [Read more about NGINX units](http://nginx.org/en/docs/syntax.html).

+ 1 - 0
go.sum

@@ -1686,6 +1686,7 @@ k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8=
 k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
 k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
+k8s.io/apimachinery v0.21.1 h1:Q6XuHGlj2xc+hlMCvqyYfbv3H7SRGn2c8NycxJquDVs=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=

+ 9 - 0
internal/forms/integration.go

@@ -66,3 +66,12 @@ func (caf *CreateAWSIntegrationForm) ToAWSIntegration() (*ints.AWSIntegration, e
 		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
 		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
 	}, nil
 	}, nil
 }
 }
+
+// OverwriteAWSIntegrationForm represents the accepted values for overwriting an
+// AWS Integration
+type OverwriteAWSIntegrationForm struct {
+	UserID             uint   `json:"user_id" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}

+ 4 - 0
internal/forms/k8s.go

@@ -44,3 +44,7 @@ type ConfigMapForm struct {
 	EnvVariables       map[string]string `json:"variables"`
 	EnvVariables       map[string]string `json:"variables"`
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 }
 }
+
+type NamespaceForm struct {
+	Name string `json:"name" form:"required"`
+}

+ 1002 - 958
internal/kubernetes/agent.go

@@ -1,958 +1,1002 @@
-package kubernetes
-
-import (
-	"bufio"
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"strings"
-
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
-	"github.com/porter-dev/porter/internal/oauth"
-	"github.com/porter-dev/porter/internal/registry"
-	"github.com/porter-dev/porter/internal/repository"
-	"golang.org/x/oauth2"
-
-	"github.com/gorilla/websocket"
-	"github.com/porter-dev/porter/internal/helm/grapher"
-	appsv1 "k8s.io/api/apps/v1"
-	batchv1 "k8s.io/api/batch/v1"
-	batchv1beta1 "k8s.io/api/batch/v1beta1"
-	v1 "k8s.io/api/core/v1"
-	v1beta1 "k8s.io/api/extensions/v1beta1"
-	"k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"k8s.io/apimachinery/pkg/types"
-	"k8s.io/cli-runtime/pkg/genericclioptions"
-	"k8s.io/client-go/informers"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/rest"
-	"k8s.io/client-go/tools/cache"
-	"k8s.io/client-go/tools/remotecommand"
-
-	"github.com/porter-dev/porter/internal/config"
-)
-
-// Agent is a Kubernetes agent for performing operations that interact with the
-// api server
-type Agent struct {
-	RESTClientGetter genericclioptions.RESTClientGetter
-	Clientset        kubernetes.Interface
-}
-
-type Message struct {
-	EventType string `json:"event_type"`
-	Object    interface{}
-	Kind      string
-}
-
-type ListOptions struct {
-	FieldSelector string
-}
-
-// CreateConfigMap creates the configmap given the key-value pairs and namespace
-func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
-	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
-		context.TODO(),
-		&v1.ConfigMap{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      name,
-				Namespace: namespace,
-				Labels: map[string]string{
-					"porter": "true",
-				},
-			},
-			Data: configMap,
-		},
-		metav1.CreateOptions{},
-	)
-}
-
-// CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
-// base64 encoded
-func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
-	return a.Clientset.CoreV1().Secrets(namespace).Create(
-		context.TODO(),
-		&v1.Secret{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      name,
-				Namespace: namespace,
-				Labels: map[string]string{
-					"porter":    "true",
-					"configmap": cmName,
-				},
-			},
-			Data: data,
-		},
-		metav1.CreateOptions{},
-	)
-}
-
-type mergeConfigMapData struct {
-	Data map[string]*string `json:"data"`
-}
-
-// UpdateConfigMap updates the configmap given its name and namespace
-func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) error {
-	cmData := make(map[string]*string)
-
-	for key, val := range configMap {
-		valCopy := val
-		cmData[key] = &valCopy
-
-		if len(val) == 0 {
-			cmData[key] = nil
-		}
-	}
-
-	mergeCM := &mergeConfigMapData{
-		Data: cmData,
-	}
-
-	patchBytes, err := json.Marshal(mergeCM)
-
-	if err != nil {
-		return err
-	}
-
-	_, err = a.Clientset.CoreV1().ConfigMaps(namespace).Patch(
-		context.Background(),
-		name,
-		types.MergePatchType,
-		patchBytes,
-		metav1.PatchOptions{},
-	)
-
-	return err
-}
-
-type mergeLinkedSecretData struct {
-	Data map[string]*[]byte `json:"data"`
-}
-
-// UpdateLinkedSecret updates the secret given its name and namespace
-func (a *Agent) UpdateLinkedSecret(name, namespace, cmName string, data map[string][]byte) error {
-	secretData := make(map[string]*[]byte)
-
-	for key, val := range data {
-		valCopy := val
-		secretData[key] = &valCopy
-
-		if len(val) == 0 {
-			secretData[key] = nil
-		}
-	}
-
-	mergeSecret := &mergeLinkedSecretData{
-		Data: secretData,
-	}
-
-	patchBytes, err := json.Marshal(mergeSecret)
-
-	if err != nil {
-		return err
-	}
-
-	_, err = a.Clientset.CoreV1().Secrets(namespace).Patch(
-		context.TODO(),
-		name,
-		types.MergePatchType,
-		patchBytes,
-		metav1.PatchOptions{},
-	)
-
-	return err
-}
-
-// DeleteConfigMap deletes the configmap given its name and namespace
-func (a *Agent) DeleteConfigMap(name string, namespace string) error {
-	return a.Clientset.CoreV1().ConfigMaps(namespace).Delete(
-		context.TODO(),
-		name,
-		metav1.DeleteOptions{},
-	)
-}
-
-// DeleteLinkedSecret deletes the secret given its name and namespace
-func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
-	return a.Clientset.CoreV1().Secrets(namespace).Delete(
-		context.TODO(),
-		name,
-		metav1.DeleteOptions{},
-	)
-}
-
-// GetConfigMap retrieves the configmap given its name and namespace
-func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
-	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(
-		context.TODO(),
-		name,
-		metav1.GetOptions{},
-	)
-}
-
-// ListConfigMaps simply lists namespaces
-func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
-	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: "porter=true",
-		},
-	)
-}
-
-// ListEvents lists the events of a given object.
-func (a *Agent) ListEvents(name string, namespace string) (*v1.EventList, error) {
-	return a.Clientset.CoreV1().Events(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
-		},
-	)
-}
-
-// ListNamespaces simply lists namespaces
-func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
-	return a.Clientset.CoreV1().Namespaces().List(
-		context.TODO(),
-		metav1.ListOptions{},
-	)
-}
-
-// ListJobsByLabel lists jobs in a namespace matching a label
-type Label struct {
-	Key string
-	Val string
-}
-
-func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Job, error) {
-	selectors := make([]string, 0)
-
-	for _, label := range labels {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", label.Key, label.Val))
-	}
-
-	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: strings.Join(selectors, ","),
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Items, nil
-}
-
-// GetJobPods lists all pods belonging to a job in a namespace
-func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
-	resp, err := a.Clientset.CoreV1().Pods(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: fmt.Sprintf("%s=%s", "job-name", jobName),
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Items, nil
-}
-
-// GetIngress gets ingress given the name and namespace
-func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
-	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
-		context.TODO(),
-		name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetDeployment gets the deployment given the name and namespace
-func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
-	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetStatefulSet gets the statefulset given the name and namespace
-func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
-	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetReplicaSet gets the replicaset given the name and namespace
-func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
-	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetDaemonSet gets the daemonset by name and namespace
-func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
-	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetJob gets the job by name and namespace
-func (a *Agent) GetJob(c grapher.Object) (*batchv1.Job, error) {
-	return a.Clientset.BatchV1().Jobs(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetCronJob gets the CronJob by name and namespace
-func (a *Agent) GetCronJob(c grapher.Object) (*batchv1beta1.CronJob, error) {
-	return a.Clientset.BatchV1beta1().CronJobs(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetPodsByLabel retrieves pods with matching labels
-func (a *Agent) GetPodsByLabel(selector string, namespace string) (*v1.PodList, error) {
-	// Search in all namespaces for matching pods
-	return a.Clientset.CoreV1().Pods(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: selector,
-		},
-	)
-}
-
-// DeletePod deletes a pod by name and namespace
-func (a *Agent) DeletePod(namespace string, name string) error {
-	return a.Clientset.CoreV1().Pods(namespace).Delete(
-		context.TODO(),
-		name,
-		metav1.DeleteOptions{},
-	)
-}
-
-// GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
-	// get the pod to read in the list of contains
-	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
-		context.Background(),
-		name,
-		metav1.GetOptions{},
-	)
-
-	if err != nil {
-		return fmt.Errorf("Cannot get pod %s: %s", name, err.Error())
-	}
-
-	container := pod.Spec.Containers[0].Name
-
-	tails := int64(400)
-
-	// follow logs
-	podLogOpts := v1.PodLogOptions{
-		Follow:    true,
-		TailLines: &tails,
-		Container: container,
-	}
-
-	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
-
-	podLogs, err := req.Stream(context.TODO())
-
-	if err != nil {
-		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
-	}
-	defer podLogs.Close()
-
-	r := bufio.NewReader(podLogs)
-	errorchan := make(chan error)
-
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	go func() {
-		for {
-			select {
-			case <-errorchan:
-				defer close(errorchan)
-				return
-			default:
-			}
-
-			bytes, err := r.ReadBytes('\n')
-			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-			if err != nil {
-				if err != io.EOF {
-					errorchan <- err
-					return
-				}
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	for {
-		select {
-		case err = <-errorchan:
-			return err
-		}
-	}
-}
-
-// StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
-func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
-	jobPods, err := a.GetJobPods(namespace, name)
-
-	if err != nil {
-		return err
-	}
-
-	podName := jobPods[0].ObjectMeta.Name
-
-	restConf, err := a.RESTClientGetter.ToRESTConfig()
-
-	restConf.GroupVersion = &schema.GroupVersion{
-		Group:   "api",
-		Version: "v1",
-	}
-
-	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
-
-	restClient, err := rest.RESTClientFor(restConf)
-
-	if err != nil {
-		return err
-	}
-
-	req := restClient.Post().
-		Resource("pods").
-		Name(podName).
-		Namespace(namespace).
-		SubResource("exec")
-
-	req.Param("command", "./signal.sh")
-	req.Param("container", "sidecar")
-	req.Param("stdin", "true")
-	req.Param("stdout", "false")
-	req.Param("tty", "false")
-
-	exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
-
-	if err != nil {
-		return err
-	}
-
-	return exec.Stream(remotecommand.StreamOptions{
-		Tty:   false,
-		Stdin: strings.NewReader("./signal.sh"),
-	})
-}
-
-// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
-// TODO: Support Jobs
-func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error {
-	factory := informers.NewSharedInformerFactory(
-		a.Clientset,
-		0,
-	)
-
-	var informer cache.SharedInformer
-
-	// Spins up an informer depending on kind. Convert to lowercase for robustness
-	switch strings.ToLower(kind) {
-	case "deployment":
-		informer = factory.Apps().V1().Deployments().Informer()
-	case "statefulset":
-		informer = factory.Apps().V1().StatefulSets().Informer()
-	case "replicaset":
-		informer = factory.Apps().V1().ReplicaSets().Informer()
-	case "daemonset":
-		informer = factory.Apps().V1().DaemonSets().Informer()
-	case "job":
-		informer = factory.Batch().V1().Jobs().Informer()
-	case "cronjob":
-		informer = factory.Batch().V1beta1().CronJobs().Informer()
-	}
-
-	stopper := make(chan struct{})
-	errorchan := make(chan error)
-	defer close(errorchan)
-
-	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
-			msg := Message{
-				EventType: "UPDATE",
-				Object:    newObj,
-				Kind:      strings.ToLower(kind),
-			}
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		AddFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "ADD",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "DELETE",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-	})
-
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
-				defer close(stopper)
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	go informer.Run(stopper)
-
-	for {
-		select {
-		case err := <-errorchan:
-			return err
-		}
-	}
-}
-
-// ProvisionECR spawns a new provisioning pod that creates an ECR instance
-func (a *Agent) ProvisionECR(
-	projectID uint,
-	awsConf *integrations.AWSIntegration,
-	ecrName string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.ECR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		AWS: &aws.Conf{
-			AWSRegion:          awsConf.AWSRegion,
-			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
-			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
-		},
-		ECR: &ecr.Conf{
-			ECRName: ecrName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionEKS spawns a new provisioning pod that creates an EKS instance
-func (a *Agent) ProvisionEKS(
-	projectID uint,
-	awsConf *integrations.AWSIntegration,
-	eksName, machineType string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.EKS,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		AWS: &aws.Conf{
-			AWSRegion:          awsConf.AWSRegion,
-			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
-			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
-		},
-		EKS: &eks.Conf{
-			ClusterName: eksName,
-			MachineType: machineType,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionGCR spawns a new provisioning pod that creates a GCR instance
-func (a *Agent) ProvisionGCR(
-	projectID uint,
-	gcpConf *integrations.GCPIntegration,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.GCR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		GCP: &gcp.Conf{
-			GCPRegion:    gcpConf.GCPRegion,
-			GCPProjectID: gcpConf.GCPProjectID,
-			GCPKeyData:   string(gcpConf.GCPKeyData),
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionGKE spawns a new provisioning pod that creates a GKE instance
-func (a *Agent) ProvisionGKE(
-	projectID uint,
-	gcpConf *integrations.GCPIntegration,
-	gkeName string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.GKE,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		GCP: &gcp.Conf{
-			GCPRegion:    gcpConf.GCPRegion,
-			GCPProjectID: gcpConf.GCPProjectID,
-			GCPKeyData:   string(gcpConf.GCPKeyData),
-		},
-		GKE: &gke.Conf{
-			ClusterName: gkeName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
-func (a *Agent) ProvisionDOCR(
-	projectID uint,
-	doConf *integrations.OAuthIntegration,
-	doAuth *oauth2.Config,
-	repo repository.Repository,
-	docrName, docrSubscriptionTier string,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	// get the token
-	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
-		infra.DOIntegrationID,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
-
-	if err != nil {
-		return nil, err
-	}
-
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.DOCR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		DO: &do.Conf{
-			DOToken: tok,
-		},
-		DOCR: &docr.Conf{
-			DOCRName:             docrName,
-			DOCRSubscriptionTier: docrSubscriptionTier,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
-func (a *Agent) ProvisionDOKS(
-	projectID uint,
-	doConf *integrations.OAuthIntegration,
-	doAuth *oauth2.Config,
-	repo repository.Repository,
-	doRegion, doksClusterName string,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	// get the token
-	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
-		infra.DOIntegrationID,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
-
-	if err != nil {
-		return nil, err
-	}
-
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.DOKS,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		LastApplied:         infra.LastApplied,
-		ProvisionerImageTag: provImageTag,
-		DO: &do.Conf{
-			DOToken: tok,
-		},
-		DOKS: &doks.Conf{
-			DORegion:        doRegion,
-			DOKSClusterName: doksClusterName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionTest spawns a new provisioning pod that tests provisioning
-func (a *Agent) ProvisionTest(
-	projectID uint,
-	infra *models.Infra,
-	repo repository.Repository,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Operation:           operation,
-		Kind:                provisioner.Test,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-func (a *Agent) provision(
-	prov *provisioner.Conf,
-	infra *models.Infra,
-	repo repository.Repository,
-) (*batchv1.Job, error) {
-	prov.Namespace = "default"
-
-	job, err := prov.GetProvisionerJobTemplate()
-
-	if err != nil {
-		return nil, err
-	}
-
-	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
-		context.TODO(),
-		job,
-		metav1.CreateOptions{},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	infra.LastApplied = prov.LastApplied
-	infra, err = repo.Infra.UpdateInfra(infra)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return job, nil
-}
-
-// CreateImagePullSecrets will create the required image pull secrets and
-// return a map from the registry name to the name of the secret.
-func (a *Agent) CreateImagePullSecrets(
-	repo repository.Repository,
-	namespace string,
-	linkedRegs map[string]*models.Registry,
-	doAuth *oauth2.Config,
-) (map[string]string, error) {
-	res := make(map[string]string)
-
-	for key, val := range linkedRegs {
-		_reg := registry.Registry(*val)
-
-		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
-
-		if err != nil {
-			return nil, err
-		}
-
-		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
-
-		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
-			context.TODO(),
-			secretName,
-			metav1.GetOptions{},
-		)
-
-		// if not found, create the secret
-		if err != nil && errors.IsNotFound(err) {
-			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
-				context.TODO(),
-				&v1.Secret{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: secretName,
-					},
-					Data: map[string][]byte{
-						string(v1.DockerConfigJsonKey): data,
-					},
-					Type: v1.SecretTypeDockerConfigJson,
-				},
-				metav1.CreateOptions{},
-			)
-
-			if err != nil {
-				return nil, err
-			}
-
-			// add secret name to the map
-			res[key] = secretName
-
-			continue
-		} else if err != nil {
-			return nil, err
-		}
-
-		// otherwise, check that the secret contains the correct data: if
-		// if doesn't, update it
-		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
-			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
-				context.TODO(),
-				&v1.Secret{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: secretName,
-					},
-					Data: map[string][]byte{
-						string(v1.DockerConfigJsonKey): data,
-					},
-					Type: v1.SecretTypeDockerConfigJson,
-				},
-				metav1.UpdateOptions{},
-			)
-
-			if err != nil {
-				return nil, err
-			}
-		}
-
-		// add secret name to the map
-		res[key] = secretName
-	}
-
-	return res, nil
-}
+package kubernetes
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/registry"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+
+	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/internal/helm/grapher"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	batchv1beta1 "k8s.io/api/batch/v1beta1"
+	v1 "k8s.io/api/core/v1"
+	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/informers"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/remotecommand"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// Agent is a Kubernetes agent for performing operations that interact with the
+// api server
+type Agent struct {
+	RESTClientGetter genericclioptions.RESTClientGetter
+	Clientset        kubernetes.Interface
+}
+
+type Message struct {
+	EventType string `json:"event_type"`
+	Object    interface{}
+	Kind      string
+}
+
+type ListOptions struct {
+	FieldSelector string
+}
+
+// CreateConfigMap creates the configmap given the key-value pairs and namespace
+func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter": "true",
+				},
+			},
+			Data: configMap,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+// CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
+// base64 encoded
+func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
+	return a.Clientset.CoreV1().Secrets(namespace).Create(
+		context.TODO(),
+		&v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter":    "true",
+					"configmap": cmName,
+				},
+			},
+			Data: data,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+type mergeConfigMapData struct {
+	Data map[string]*string `json:"data"`
+}
+
+// UpdateConfigMap updates the configmap given its name and namespace
+func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) error {
+	cmData := make(map[string]*string)
+
+	for key, val := range configMap {
+		valCopy := val
+		cmData[key] = &valCopy
+
+		if len(val) == 0 {
+			cmData[key] = nil
+		}
+	}
+
+	mergeCM := &mergeConfigMapData{
+		Data: cmData,
+	}
+
+	patchBytes, err := json.Marshal(mergeCM)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().ConfigMaps(namespace).Patch(
+		context.Background(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
+type mergeLinkedSecretData struct {
+	Data map[string]*[]byte `json:"data"`
+}
+
+// UpdateLinkedSecret updates the secret given its name and namespace
+func (a *Agent) UpdateLinkedSecret(name, namespace, cmName string, data map[string][]byte) error {
+	secretData := make(map[string]*[]byte)
+
+	for key, val := range data {
+		valCopy := val
+		secretData[key] = &valCopy
+
+		if len(val) == 0 {
+			secretData[key] = nil
+		}
+	}
+
+	mergeSecret := &mergeLinkedSecretData{
+		Data: secretData,
+	}
+
+	patchBytes, err := json.Marshal(mergeSecret)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().Secrets(namespace).Patch(
+		context.TODO(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
+// DeleteConfigMap deletes the configmap given its name and namespace
+func (a *Agent) DeleteConfigMap(name string, namespace string) error {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// DeleteLinkedSecret deletes the secret given its name and namespace
+func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
+	return a.Clientset.CoreV1().Secrets(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetConfigMap retrieves the configmap given its name and namespace
+func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// ListConfigMaps simply lists namespaces
+func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: "porter=true",
+		},
+	)
+}
+
+// ListEvents lists the events of a given object.
+func (a *Agent) ListEvents(name string, namespace string) (*v1.EventList, error) {
+	return a.Clientset.CoreV1().Events(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
+		},
+	)
+}
+
+// ListNamespaces simply lists namespaces
+func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
+	return a.Clientset.CoreV1().Namespaces().List(
+		context.TODO(),
+		metav1.ListOptions{},
+	)
+}
+
+// CreateNamespace creates a namespace with the given name.
+func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	namespace := v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+
+	return a.Clientset.CoreV1().Namespaces().Create(
+		context.TODO(),
+		&namespace,
+		metav1.CreateOptions{},
+	)
+}
+
+// DeleteNamespace deletes the namespace given the name.
+func (a *Agent) DeleteNamespace(name string) error {
+	return a.Clientset.CoreV1().Namespaces().Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// ListJobsByLabel lists jobs in a namespace matching a label
+type Label struct {
+	Key string
+	Val string
+}
+
+func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Job, error) {
+	selectors := make([]string, 0)
+
+	for _, label := range labels {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", label.Key, label.Val))
+	}
+
+	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: strings.Join(selectors, ","),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
+// DeleteJob deletes the job in the given name and namespace.
+func (a *Agent) DeleteJob(name, namespace string) error {
+	return a.Clientset.BatchV1().Jobs(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetJobPods lists all pods belonging to a job in a namespace
+func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
+	resp, err := a.Clientset.CoreV1().Pods(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("%s=%s", "job-name", jobName),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
+// GetIngress gets ingress given the name and namespace
+func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
+	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDeployment gets the deployment given the name and namespace
+func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetStatefulSet gets the statefulset given the name and namespace
+func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
+	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetReplicaSet gets the replicaset given the name and namespace
+func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
+	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDaemonSet gets the daemonset by name and namespace
+func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
+	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetJob gets the job by name and namespace
+func (a *Agent) GetJob(c grapher.Object) (*batchv1.Job, error) {
+	return a.Clientset.BatchV1().Jobs(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetCronJob gets the CronJob by name and namespace
+func (a *Agent) GetCronJob(c grapher.Object) (*batchv1beta1.CronJob, error) {
+	return a.Clientset.BatchV1beta1().CronJobs(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetPodsByLabel retrieves pods with matching labels
+func (a *Agent) GetPodsByLabel(selector string, namespace string) (*v1.PodList, error) {
+	// Search in all namespaces for matching pods
+	return a.Clientset.CoreV1().Pods(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: selector,
+		},
+	)
+}
+
+// DeletePod deletes a pod by name and namespace
+func (a *Agent) DeletePod(namespace string, name string) error {
+	return a.Clientset.CoreV1().Pods(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetPodLogs streams real-time logs from a given pod.
+func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	// get the pod to read in the list of contains
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil {
+		return fmt.Errorf("Cannot get pod %s: %s", name, err.Error())
+	}
+
+	container := pod.Spec.Containers[0].Name
+
+	tails := int64(400)
+
+	// follow logs
+	podLogOpts := v1.PodLogOptions{
+		Follow:    true,
+		TailLines: &tails,
+		Container: container,
+	}
+
+	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(context.TODO())
+
+	if err != nil {
+		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
+	}
+	defer podLogs.Close()
+
+	r := bufio.NewReader(podLogs)
+	errorchan := make(chan error)
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go func() {
+		for {
+			select {
+			case <-errorchan:
+				defer close(errorchan)
+				return
+			default:
+			}
+
+			bytes, err := r.ReadBytes('\n')
+			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+			if err != nil {
+				if err != io.EOF {
+					errorchan <- err
+					return
+				}
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err = <-errorchan:
+			return err
+		}
+	}
+}
+
+// StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
+func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
+	jobPods, err := a.GetJobPods(namespace, name)
+
+	if err != nil {
+		return err
+	}
+
+	podName := jobPods[0].ObjectMeta.Name
+
+	restConf, err := a.RESTClientGetter.ToRESTConfig()
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	req := restClient.Post().
+		Resource("pods").
+		Name(podName).
+		Namespace(namespace).
+		SubResource("exec")
+
+	req.Param("command", "./signal.sh")
+	req.Param("container", "sidecar")
+	req.Param("stdin", "true")
+	req.Param("stdout", "false")
+	req.Param("tty", "false")
+
+	exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
+
+	if err != nil {
+		return err
+	}
+
+	return exec.Stream(remotecommand.StreamOptions{
+		Tty:   false,
+		Stdin: strings.NewReader("./signal.sh"),
+	})
+}
+
+// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
+// TODO: Support Jobs
+func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, selectors string) error {
+	// selectors is an array of max length 1. StreamControllerStatus accepts calls without the selectors argument.
+	// selectors argument is a single string with comma separated key=value pairs. (e.g. "app=porter,porter=true")
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	var informer cache.SharedInformer
+
+	// Spins up an informer depending on kind. Convert to lowercase for robustness
+	switch strings.ToLower(kind) {
+	case "deployment":
+		informer = factory.Apps().V1().Deployments().Informer()
+	case "statefulset":
+		informer = factory.Apps().V1().StatefulSets().Informer()
+	case "replicaset":
+		informer = factory.Apps().V1().ReplicaSets().Informer()
+	case "daemonset":
+		informer = factory.Apps().V1().DaemonSets().Informer()
+	case "job":
+		informer = factory.Batch().V1().Jobs().Informer()
+	case "cronjob":
+		informer = factory.Batch().V1beta1().CronJobs().Informer()
+	case "namespace":
+		informer = factory.Core().V1().Namespaces().Informer()
+	case "pod":
+		informer = factory.Core().V1().Pods().Informer()
+	}
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    newObj,
+				Kind:      strings.ToLower(kind),
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "ADD",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "DELETE",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				defer close(stopper)
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+// ProvisionECR spawns a new provisioning pod that creates an ECR instance
+func (a *Agent) ProvisionECR(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	ecrName string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.ECR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		ECR: &ecr.Conf{
+			ECRName: ecrName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionEKS spawns a new provisioning pod that creates an EKS instance
+func (a *Agent) ProvisionEKS(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	eksName, machineType string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.EKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		EKS: &eks.Conf{
+			ClusterName: eksName,
+			MachineType: machineType,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionGCR spawns a new provisioning pod that creates a GCR instance
+func (a *Agent) ProvisionGCR(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionGKE spawns a new provisioning pod that creates a GKE instance
+func (a *Agent) ProvisionGKE(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	gkeName string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GKE,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+		GKE: &gke.Conf{
+			ClusterName: gkeName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
+func (a *Agent) ProvisionDOCR(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	docrName, docrSubscriptionTier string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOCR: &docr.Conf{
+			DOCRName:             docrName,
+			DOCRSubscriptionTier: docrSubscriptionTier,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
+func (a *Agent) ProvisionDOKS(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	doRegion, doksClusterName string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		LastApplied:         infra.LastApplied,
+		ProvisionerImageTag: provImageTag,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOKS: &doks.Conf{
+			DORegion:        doRegion,
+			DOKSClusterName: doksClusterName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+	infra *models.Infra,
+	repo repository.Repository,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Operation:           operation,
+		Kind:                provisioner.Test,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+func (a *Agent) provision(
+	prov *provisioner.Conf,
+	infra *models.Infra,
+	repo repository.Repository,
+) (*batchv1.Job, error) {
+	prov.Namespace = "default"
+
+	job, err := prov.GetProvisionerJobTemplate()
+
+	if err != nil {
+		return nil, err
+	}
+
+	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+		context.TODO(),
+		job,
+		metav1.CreateOptions{},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	infra.LastApplied = prov.LastApplied
+	infra, err = repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return job, nil
+}
+
+// CreateImagePullSecrets will create the required image pull secrets and
+// return a map from the registry name to the name of the secret.
+func (a *Agent) CreateImagePullSecrets(
+	repo repository.Repository,
+	namespace string,
+	linkedRegs map[string]*models.Registry,
+	doAuth *oauth2.Config,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	for key, val := range linkedRegs {
+		_reg := registry.Registry(*val)
+
+		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
+
+		if err != nil {
+			return nil, err
+		}
+
+		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
+
+		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+			context.TODO(),
+			secretName,
+			metav1.GetOptions{},
+		)
+
+		// if not found, create the secret
+		if err != nil && errors.IsNotFound(err) {
+			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.CreateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			// add secret name to the map
+			res[key] = secretName
+
+			continue
+		} else if err != nil {
+			return nil, err
+		}
+
+		// otherwise, check that the secret contains the correct data: if
+		// if doesn't, update it
+		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
+			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.UpdateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// add secret name to the map
+		res[key] = secretName
+	}
+
+	return res, nil
+}

+ 99 - 0
internal/kubernetes/errors.go

@@ -0,0 +1,99 @@
+package kubernetes
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"os"
+	"syscall"
+
+	k8sErrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+type ErrExternalized struct {
+	error
+	Message string `json:"message"`
+	Details string `json:"error"`
+}
+
+type K8sConnectionError interface {
+	Externalize() *ErrExternalized
+	Error() string
+}
+
+func CatchK8sConnectionError(err error) K8sConnectionError {
+	if uerr, ok := err.(*url.Error); ok {
+		if noerr, ok := uerr.Err.(*net.OpError); ok {
+			if scerr, ok := noerr.Err.(*os.SyscallError); ok {
+				if scerr.Err == syscall.ECONNREFUSED {
+					return &ErrConnection{
+						k8sErr: err,
+					}
+				}
+			}
+		}
+	}
+
+	if k8sErrors.IsTimeout(err) {
+		return &ErrConnection{
+			k8sErr: err,
+		}
+	}
+
+	if k8sErrors.IsUnauthorized(err) || k8sErrors.IsForbidden(err) {
+		return &ErrUnauthorized{
+			k8sErr: err,
+		}
+	}
+
+	return &ErrUnknown{
+		k8sErr: err,
+	}
+}
+
+type ErrUnknown struct {
+	k8sErr error
+}
+
+func (e *ErrUnknown) Error() string {
+	return fmt.Sprintf("Unknown or unhandled error: %s", e.k8sErr.Error())
+}
+
+func (e *ErrUnknown) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Unknown or unhandled error",
+		Details: e.Error(),
+	}
+}
+
+// For ECONNREFUSED and errors.IsTimeout
+type ErrConnection struct {
+	k8sErr error
+}
+
+func (e *ErrConnection) Error() string {
+	return fmt.Sprintf("Could not connect to cluster: %s", e.k8sErr.Error())
+}
+
+func (e *ErrConnection) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Could not connect to cluster",
+		Details: e.Error(),
+	}
+}
+
+// For errors.IsForbidden and errors.IsUnauthorized
+type ErrUnauthorized struct {
+	k8sErr error
+}
+
+func (e *ErrUnauthorized) Error() string {
+	return fmt.Sprintf("Unauthorized: %s", e.k8sErr.Error())
+}
+
+func (e *ErrUnauthorized) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Unauthorized",
+		Details: e.Error(),
+	}
+}

+ 122 - 0
internal/kubernetes/nodes/helpers.go

@@ -0,0 +1,122 @@
+package nodes
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+func getPodsTotalRequestsAndLimits(podList *corev1.PodList) (reqs map[corev1.ResourceName]resource.Quantity, limits map[corev1.ResourceName]resource.Quantity) {
+	reqs, limits = map[corev1.ResourceName]resource.Quantity{}, map[corev1.ResourceName]resource.Quantity{}
+	for _, pod := range podList.Items {
+		podReqs, podLimits := podRequestsAndLimits(&pod)
+		for podReqName, podReqValue := range podReqs {
+			if value, ok := reqs[podReqName]; !ok {
+				reqs[podReqName] = podReqValue.DeepCopy()
+			} else {
+				value.Add(podReqValue)
+				reqs[podReqName] = value
+			}
+		}
+		for podLimitName, podLimitValue := range podLimits {
+			if value, ok := limits[podLimitName]; !ok {
+				limits[podLimitName] = podLimitValue.DeepCopy()
+			} else {
+				value.Add(podLimitValue)
+				limits[podLimitName] = value
+			}
+		}
+	}
+	return
+}
+
+func podRequestsAndLimits(pod *corev1.Pod) (reqs, limits corev1.ResourceList) {
+	reqs, limits = corev1.ResourceList{}, corev1.ResourceList{}
+	for _, container := range pod.Spec.Containers {
+		addResourceList(reqs, container.Resources.Requests)
+		addResourceList(limits, container.Resources.Limits)
+	}
+	// init containers define the minimum of any resource
+	for _, container := range pod.Spec.InitContainers {
+		maxResourceList(reqs, container.Resources.Requests)
+		maxResourceList(limits, container.Resources.Limits)
+	}
+
+	// Add overhead for running a pod to the sum of requests and to non-zero limits:
+	if pod.Spec.Overhead != nil {
+		addResourceList(reqs, pod.Spec.Overhead)
+
+		for name, quantity := range pod.Spec.Overhead {
+			if value, ok := limits[name]; ok && !value.IsZero() {
+				value.Add(quantity)
+				limits[name] = value
+			}
+		}
+	}
+	return
+}
+
+// addResourceList adds the resources in newList to list
+func addResourceList(list, new corev1.ResourceList) {
+	for name, quantity := range new {
+		if value, ok := list[name]; !ok {
+			list[name] = quantity.DeepCopy()
+		} else {
+			value.Add(quantity)
+			list[name] = value
+		}
+	}
+}
+
+// maxResourceList sets list to the greater of list/newList for every resource
+// either list
+func maxResourceList(list, new corev1.ResourceList) {
+	for name, quantity := range new {
+		if value, ok := list[name]; !ok {
+			list[name] = quantity.DeepCopy()
+			continue
+		} else {
+			if quantity.Cmp(value) > 0 {
+				list[name] = quantity.DeepCopy()
+			}
+		}
+	}
+}
+
+// Returns the summatory of resources requested and their limits by a list of pods on a specific node in fraction values.
+func DescribeNodeResource(nodeNonTerminatedPodsList *corev1.PodList, node *corev1.Node) *NodeUsage {
+	allocatable := node.Status.Capacity
+	if len(node.Status.Allocatable) > 0 {
+		allocatable = node.Status.Allocatable
+	}
+
+	reqs, limits := getPodsTotalRequestsAndLimits(nodeNonTerminatedPodsList)
+	cpuReqs, cpuLimits, memoryReqs, memoryLimits, ephemeralstorageReqs, ephemeralstorageLimits :=
+		reqs[corev1.ResourceCPU], limits[corev1.ResourceCPU], reqs[corev1.ResourceMemory], limits[corev1.ResourceMemory], reqs[corev1.ResourceEphemeralStorage], limits[corev1.ResourceEphemeralStorage]
+	fractionCpuReqs := float64(0)
+	fractionCpuLimits := float64(0)
+	if allocatable.Cpu().MilliValue() != 0 {
+		fractionCpuReqs = float64(cpuReqs.MilliValue()) / float64(allocatable.Cpu().MilliValue()) * 100
+		fractionCpuLimits = float64(cpuLimits.MilliValue()) / float64(allocatable.Cpu().MilliValue()) * 100
+	}
+	fractionMemoryReqs := float64(0)
+	fractionMemoryLimits := float64(0)
+	if allocatable.Memory().Value() != 0 {
+		fractionMemoryReqs = float64(memoryReqs.Value()) / float64(allocatable.Memory().Value()) * 100
+		fractionMemoryLimits = float64(memoryLimits.Value()) / float64(allocatable.Memory().Value()) * 100
+	}
+	fractionEphemeralStorageReqs := float64(0)
+	fractionEphemeralStorageLimits := float64(0)
+	if allocatable.StorageEphemeral().Value() != 0 {
+		fractionEphemeralStorageReqs = float64(ephemeralstorageReqs.Value()) / float64(allocatable.StorageEphemeral().Value()) * 100
+		fractionEphemeralStorageLimits = float64(ephemeralstorageLimits.Value()) / float64(allocatable.StorageEphemeral().Value()) * 100
+	}
+
+	return &NodeUsage{
+		fractionCpuReqs,
+		fractionCpuLimits,
+		fractionMemoryReqs,
+		fractionMemoryLimits,
+		fractionEphemeralStorageReqs,
+		fractionEphemeralStorageLimits,
+	}
+}

+ 77 - 0
internal/kubernetes/nodes/nodes.go

@@ -0,0 +1,77 @@
+package nodes
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type NodeUsage struct {
+	fractionCpuReqs                float64
+	fractionCpuLimits              float64
+	fractionMemoryReqs             float64
+	fractionMemoryLimits           float64
+	fractionEphemeralStorageReqs   float64
+	fractionEphemeralStorageLimits float64
+}
+
+type NodeWithUsageData struct {
+	Name                           string             `json:"name"`
+	FractionCpuReqs                float64            `json:"cpu_reqs"`
+	FractionCpuLimits              float64            `json:"cpu_limits"`
+	FractionMemoryReqs             float64            `json:"memory_reqs"`
+	FractionMemoryLimits           float64            `json:"memory_limits"`
+	FractionEphemeralStorageReqs   float64            `json:"ephemeral_storage_reqs"`
+	FractionEphemeralStorageLimits float64            `json:"ephemeral_storage_limits"`
+	Condition                      []v1.NodeCondition `json:"node_conditions"`
+}
+
+func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
+	return &NodeWithUsageData{
+		Name:                           node.Name,
+		FractionCpuReqs:                nu.fractionCpuReqs,
+		FractionCpuLimits:              nu.fractionCpuLimits,
+		FractionMemoryReqs:             nu.fractionMemoryReqs,
+		FractionMemoryLimits:           nu.fractionMemoryLimits,
+		FractionEphemeralStorageReqs:   nu.fractionEphemeralStorageReqs,
+		FractionEphemeralStorageLimits: nu.fractionEphemeralStorageLimits,
+		Condition:                      node.Status.Conditions,
+	}
+}
+
+func GetNodesUsage(clientset kubernetes.Interface) []*NodeWithUsageData {
+	nodeList, _ := clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
+
+	extNodeList := make([]*NodeWithUsageData, len(nodeList.Items))
+	var wg sync.WaitGroup
+	for i := range nodeList.Items {
+		index := i
+		currentNode := &nodeList.Items[index]
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			podList := getPodsForNode(clientset, currentNode.Name)
+			nodeUsage := DescribeNodeResource(podList, currentNode)
+
+			extNodeList[index] = nodeUsage.Externalize(*currentNode)
+		}()
+	}
+	wg.Wait()
+
+	return extNodeList
+}
+
+func getPodsForNode(clientset kubernetes.Interface, nodeName string) *v1.PodList {
+	fmt.Printf("%s", nodeName)
+
+	podList, _ := clientset.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{
+		FieldSelector: "spec.nodeName=" + nodeName + ",status.phase=Running",
+	})
+
+	return podList
+}

+ 29 - 6
internal/models/cluster.go

@@ -86,6 +86,9 @@ type ClusterExternal struct {
 
 
 	// The infra id, if cluster was provisioned with Porter
 	// The infra id, if cluster was provisioned with Porter
 	InfraID uint `json:"infra_id"`
 	InfraID uint `json:"infra_id"`
+
+	// (optional) The aws integration id, if available
+	AWSIntegrationID uint `json:"aws_integration_id"`
 }
 }
 
 
 // Externalize generates an external Cluster to be shared over REST
 // Externalize generates an external Cluster to be shared over REST
@@ -101,12 +104,32 @@ func (c *Cluster) Externalize() *ClusterExternal {
 	}
 	}
 
 
 	return &ClusterExternal{
 	return &ClusterExternal{
-		ID:        c.ID,
-		ProjectID: c.ProjectID,
-		Name:      c.Name,
-		Server:    c.Server,
-		Service:   serv,
-		InfraID:   c.InfraID,
+		ID:               c.ID,
+		ProjectID:        c.ProjectID,
+		Name:             c.Name,
+		Server:           c.Server,
+		Service:          serv,
+		InfraID:          c.InfraID,
+		AWSIntegrationID: c.AWSIntegrationID,
+	}
+}
+
+type ClusterDetailedExternal struct {
+	// Simple cluster external data
+	ClusterExternal
+
+	// The NGINX Ingress IP to access the cluster
+	IngressIP string `json:"ingress_ip"`
+
+	// Error displayed in case couldn't get the IP
+	IngressError error `json:"ingress_error"`
+}
+
+func (c *Cluster) DetailedExternalize() *ClusterDetailedExternal {
+	clusterExt := c.Externalize()
+
+	return &ClusterDetailedExternal{
+		ClusterExternal: *clusterExt,
 	}
 	}
 }
 }
 
 

+ 21 - 10
internal/registry/registry.go

@@ -52,6 +52,9 @@ type Image struct {
 
 
 	// The name of the repository associated with the image.
 	// The name of the repository associated with the image.
 	RepositoryName string `json:"repository_name"`
 	RepositoryName string `json:"repository_name"`
+
+	// When the image was pushed
+	PushedAt *time.Time `json:"pushed_at"`
 }
 }
 
 
 // ListRepositories lists the repositories for a registry
 // ListRepositories lists the repositories for a registry
@@ -479,18 +482,26 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+		RepositoryName: &repoName,
+		ImageIds:       resp.ImageIds,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
 	res := make([]*Image, 0)
 	res := make([]*Image, 0)
 
 
-	for _, img := range resp.ImageIds {
-		if img.ImageTag == nil {
-			continue
+	for _, img := range describeResp.ImageDetails {
+		for _, tag := range img.ImageTags {
+			res = append(res, &Image{
+				Digest:         *img.ImageDigest,
+				Tag:            *tag,
+				RepositoryName: repoName,
+				PushedAt:       img.ImagePushedAt,
+			})
 		}
 		}
-
-		res = append(res, &Image{
-			Digest:         *img.ImageDigest,
-			Tag:            *img.ImageTag,
-			RepositoryName: repoName,
-		})
 	}
 	}
 
 
 	return res, nil
 	return res, nil
@@ -909,4 +920,4 @@ func (r *Registry) getPrivateRegistryDockerConfigFile(
 
 
 func generateAuthToken(username, password string) string {
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
-}
+}

+ 17 - 0
internal/repository/gorm/auth.go

@@ -936,6 +936,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 	return am, nil
 }
 }
 
 
+// UpdateCluster modifies an existing Cluster in the database
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	err := repo.EncryptAWSIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,
 	id uint,

+ 51 - 0
internal/repository/gorm/auth_test.go

@@ -499,6 +499,57 @@ func TestCreateAWSIntegration(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestOverwriteAWSIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_overwrite_aws.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initAWSIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	aws, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	aws.AWSAccessKeyID = []byte("accesskey2")
+	aws.AWSSecretAccessKey = []byte("secret2")
+
+	aws, err = tester.repo.AWSIntegration.OverwriteAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gotAWS, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	expAWS := &ints.AWSIntegration{
+		ProjectID:          tester.initProjects[0].ID,
+		UserID:             tester.initUsers[0].ID,
+		AWSClusterID:       []byte("example-cluster-0"),
+		AWSAccessKeyID:     []byte("accesskey2"),
+		AWSSecretAccessKey: []byte("secret2"),
+		AWSSessionToken:    []byte("optional"),
+	}
+
+	// make sure id is 1
+	if gotAWS.Model.ID != 1 {
+		t.Errorf("incorrect aws integration ID: expected %d, got %d\n", 1, gotAWS.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	gotAWS.Model = orm.Model{}
+
+	if diff := deep.Equal(expAWS, gotAWS); diff != nil {
+		t.Errorf("incorrect aws integration")
+		t.Error(diff)
+	}
+}
+
 func TestListAWSIntegrationsByProjectID(t *testing.T) {
 func TestListAWSIntegrationsByProjectID(t *testing.T) {
 	tester := &tester{
 	tester := &tester{
 		dbFileName: "./porter_list_awss.db",
 		dbFileName: "./porter_list_awss.db",

+ 1 - 0
internal/repository/integrations.go

@@ -41,6 +41,7 @@ type OAuthIntegrationRepository interface {
 // mechanism
 // mechanism
 type AWSIntegrationRepository interface {
 type AWSIntegrationRepository interface {
 	CreateAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
 	CreateAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
+	OverwriteAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
 	ReadAWSIntegration(id uint) (*ints.AWSIntegration, error)
 	ReadAWSIntegration(id uint) (*ints.AWSIntegration, error)
 	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
 	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
 }
 }

+ 17 - 0
internal/repository/memory/auth.go

@@ -311,6 +311,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 	return am, nil
 }
 }
 
 
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(am.ID-1) >= len(repo.awsIntegrations) || repo.awsIntegrations[am.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(am.ID - 1)
+	repo.awsIntegrations[index] = am
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,
 	id uint,

+ 30 - 1
server/api/cluster_handler.go

@@ -7,6 +7,8 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
 
 
@@ -79,7 +81,33 @@ func (app *App) HandleReadProjectCluster(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	clusterExt := cluster.Externalize()
+	clusterExt := cluster.DetailedExternalize()
+
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+			Cluster:           cluster,
+		},
+	}
+
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, _ = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	endpoint, found, ingressErr := domain.GetNGINXIngressServiceIP(agent.Clientset)
+
+	if found {
+		clusterExt.IngressIP = endpoint
+	}
+
+	if !found && ingressErr != nil {
+		clusterExt.IngressError = kubernetes.CatchK8sConnectionError(ingressErr).Externalize()
+	}
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 
 
@@ -107,6 +135,7 @@ func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request
 
 
 	extClusters := make([]*models.ClusterExternal, 0)
 	extClusters := make([]*models.ClusterExternal, 0)
 
 
+
 	for _, cluster := range clusters {
 	for _, cluster := range clusters {
 		extClusters = append(extClusters, cluster.Externalize())
 		extClusters = append(extClusters, cluster.Externalize())
 	}
 	}

+ 101 - 0
server/api/integration_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strconv"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
@@ -186,6 +187,106 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 	}
 	}
 }
 }
 
 
+// HandleOverwriteAWSIntegration overwrites the ID of an AWS integration in the DB
+func (app *App) HandleOverwriteAWSIntegration(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegrationID, err := strconv.ParseUint(chi.URLParam(r, "aws_integration_id"), 0, 64)
+
+	if err != nil || awsIntegrationID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.OverwriteAWSIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// read the aws integration by ID and overwrite the access id/secret
+	awsIntegration, err := app.Repo.AWSIntegration.ReadAWSIntegration(uint(awsIntegrationID))
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegration.AWSAccessKeyID = []byte(form.AWSAccessKeyID)
+	awsIntegration.AWSSecretAccessKey = []byte(form.AWSSecretAccessKey)
+
+	// handle write to the database
+	awsIntegration, err = app.Repo.AWSIntegration.OverwriteAWSIntegration(awsIntegration)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// clear the cluster token cache if cluster_id exists
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if len(vals["cluster_id"]) > 0 {
+		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		cluster, err := app.Repo.Cluster.ReadCluster(uint(clusterID))
+
+		// clear the token
+		cluster.TokenCache.Token = []byte("")
+
+		cluster, err = app.Repo.Cluster.UpdateClusterTokenCache(&cluster.TokenCache)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+	}
+
+	app.Logger.Info().Msgf("AWS integration overwritten: %d", awsIntegration.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	awsExt := awsIntegration.Externalize()
+
+	if err := json.NewEncoder(w).Encode(awsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
 	userID, err := app.getUserIDFromRequest(r)
 	userID, err := app.getUserIDFromRequest(r)

+ 214 - 6
server/api/k8s_handler.go

@@ -5,12 +5,14 @@ import (
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
+	"strconv"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd"
@@ -75,6 +77,116 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
+// HandleCreateNamespace creates a new namespace given the name.
+func (app *App) HandleCreateNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	fmt.Println(vals)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	ns := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(ns); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	namespace, err := agent.CreateNamespace(ns.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteNamespace deletes a namespace given the name.
+func (app *App) HandleDeleteNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	namespace := &forms.NamespaceForm{
+		Name: vals.Get("name"),
+	}
+
+	err = agent.DeleteNamespace(namespace.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleListPodEvents retrieves all events tied to a pod.
 // HandleListPodEvents retrieves all events tied to a pod.
 func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	vals, err := url.ParseQuery(r.URL.RawQuery)
@@ -743,6 +855,54 @@ func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteJob(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	err = agent.DeleteJob(name, namespace)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleStopJob stops a running job
 // HandleStopJob stops a running job
 func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	// get path parameters
@@ -784,7 +944,10 @@ func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	err = agent.StopJobWithJobSidecar(namespace, name)
 	err = agent.StopJobWithJobSidecar(namespace, name)
 
 
 	if err != nil {
 	if err != nil {
-		app.handleErrorInternal(err, w)
+		app.sendExternalError(err, 500, HTTPError{
+			Code:   500,
+			Errors: []string{err.Error()},
+		}, w)
 		return
 		return
 	}
 	}
 
 
@@ -846,16 +1009,15 @@ func (app *App) HandleListJobPods(w http.ResponseWriter, r *http.Request) {
 // HandleStreamControllerStatus test calls
 // HandleStreamControllerStatus test calls
 // TODO: Refactor repeated calls.
 // TODO: Refactor repeated calls.
 func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
-
-	// get session to retrieve correct kubeconfig
-	_, err := app.Store.Get(r, app.ServerConf.CookieName)
+	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 		return
 	}
 	}
 
 
-	vals, err := url.ParseQuery(r.URL.RawQuery)
+	// get session to retrieve correct kubeconfig
+	_, err = app.Store.Get(r, app.ServerConf.CookieName)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -898,7 +1060,12 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 
 
 	// get path parameters
 	// get path parameters
 	kind := chi.URLParam(r, "kind")
 	kind := chi.URLParam(r, "kind")
-	err = agent.StreamControllerStatus(conn, kind)
+
+	selectors := ""
+	if vals["selectors"] != nil {
+		selectors = vals["selectors"][0]
+	} 
+	err = agent.StreamControllerStatus(conn, kind, selectors)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)
 		app.handleErrorWebsocketWrite(err, w)
@@ -1109,3 +1276,44 @@ func (app *App) HandleGetTemporaryKubeconfig(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 }
 }
+
+func (app *App) HandleListNodes(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	cluster, err := app.Repo.Cluster.ReadCluster(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+			Cluster:           cluster,
+		},
+	}
+
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, _ = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	nodeWithUsageList := nodes.GetNodesUsage(agent.Clientset)
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(nodeWithUsageList); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 1 - 1
server/api/oauth_google_handler.go

@@ -145,7 +145,7 @@ func (app *App) upsertGoogleUserFromToken(tok *oauth2.Token) (*models.User, erro
 
 
 	// if the app has a restricted domain, check the `hd` query param
 	// if the app has a restricted domain, check the `hd` query param
 	if app.ServerConf.GoogleRestrictedDomain != "" {
 	if app.ServerConf.GoogleRestrictedDomain != "" {
-		if gInfo.HD != "bloomchat.app" {
+		if gInfo.HD != app.ServerConf.GoogleRestrictedDomain {
 			return nil, fmt.Errorf("Email is not in the restricted domain group.")
 			return nil, fmt.Errorf("Email is not in the restricted domain group.")
 		}
 		}
 	}
 	}

+ 75 - 0
server/router/router.go

@@ -599,6 +599,20 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/nodes",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleListNodes, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects/{project_id}/clusters/{cluster_id}",
 				"/projects/{project_id}/clusters/{cluster_id}",
@@ -679,6 +693,25 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/integrations/aws/{aws_integration_id}/overwrite",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						auth.DoesUserHaveAWSIntegrationAccess(
+							requestlog.NewHandler(a.HandleOverwriteAWSIntegration, l),
+							mw.URLParam,
+							mw.URLParam,
+							false,
+						),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects/{project_id}/integrations/basic",
 				"/projects/{project_id}/integrations/basic",
@@ -1085,6 +1118,34 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/k8s/namespaces/create",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/namespaces/delete",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 			r.Method(
 				"GET",
 				"GET",
 				"/projects/{project_id}/k8s/kubeconfig",
 				"/projects/{project_id}/k8s/kubeconfig",
@@ -1323,6 +1384,20 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/jobs/{namespace}/{name}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteJob, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/stop",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/stop",