Quellcode durchsuchen

Merge branch 'master' into 0.8.0-live-deployment-updates

Ivan Galakhov vor 4 Jahren
Ursprung
Commit
a916a39be8
72 geänderte Dateien mit 2328 neuen und 1384 gelöschten Zeilen
  1. 1 1
      .github/workflows/production.yaml
  2. 1 1
      .github/workflows/release.yaml
  3. 1 1
      cli/cmd/deploy.go
  4. 6 4
      cli/cmd/deploy/deploy.go
  5. 1 0
      cmd/app/main.go
  6. 15 9
      dashboard/package-lock.json
  7. 2 1
      dashboard/package.json
  8. 12 3
      dashboard/src/components/Placeholder.tsx
  9. 5 5
      dashboard/src/components/form-components/CheckboxList.tsx
  10. 46 17
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  11. 4 4
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  12. 22 19
      dashboard/src/components/porter-form/field-components/Input.tsx
  13. 7 10
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  14. 3 6
      dashboard/src/components/porter-form/field-components/Select.tsx
  15. 2 6
      dashboard/src/components/porter-form/types.ts
  16. 5 0
      dashboard/src/components/porter-form/utils.ts
  17. 20 1
      dashboard/src/main/home/Home.tsx
  18. 305 0
      dashboard/src/main/home/WelcomeForm.tsx
  19. 23 13
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  20. 61 0
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  21. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  22. 21 85
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  23. 203 72
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  24. 25 12
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  25. 17 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  26. 136 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  27. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  28. 23 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  29. 14 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  30. 3 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  31. 4 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  32. 1 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  33. 2 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  34. 1 1
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  35. 1 1
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  36. 12 0
      dashboard/src/main/home/navbar/Navbar.tsx
  37. 6 0
      dashboard/src/main/home/new-project/NewProject.tsx
  38. 302 341
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  39. 169 189
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  40. 2 0
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  41. 227 232
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  42. 183 181
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  43. 28 5
      dashboard/src/shared/api.tsx
  44. 83 0
      dashboard/src/shared/baseApi.ts
  45. 0 46
      dashboard/src/shared/baseApi.tsx
  46. 11 0
      dashboard/src/shared/types.tsx
  47. 0 3
      dashboard/webpack.config.js
  48. 3 1
      docker/Dockerfile
  49. 14 1
      docs/deploy/applications/deploying-from-the-cli.md
  50. 3 2
      internal/analytics/track_events.go
  51. 21 0
      internal/analytics/tracks.go
  52. 2 0
      internal/config/config.go
  53. 1 1
      internal/forms/git_action.go
  54. 29 2
      internal/helm/agent.go
  55. 24 1
      internal/helm/loader/loader.go
  56. 33 17
      internal/integrations/ci/actions/actions.go
  57. 16 6
      internal/integrations/ci/actions/steps.go
  58. 2 1
      internal/kubernetes/agent.go
  59. 0 4
      internal/kubernetes/prometheus/metrics.go
  60. 28 11
      internal/oauth/config.go
  61. 22 18
      server/api/api.go
  62. 3 1
      server/api/deploy_handler.go
  63. 5 3
      server/api/git_action_handler.go
  64. 18 3
      server/api/git_repo_handler.go
  65. 7 2
      server/api/k8s_handler.go
  66. 1 0
      server/api/oauth_do_handler.go
  67. 5 4
      server/api/oauth_github_handler.go
  68. 1 0
      server/api/oauth_google_handler.go
  69. 12 10
      server/api/release_handler.go
  70. 8 2
      server/api/user_handler.go
  71. 34 0
      server/api/welcome_handler.go
  72. 18 3
      server/router/router.go

+ 1 - 1
.github/workflows/production.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile --build-arg version=production
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:latest

+ 1 - 1
.github/workflows/release.yaml

@@ -34,7 +34,7 @@ jobs:
           cat ./dashboard/.env
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
       - name: Push
         run: |
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}

+ 1 - 1
cli/cmd/deploy.go

@@ -510,7 +510,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 
 func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
-	color.New(color.FgGreen).Println("Calling webhook for", app)
+	color.New(color.FgGreen).Println("Upgrading configuration for", app)
 
 	if stream {
 		updateAgent.StreamEvent(api.Event{

+ 6 - 4
cli/cmd/deploy/deploy.go

@@ -3,7 +3,6 @@ package deploy
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -279,6 +278,11 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
+	// if this is a job chart, set "paused" to false so that the job doesn't run
+	if d.release.Chart.Name() == "job" {
+		mergedValues["paused"] = true
+	}
+
 	// if the current image section is hello-porter, the image must be overriden
 	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
 		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
@@ -323,10 +327,8 @@ func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error)
 	envConfig, err := getNestedMap(config, "container", "env", "normal")
 
 	// if the field is not found, set envConfig to an empty map; this release has no env set
-	if e := (&NestedMapFieldNotFoundError{}); errors.As(err, &e) {
+	if err != nil {
 		envConfig = make(map[string]interface{})
-	} else if err != nil {
-		return nil, fmt.Errorf("could not get environment variables from release: %s", err.Error())
 	}
 
 	mapEnvConfig := make(map[string]string)

+ 1 - 0
cmd/app/main.go

@@ -59,6 +59,7 @@ func main() {
 	repo := gorm.NewRepository(db, &key)
 
 	a, err := api.New(&api.AppConfig{
+		Version:    Version,
 		Logger:     logger,
 		Repository: repo,
 		ServerConf: appConf.Server,

+ 15 - 9
dashboard/package-lock.json

@@ -3090,11 +3090,12 @@
       "dev": true
     },
     "@types/react": {
-      "version": "16.14.2",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.2.tgz",
-      "integrity": "sha512-BzzcAlyDxXl2nANlabtT4thtvbbnhee8hMmH/CcJrISDBVcJS1iOsP1f0OAgSdGE0MsY9tqcrb9YoZcOFv9dbQ==",
+      "version": "16.14.14",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.14.tgz",
+      "integrity": "sha512-uwIWDYW8LznHzEMJl7ag9St1RsK0gw/xaFZ5+uI1ZM1HndwUgmPH3/wQkSb87GkOVg7shUxnpNW8DcN0AzvG5Q==",
       "requires": {
         "@types/prop-types": "*",
+        "@types/scheduler": "*",
         "csstype": "^3.0.2"
       }
     },
@@ -3162,6 +3163,11 @@
         "@types/react": "*"
       }
     },
+    "@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
+    },
     "@types/semver": {
       "version": "7.3.5",
       "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.5.tgz",
@@ -5638,9 +5644,9 @@
       }
     },
     "dom-helpers": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz",
-      "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
       "requires": {
         "@babel/runtime": "^7.8.7",
         "csstype": "^3.0.2"
@@ -9306,9 +9312,9 @@
       "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
     },
     "react-transition-group": {
-      "version": "4.4.1",
-      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
-      "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
+      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
       "requires": {
         "@babel/runtime": "^7.5.5",
         "dom-helpers": "^5.0.1",

+ 2 - 1
dashboard/package.json

@@ -38,6 +38,7 @@
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",
+    "react-transition-group": "^4.4.2",
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
@@ -68,7 +69,7 @@
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
-    "@types/react": "^16.9.49",
+    "@types/react": "^16.14.14",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",

+ 12 - 3
dashboard/src/components/Placeholder.tsx

@@ -3,18 +3,27 @@ import styled from "styled-components";
 
 interface Props {
   height?: string;
+  minHeight?: string;
   children: React.ReactNode;
 }
 
-const Placeholder: React.FC<Props> = ({ height, children }) => {
-  return <StyledPlaceholder height={height}>{children}</StyledPlaceholder>;
+const Placeholder: React.FC<Props> = ({ height, minHeight, children }) => {
+  return (
+    <StyledPlaceholder height={height} minHeight={minHeight}>
+      {children}
+    </StyledPlaceholder>
+  );
 };
 
 export default Placeholder;
 
-const StyledPlaceholder = styled.div<{ height: string }>`
+const StyledPlaceholder = styled.div<{
+  height: string;
+  minHeight: string;
+}>`
   width: 100%;
   height: ${(props) => props.height || "100px"};
+  minheight: ${(props) => props.minHeight || ""};
   display: flex;
   align-items: center;
   justify-content: center;

+ 5 - 5
dashboard/src/components/form-components/CheckboxList.tsx

@@ -10,12 +10,12 @@ type PropsType = {
 
 const CheckboxList = ({ label, options, selected, setSelected }: PropsType) => {
   let onSelectOption = (option: { value: string; label: string }) => {
-    if (!selected.includes(option)) {
-      selected.push(option);
-      setSelected(selected);
+    const tmp = [...selected];
+    if (!tmp.includes(option)) {
+      setSelected([...tmp, option]);
     } else {
-      selected.splice(selected.indexOf(option), 1);
-      setSelected(selected);
+      tmp.splice(tmp.indexOf(option), 1);
+      setSelected(tmp);
     }
   };
 

+ 46 - 17
dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -6,16 +6,24 @@ import {
   GetFinalVariablesFunction,
 } from "../types";
 import useFormField from "../hooks/useFormField";
+import { hasSetValue } from "../utils";
+
+// this is used to set validation for the below form component in case
+// input validation needs to get more complicated in the future
+const validateArray = (arr: any[]) => {
+  return arr.some((x) => x);
+};
 
 const ArrayInput: React.FC<ArrayInputField> = (props) => {
-  const { state, variables, setVars } = useFormField<ArrayInputFieldState>(
-    props.id,
-    {
+  const { state, variables, setVars, setValidation } =
+    useFormField<ArrayInputFieldState>(props.id, {
       initVars: {
-        [props.variable]: props.value && props.value[0] ? props.value[0] : [],
+        [props.variable]: hasSetValue(props) ? props.value[0] : [],
       },
-    }
-  );
+      initValidation: {
+        validated: validateArray(hasSetValue(props) ? props.value[0] : []),
+      },
+    });
 
   if (state == undefined) return <></>;
 
@@ -25,10 +33,17 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
         <DeleteButton
           onClick={() => {
             setVars((prev) => {
+              const val = prev[props.variable]
+                .slice(0, i)
+                .concat(prev[props.variable].slice(i + 1));
+              setValidation((prev) => {
+                return {
+                  ...prev,
+                  validated: validateArray(val),
+                };
+              });
               return {
-                [props.variable]: prev[props.variable]
-                  .slice(0, i)
-                  .concat(prev[props.variable].slice(i + 1)),
+                [props.variable]: val,
               };
             });
           }}
@@ -52,12 +67,19 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
                 onChange={(e: any) => {
                   e.persist();
                   setVars((prev) => {
+                    const val = prev[props.variable]?.map(
+                      (t: string, j: number) => {
+                        return i == j ? e.target.value : t;
+                      }
+                    );
+                    setValidation((prev) => {
+                      return {
+                        ...prev,
+                        validated: validateArray(val),
+                      };
+                    });
                     return {
-                      [props.variable]: prev[props.variable]?.map(
-                        (t: string, j: number) => {
-                          return i == j ? e.target.value : t;
-                        }
-                      ),
+                      [props.variable]: val,
                     };
                   });
                 }}
@@ -73,7 +95,10 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
 
   return (
     <StyledInputArray>
-      <Label>{props.label}</Label>
+      <Label>
+        {props.label}
+        {props.required && <Required>{" *"}</Required>}
+      </Label>
       {variables[props.variable] === 0 ? (
         <></>
       ) : (
@@ -100,10 +125,10 @@ export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
   vars,
   props: ArrayInputField
 ) => {
-  return vars[props.variable]
+  return vars[props.variable] != undefined && vars[props.variable] != null
     ? {}
     : {
-        [props.variable]: props.value ? props.value[0] : [],
+        [props.variable]: hasSetValue(props) ? props.value[0] : [],
       };
 };
 
@@ -185,3 +210,7 @@ const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
 `;
+
+const Required = styled.span`
+  color: #fc4976;
+`;

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

@@ -26,7 +26,7 @@ const Checkbox: React.FC<Props> = ({
       validated: !required,
     },
     initVars: {
-      [variable]: value ? value[0] : !!settings?.default,
+      [variable]: value ? value[0] : false,
     },
   });
 
@@ -60,9 +60,9 @@ export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
 ) => {
   // Read from revision values if unrendered (and therefore not in form state)
   if (vars[props.variable] === null || vars[props.variable] === undefined) {
-    if (props.value[0] === false) {
+    if (props.value && props.value[0] === false) {
       return { [props.variable]: false };
-    } else if (props.value[0] === true) {
+    } else if (props.value && props.value[0] === true) {
       return { [props.variable]: true };
     }
   }
@@ -75,6 +75,6 @@ export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
   }
 
   return {
-    [props.variable]: props.value ? props.value[0] : !!props.settings?.default,
+    [props.variable]: props.value ? props.value[0] : false,
   };
 };

+ 22 - 19
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -6,6 +6,7 @@ import {
   InputField,
   StringInputFieldState,
 } from "../types";
+import { hasSetValue } from "../utils";
 
 const clipOffUnit = (unit: string, x: string) => {
   if (typeof x === "string" && unit) {
@@ -16,17 +17,19 @@ const clipOffUnit = (unit: string, x: string) => {
   return x;
 };
 
-const Input: React.FC<InputField> = ({
-  id,
-  variable,
-  label,
-  required,
-  placeholder,
-  info,
-  settings,
-  isReadOnly,
-  value,
-}) => {
+const Input: React.FC<InputField> = (props) => {
+  const {
+    id,
+    variable,
+    label,
+    required,
+    placeholder,
+    info,
+    settings,
+    isReadOnly,
+    value,
+  } = props;
+
   const {
     state,
     variables,
@@ -34,14 +37,12 @@ const Input: React.FC<InputField> = ({
     setValidation,
   } = useFormField<StringInputFieldState>(id, {
     initValidation: {
-      validated: value
-        ? value[0] !== undefined && value[0] !== "" && value[0] != null
-        : settings?.default != undefined,
+      validated: hasSetValue(props),
     },
     initVars: {
-      [variable]: value
+      [variable]: hasSetValue(props)
         ? clipOffUnit(settings?.unit, value[0])
-        : settings?.default,
+        : undefined,
     },
   });
 
@@ -93,10 +94,12 @@ export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
   props: InputField
 ) => {
   const val =
-    vars[props.variable] ||
-    (props.value
+    vars[props.variable] != undefined && vars[props.variable] != null
+      ? vars[props.variable]
+      : hasSetValue(props)
       ? clipOffUnit(props.settings?.unit, props.value[0])
-      : props.settings?.default);
+      : undefined;
+
   return {
     [props.variable]:
       props.settings?.unit && !props.settings.omitUnitFromValue

+ 7 - 10
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -11,6 +11,7 @@ import useFormField from "../hooks/useFormField";
 import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
+import { hasSetValue } from "../utils";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -21,12 +22,11 @@ const KeyValueArray: React.FC<Props> = (props) => {
     props.id,
     {
       initState: {
-        values:
-          props.value && props.value[0]
-            ? (Object.entries(props.value[0])?.map(([k, v]) => {
-                return { key: k, value: v };
-              }) as any[])
-            : [],
+        values: hasSetValue(props)
+          ? (Object.entries(props.value[0])?.map(([k, v]) => {
+              return { key: k, value: v };
+            }) as any[])
+          : [],
         showEnvModal: false,
         showEditorModal: false,
       },
@@ -349,12 +349,9 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   props: KeyValueArrayField,
   state: KeyValueArrayFieldState
 ) => {
-  console.log(vars);
-  console.log(props);
-  console.log(state);
   if (!state) {
     return {
-      [props.variable]: props.value ? props.value[0] : [],
+      [props.variable]: hasSetValue(props) ? props.value[0] : [],
     };
   }
 

+ 3 - 6
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -8,15 +8,14 @@ import Selector from "../../Selector";
 import styled from "styled-components";
 import useFormField from "../hooks/useFormField";
 import { Context } from "../../../shared/Context";
+import { hasSetValue } from "../utils";
 
 const Select: React.FC<SelectField> = (props) => {
   const { currentCluster } = useContext(Context);
   const { variables, setVars } = useFormField<SelectFieldState>(props.id, {
     initVars: {
-      [props.variable]: props.value
+      [props.variable]: hasSetValue(props)
         ? props.value[0]
-        : props.settings.default
-        ? props.settings.default
         : props.settings.type == "provider"
         ? ({
             gke: "gcp",
@@ -72,10 +71,8 @@ export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
   return vars[props.variable]
     ? {}
     : {
-        [props.variable]: props.value
+        [props.variable]: hasSetValue(props)
           ? props.value[0]
-          : props.settings.default
-          ? props.settings.default
           : props.settings.type == "provider"
           ? ({
               gke: "gcp",

+ 2 - 6
dashboard/src/components/porter-form/types.ts

@@ -18,7 +18,7 @@ export interface GenericInputField extends GenericField {
   settings?: any;
 
   // Read in value from Helm for existing revisions
-  value?: any[];
+  value?: [any] | [];
 }
 
 export interface HeadingField extends GenericField {
@@ -61,9 +61,7 @@ export interface InputField extends GenericInputField {
 export interface CheckboxField extends GenericInputField {
   type: "checkbox";
   label?: string;
-  settings?: {
-    default: boolean;
-  };
+  settings?: {};
 }
 
 export interface KeyValueArrayField extends GenericInputField {
@@ -88,11 +86,9 @@ export interface SelectField extends GenericInputField {
     | {
         type: "normal";
         options: { value: string; label: string }[];
-        default?: string;
       }
     | {
         type: "provider";
-        default?: string;
       };
   width: string;
   label?: string;

+ 5 - 0
dashboard/src/components/porter-form/utils.ts

@@ -0,0 +1,5 @@
+import { GenericInputField } from "./types";
+
+export const hasSetValue = (field: GenericInputField) => {
+  return field.value && field.value.length != 0 && field.value[0] != null;
+};

+ 20 - 1
dashboard/src/main/home/Home.tsx

@@ -12,6 +12,7 @@ import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
 import Dashboard from "./dashboard/Dashboard";
+import WelcomeForm from "./WelcomeForm";
 import Integrations from "./integrations/Integrations";
 import Templates from "./launch/Launch";
 import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
@@ -65,6 +66,7 @@ type StateType = {
 
   // Track last project id for refreshing clusters on project change
   prevProjectId: number | null;
+  showWelcomeForm: boolean;
 };
 
 // TODO: Handle cluster connected but with some failed infras (no successful set)
@@ -78,6 +80,7 @@ class Home extends Component<PropsType, StateType> {
     sidebarReady: false,
     handleDO: false,
     ghRedirect: false,
+    showWelcomeForm: true,
   };
 
   // TODO: Refactor and prevent flash + multiple reload
@@ -385,6 +388,22 @@ class Home extends Component<PropsType, StateType> {
             <Icon src={discordLogo} />
             Join Our Discord
           </DiscordButton>
+          {
+            (this.context?.capabilities?.version === "production" ||
+            this.context?.capabilities?.version === "staging") &&
+            this.state.showWelcomeForm &&
+            localStorage.getItem("welcomed") != "true" && (
+              <>
+                <WelcomeForm
+                  closeForm={() => this.setState({ showWelcomeForm: false })}
+                />
+                <Navbar
+                  logOut={this.props.logOut}
+                  currentView={this.props.currentRoute} // For form feedback
+                />
+              </>
+            )
+          }
         </>
       );
     }
@@ -658,7 +677,7 @@ const StyledHome = styled.div`
 
 const DiscordButton = styled.a`
   position: absolute;
-  z-index: 100;
+  z-index: 1;
   text-decoration: none;
   bottom: 17px;
   display: flex;

+ 305 - 0
dashboard/src/main/home/WelcomeForm.tsx

@@ -0,0 +1,305 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { CSSTransition } from "react-transition-group";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+
+type Props = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  active: boolean;
+};
+
+const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
+  const context = useContext(Context);
+  const [active, setActive] = useState(true);
+  const [isCompany, setIsCompany] = useState(false);
+  const [role, setRole] = useState("unspecified");
+  const [company, setCompany] = useState("");
+
+  const submitForm = () => {
+    api
+      .getWelcome(
+        "<token>",
+        {
+          email: context.user && context.user.email,
+          isCompany,
+          company,
+          role,
+        },
+        {}
+      )
+      .then(() => {
+        localStorage.setItem("welcomed", "true");
+        setActive(false);
+      })
+      .catch((err) => console.log(err));
+  };
+
+  const renderContents = () => {
+    if (isCompany) {
+      return (
+        <FadeWrapper>
+          <Title>Welcome to Porter</Title>
+          <Subtitle>Just two things before getting started.</Subtitle>
+          <SubtitleAlt>
+            <Num>1</Num> What is your company name? *
+          </SubtitleAlt>
+          <Input
+            placeholder="ex: Acme"
+            value={company}
+            onChange={(e: any) => setCompany(e.target.value)}
+          />
+          <SubtitleAlt>
+            <Num>2</Num> What is your role? *
+          </SubtitleAlt>
+          <RadioButton
+            onClick={() => setRole("founder")}
+            selected={role === "founder"}
+          >
+            <i className="material-icons-round">
+              {role === "founder" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            Founder
+          </RadioButton>
+          <RadioButton
+            onClick={() => setRole("developer")}
+            selected={role === "developer"}
+          >
+            <i className="material-icons-round">
+              {role === "developer" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            Developer
+          </RadioButton>
+          <RadioButton
+            onClick={() => setRole("devops")}
+            selected={role === "devops"}
+          >
+            <i className="material-icons-round">
+              {role === "devops" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            DevOps
+          </RadioButton>
+
+          <Submit
+            isDisabled={!company || role === "unspecified"}
+            onClick={() => company && role !== "unspecified" && submitForm()}
+          >
+            <i className="material-icons-round">check</i> Done
+          </Submit>
+        </FadeWrapper>
+      );
+    }
+    return (
+      <>
+        <Title>Welcome to Porter</Title>
+        <Subtitle delay="0.7s">I am interested in using Porter as:</Subtitle>
+        <Option onClick={() => setIsCompany(true)}>
+          <i className="material-icons-round">people</i> A Company
+        </Option>
+        <Option onClick={() => submitForm()}>
+          <i className="material-icons-round">person</i> An Individual
+        </Option>
+      </>
+    );
+  };
+
+  return (
+    <CSSTransition
+      in={active}
+      timeout={500}
+      classNames="alert"
+      unmountOnExit
+      onEnter={() => setActive(true)}
+      onExited={() => setActive(false)}
+    >
+      <StyledWelcomeForm>
+        <div>
+          {renderContents()}
+          <br />
+          <br />
+        </div>
+      </StyledWelcomeForm>
+    </CSSTransition>
+  );
+};
+
+export default WelcomeForm;
+
+const Circle = styled.div`
+  width: 13px;
+  height: 13px;
+  border-radius: 20px;
+  background: #ffffff11;
+  margin-right: 12px;
+  border: 1px solid #aaaabb;
+`;
+
+const FadeWrapper = styled.div`
+  background: #202227;
+  opacity: 0;
+  animation: fadeIn 0.7s 0s;
+  animation-fill-mode: forwards;
+`;
+
+const Num = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border: 1px solid #ffffff;
+`;
+
+const Option = styled.div`
+  width: 500px;
+  max-width: 80vw;
+  height: 50px;
+  background: #ffffff22;
+  display: flex;
+  align-items: center;
+  margin-top: 15px;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  padding-left: 15px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+
+  > i {
+    font-size: 20px;
+    margin-right: 12px;
+    color: #aaaabb;
+  }
+
+  opacity: 0;
+  animation: slideIn 0.7s 1.3s;
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(-30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const Submit = styled(Option)<{ isDisabled: boolean }>`
+  border: 0;
+  opacity: 0;
+  animation: fadeIn 0.7s 0.5s;
+  animation-fill-mode: forwards;
+  margin-top: 35px;
+  cursor: ${(props) => (props.isDisabled ? "not-allowed" : "pointer")};
+  background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
+  :hover {
+    filter: ${(props) => (props.isDisabled ? "" : "brightness(130%)")};
+    background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
+  }
+
+  > i {
+    color: #ffffff;
+  }
+`;
+
+const RadioButton = styled(Option)<{ selected: boolean }>`
+  opacity: 0;
+  background: ${(props) => (props.selected ? "#ffffff44" : "#ffffff22")};
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+
+  > div {
+    background: ${(props) => (props.selected ? "#ffffff44" : "")};
+  }
+`;
+
+const Input = styled.input`
+  width: 500px;
+  max-width: 80vw;
+  height: 50px;
+  background: #ffffff22;
+  font-size: 18px;
+  display: flex;
+  align-items: center;
+  margin-top: 0px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  padding-left: 15px;
+  margin-bottom: 40px;
+
+  opacity: 0;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+`;
+
+const Subtitle = styled.div<{ delay?: string }>`
+  margin: 20px 0 30px;
+  color: #aaaabb;
+
+  opacity: 0;
+  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
+  animation-fill-mode: forwards;
+`;
+
+const SubtitleAlt = styled(Subtitle)`
+  margin: -5px 0 30px;
+  color: white;
+  display: flex;
+  align-items: center;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+`;
+
+const Title = styled.div`
+  color: white;
+
+  font-size: 26px;
+  margin-bottom: 5px;
+  display: flex;
+  align-items: center;
+
+  opacity: 0;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledWelcomeForm = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  background: #202227;
+
+  &.alert-exit {
+    opacity: 1;
+  }
+  &.alert-exit-active {
+    opacity: 0;
+    transform: translateY(-100px);
+    transition: opacity 500ms, transform 1000ms;
+  }
+`;

+ 23 - 13
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -5,7 +5,7 @@ import monoweb from "assets/monoweb.png";
 import { Route, Switch } from "react-router-dom";
 
 import { Context } from "shared/Context";
-import { ChartType, ClusterType } from "shared/types";
+import { ChartType, ClusterType, JobStatusType } from "shared/types";
 import {
   getQueryParam,
   PorterUrl,
@@ -25,6 +25,7 @@ import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
 import GuardedRoute from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import LastRunStatusSelector from "./LastRunStatusSelector";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -36,6 +37,7 @@ type PropsType = RouteComponentProps &
 type StateType = {
   namespace: string;
   sortType: string;
+  lastRunStatus: JobStatusType | null;
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
 };
@@ -47,6 +49,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
+    lastRunStatus: null as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
   };
@@ -130,7 +133,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
     return (
       <>
-        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+        <ControlRow>
           {isAuthorizedToAdd && (
             <Button
               onClick={() =>
@@ -141,10 +144,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </Button>
           )}
           <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
+            {currentView === "jobs" && (
+              <LastRunStatusSelector
+                lastRunStatus={this.state.lastRunStatus}
+                setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                  this.setState({ lastRunStatus });
+                }}
+              />
+            )}
             <NamespaceSelector
               setNamespace={(namespace) =>
                 this.setState({ namespace }, () => {
@@ -155,12 +162,17 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               }
               namespace={this.state.namespace}
             />
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
           </SortFilterWrapper>
         </ControlRow>
 
         <ChartList
           currentView={currentView}
           currentCluster={currentCluster}
+          lastRunStatus={this.state.lastRunStatus}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
         />
@@ -239,12 +251,8 @@ const Br = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
-    if (props.hasMultipleChilds) {
-      return "space-between";
-    }
-    return "flex-end";
-  }};
+  margin-left: auto;
+  justify-content: space-between;
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -389,7 +397,9 @@ const Img = styled.img`
 `;
 
 const SortFilterWrapper = styled.div`
-  width: 468px;
   display: flex;
   justify-content: space-between;
+  > div:not(:first-child) {
+    margin-left: 30px;
+  }
 `;

+ 61 - 0
dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx

@@ -0,0 +1,61 @@
+import React from "react";
+import styled from "styled-components";
+
+import Selector from "components/Selector";
+import { JobStatusType } from "shared/types";
+
+type PropsType = {
+  lastRunStatus: JobStatusType;
+  setLastRunStatus: (lastRunStatus: JobStatusType) => void;
+};
+
+const LastRunStatusSelector = (props: PropsType) => {
+  const options = [
+    {
+      label: "All",
+      value: null,
+    },
+  ].concat(
+    Object.entries(JobStatusType).map((status) => ({
+      label: status[0],
+      value: status[1],
+    }))
+  );
+
+  return (
+    <StyledLastRunStatusSelector>
+      <Label>
+        <i className="material-icons">filter_alt</i>
+        Last Run Status
+      </Label>
+      <Selector
+        activeValue={props.lastRunStatus}
+        setActiveValue={props.setLastRunStatus}
+        options={options}
+        dropdownLabel="Last Run Status"
+        width="150px"
+        dropdownWidth="230px"
+        closeOverlay={true}
+      />
+    </StyledLastRunStatusSelector>
+  );
+};
+
+export default LastRunStatusSelector;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledLastRunStatusSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

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

@@ -100,7 +100,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
     return (
       <StyledNamespaceSelector>
         <Label>
-          <i className="material-icons">filter_alt</i> Filter
+          <i className="material-icons">filter_alt</i> Namespace
         </Label>
         <Selector
           activeValue={this.props.namespace}

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

@@ -2,46 +2,35 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import { useHistory, useLocation, useRouteMatch } from "react-router";
 
-import { ChartType, StorageType } from "shared/types";
+import {
+  ChartType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
-import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
-  isJob: boolean;
-  release: any;
-};
-
-type JobStatusType = {
-  status: "succeeded" | "running" | "failed";
-  start_time: string;
+  jobStatus: JobStatusWithTimeType;
 };
 
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
-  isJob,
-  release,
+  jobStatus,
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
-  const [jobStatus, setJobStatus] = useState<JobStatusType>(null);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
   const match = useRouteMatch();
 
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeAllWebsockets,
-    closeWebsocket,
-  } = useWebsockets();
-
   const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
@@ -80,59 +69,6 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(chart);
   }, [chart]);
 
-  const setupWebsocket = (kind: string) => {
-    const { currentProject, currentCluster } = context;
-
-    const apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onmessage(evt: MessageEvent) {
-        const event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-        if (event.event_type != "UPDATE") {
-          return;
-        }
-        getJobStatus();
-      },
-      onerror() {
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiEndpoint, wsConfig);
-    openWebsocket(kind);
-  };
-
-  const getJobStatus = () => {
-    let { currentCluster, currentProject, setCurrentError } = context;
-
-    api
-      .getJobStatus(
-        "<token>",
-        {
-          cluster_id: currentCluster.id,
-        },
-        {
-          id: currentProject.id,
-          name: chart.name,
-          namespace: chart.namespace,
-        }
-      )
-      .then((res) => {
-        setJobStatus(res.data);
-      })
-      .catch((err) => setCurrentError(err));
-  };
-
-  useEffect(() => {
-    if (isJob) {
-      getJobStatus();
-      setupWebsocket("job");
-    }
-    return () => closeAllWebsockets();
-  }, [isJob]);
-
   const readableDate = (s: string) => {
     const ts = new Date(s);
     const date = ts.toLocaleDateString();
@@ -179,22 +115,21 @@ const Chart: React.FunctionComponent<Props> = ({
             margin_left={"17px"}
           />
           <LastDeployed>
-            {isJob && jobStatus?.status ? (
+            {jobStatus?.status ? (
               <>
                 <Dot>•</Dot>
                 <JobStatus status={jobStatus.status}>
-                  {jobStatus.status === "running" ? "Started" : "Last run"} {jobStatus.status} at{" "}
-                  {readableDate(jobStatus.start_time)}
+                  {jobStatus.status === JobStatusType.Running
+                    ? "Started running"
+                    : `Last run ${jobStatus.status}`}{" "}
+                  at {readableDate(jobStatus.start_time)}
                 </JobStatus>
               </>
             ) : (
               <>
                 <Dot>•</Dot>
                 <JobStatus>
-                  Last deployed{" "}
-                  {readableDate(
-                    release?.info?.last_deployed || chart.info.last_deployed
-                  )}
+                  Last deployed {readableDate(chart.info.last_deployed)}
                 </JobStatus>
               </>
             )}
@@ -208,7 +143,7 @@ const Chart: React.FunctionComponent<Props> = ({
       </BottomWrapper>
 
       <TopRightContainer>
-        <span>v{release?.version || chart.version}</span>
+        <span>v{chart.version}</span>
       </TopRightContainer>
     </StyledChart>
   );
@@ -337,14 +272,15 @@ const Title = styled.div`
   }
 `;
 
-const JobStatus = styled.span<{ status?: string }>`
+const JobStatus = styled.span<{ status?: JobStatusType }>`
   font-size: 13px;
-  font-weight: ${props => props.status && props.status !== "running" ? "500" : ""};
-  ${props => `
+  font-weight: ${(props) =>
+    props.status && props.status !== JobStatusType.Running ? "500" : ""};
+  ${(props) => `
   color: ${
-    props.status === "succeeded"
+    props.status === JobStatusType.Succeeded
       ? "rgb(56, 168, 138)"
-      : props.status === "failed"
+      : props.status === JobStatusType.Failed
       ? "#ff385d"
       : "#aaaabb66"
   }`}

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

@@ -1,9 +1,16 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
+import _ from "lodash";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ChartType, ClusterType, StorageType } from "shared/types";
+import {
+  ChartType,
+  ClusterType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
@@ -12,13 +19,19 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 
 type Props = {
   currentCluster: ClusterType;
+  lastRunStatus?: JobStatusType | null;
   namespace: string;
   // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
 };
 
+interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
+  resource_version: number;
+}
+
 const ChartList: React.FunctionComponent<Props> = ({
+  lastRunStatus,
   namespace,
   sortType,
   currentView,
@@ -33,12 +46,17 @@ const ChartList: React.FunctionComponent<Props> = ({
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
-  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [jobStatus, setJobStatus] = useState<
+    Record<string, JobStatusWithTimeAndVersion>
+  >({});
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
 
   const context = useContext(Context);
 
+  const getChartKey = (name: string, namespace: string) =>
+    `${namespace}-${name}`;
+
   const updateCharts = async () => {
     try {
       const { currentCluster, currentProject } = context;
@@ -66,37 +84,8 @@ const ChartList: React.FunctionComponent<Props> = ({
         { id: currentProject.id }
       );
       const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
       setIsError(false);
-      return sortedCharts;
+      return charts;
     } catch (error) {
       console.log(error);
       context.setCurrentError(JSON.stringify(error));
@@ -104,54 +93,68 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
   };
 
-  const setupHelmReleasesWebsocket = () => {
-    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+  const setupHelmReleasesWebsocket = (
+    websocketID: string,
+    namespace: string
+  ) => {
+    let apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+    if (namespace) {
+      apiPath += `&namespace=${namespace}`;
+    }
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to chart live updates websocket");
+        console.log(`connected to websocket: ${websocketID}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
-        const object = event.Object;
-        setReleases((oldReleases) => {
-          const currentRelease = oldReleases[object?.name];
-          const currentReleaseVersion = Number(currentRelease?.version);
-          const newReleaseVersion = Number(object?.version);
-          if (currentReleaseVersion > newReleaseVersion) {
-            return {
-              ...oldReleases,
-            };
+        const newChart: ChartType = event.Object;
+        const isSameChart = (chart: ChartType) =>
+          getChartKey(chart.name, chart.namespace) ===
+          getChartKey(newChart.name, newChart.namespace);
+        setCharts((currentCharts) => {
+          switch (event.event_type) {
+            case "ADD":
+              if (currentCharts.find(isSameChart)) {
+                return currentCharts;
+              }
+              return currentCharts.concat(newChart);
+            case "UPDATE":
+              return currentCharts.map((chart) => {
+                if (isSameChart(chart) && newChart.version >= chart.version) {
+                  return newChart;
+                }
+                return chart;
+              });
+            case "DELETE":
+              return currentCharts.filter((chart) => !isSameChart(chart));
+            default:
+              return currentCharts;
           }
-
-          return {
-            ...oldReleases,
-            [object.name]: object,
-          };
         });
       },
 
       onclose: () => {
-        console.log("closing chart live updates websocket");
+        console.log(`closing websocket: ${websocketID}`);
       },
 
       onerror: (err: ErrorEvent) => {
         console.log(err);
-        closeWebsocket("helm_releases");
+        closeWebsocket(websocketID);
       },
     };
 
-    newWebsocket("helm_releases", apiPath, wsConfig);
-    openWebsocket("helm_releases");
+    newWebsocket(websocketID, apiPath, wsConfig);
+    openWebsocket(websocketID);
   };
 
-  const setupWebsocket = (kind: string) => {
+  const setupControllerWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = context;
     const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to websocket");
+        console.log(`connected to websocket: ${kind}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
@@ -164,7 +167,7 @@ const ChartList: React.FunctionComponent<Props> = ({
         }));
       },
       onclose: () => {
-        console.log("closing websocket");
+        console.log(`closing websocket: ${kind}`);
       },
       onerror: (err: ErrorEvent) => {
         console.log(err);
@@ -177,27 +180,108 @@ const ChartList: React.FunctionComponent<Props> = ({
     openWebsocket(kind);
   };
 
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
+  const setupControllerWebsockets = (controllers: string[]) => {
+    controllers.map((kind) => setupControllerWebsocket(kind));
+  };
+
+  const setupJobWebsocket = (websocketID: string) => {
+    const kind = "job";
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log(`connected to websocket: ${websocketID}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+
+        if (_.get(object.metadata, ["annotations", "helm.sh/hook"])) {
+          return;
+        }
+
+        setJobStatus((currentStatus) => {
+          let nextStatus: JobStatusType = null;
+          for (const status of Object.values(JobStatusType)) {
+            if (_.get(object.status, status, 0) > 0) {
+              nextStatus = status;
+              break;
+            }
+          }
+
+          const chartName =
+            object.metadata.labels["app.kubernetes.io/instance"];
+          const chartNamespace = object.metadata.namespace;
+          const key = getChartKey(chartName, chartNamespace);
+
+          const existingValue: JobStatusWithTimeAndVersion = _.get(
+            currentStatus,
+            key,
+            null
+          );
+          const newValue: JobStatusWithTimeAndVersion = {
+            status: nextStatus,
+            start_time: object.status.startTime,
+            resource_version: object.metadata.resourceVersion,
+          };
+
+          if (
+            !existingValue ||
+            newValue.resource_version > existingValue.resource_version
+          ) {
+            return {
+              ...currentStatus,
+              [key]: newValue,
+            };
+          }
+
+          return currentStatus;
+        });
+      },
+      onclose: () => {
+        console.log(`closing websocket: ${websocketID}`);
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketID);
+      },
+    };
+
+    newWebsocket(websocketID, apiPath, wsConfig);
+
+    openWebsocket(websocketID);
   };
 
   // Setup basic websockets on start
   useEffect(() => {
-    setControllerWebsockets([
+    const controllers = [
       "deployment",
       "statefulset",
       "daemonset",
       "replicaset",
-    ]);
-    setupHelmReleasesWebsocket();
+    ];
+    setupControllerWebsockets(controllers);
+
+    const jobWebsocketID = "job";
+    setupJobWebsocket(jobWebsocketID);
 
     return () => {
-      closeAllWebsockets();
+      controllers.map((controller) => closeWebsocket(controller));
+      closeWebsocket(jobWebsocketID);
     };
   }, []);
 
+  useEffect(() => {
+    const websocketID = "helm_releases";
+
+    setupHelmReleasesWebsocket(websocketID, namespace);
+
+    return () => {
+      closeWebsocket(websocketID);
+    };
+  }, [namespace]);
+
   useEffect(() => {
     let isSubscribed = true;
 
@@ -212,6 +296,50 @@ const ChartList: React.FunctionComponent<Props> = ({
     return () => (isSubscribed = false);
   }, [namespace, currentView]);
 
+  const filteredCharts = useMemo(() => {
+    const result = charts
+      .filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      })
+      .filter((chart: ChartType) => {
+        if (currentView !== "jobs") {
+          return true;
+        }
+        if (lastRunStatus === null) {
+          return true;
+        }
+        const status: JobStatusWithTimeAndVersion = _.get(
+          jobStatus,
+          getChartKey(chart.name, chart.namespace),
+          { status: null } as any
+        );
+        return status.status === lastRunStatus;
+      });
+
+    if (sortType == "Newest") {
+      result.sort((a: any, b: any) =>
+        Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+          ? -1
+          : 1
+      );
+    } else if (sortType == "Oldest") {
+      result.sort((a: any, b: any) =>
+        Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+          ? 1
+          : -1
+      );
+    } else if (sortType == "Alphabetical") {
+      result.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+    }
+
+    return result;
+  }, [charts, sortType, jobStatus, lastRunStatus]);
+
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {
       return (
@@ -225,24 +353,27 @@ const ChartList: React.FunctionComponent<Props> = ({
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
-    } else if (charts.length === 0) {
+    } else if (filteredCharts.length === 0) {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
+          {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
+          filters.
         </Placeholder>
       );
     }
 
-    return charts.map((chart: ChartType, i: number) => {
+    return filteredCharts.map((chart: ChartType, i: number) => {
       return (
         <Chart
-          key={`${chart.namespace}-${chart.name}`}
+          key={getChartKey(chart.name, chart.namespace)}
           chart={chart}
           controllers={controllers || {}}
-          isJob={currentView === "jobs"}
-          release={releases[chart.name] || {}}
+          jobStatus={_.get(
+            jobStatus,
+            getChartKey(chart.name, chart.namespace),
+            null
+          )}
         />
       );
     });

+ 25 - 12
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -1,11 +1,11 @@
 import React, { useContext, useState, useEffect } from "react";
-import { Context } from "../../../../shared/Context";
-import api from "../../../../shared/api";
+import { Context } from "shared/Context";
+import api from "shared/api";
 import styled from "styled-components";
-import Loading from "../../../../components/Loading";
-import settings from "../../../../assets/settings.svg";
-import TabSelector from "../../../../components/TabSelector";
-import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import Loading from "components/Loading";
+import settings from "assets/settings.svg";
+import TabSelector from "components/TabSelector";
+import Placeholder from "components/Placeholder";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import {
@@ -259,10 +259,15 @@ const Metrics: React.FC = () => {
       <Loading />
     </LoadingWrapper>
   ) : !detected ? (
-    <p>
-      This message displays when either there's no ingress controller or nginx
-      is not installed
-    </p>
+    <>
+      <br />
+      <br />
+      <Placeholder height="calc(50vh - 50px)" minHeight="400px">
+        Cluster metrics unavailable. Make sure nginx-ingress and Prometheus are
+        installed.
+        <A href="/launch">Go to Launch</A>
+      </Placeholder>
+    </>
   ) : (
     <StyledMetricsSection>
       <Header>
@@ -333,8 +338,13 @@ const Metrics: React.FC = () => {
 
 export default Metrics;
 
+const A = styled.a`
+  margin-left: 5px;
+`;
+
 const LoadingWrapper = styled.div`
-  padding: 30px 0px;
+  padding: 100px 0px;
+  width: 100%;
   display: flex;
   align-items: center;
   font-size: 13px;
@@ -518,7 +528,7 @@ const StyledMetricsSection = styled.div`
   animation: floatIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  margin-top: 20px;
+  margin-top: 34px;
   @keyframes floatIn {
     from {
       opacity: 0;
@@ -533,6 +543,9 @@ const StyledMetricsSection = styled.div`
 
 const Header = styled.div`
   font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #aaaabb;
   font-size: 16px;
   margin-bottom: 15px;

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

@@ -374,8 +374,17 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   <Spinner src={loadingSrc} /> This application is currently
                   being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the{" "}
+                <A
+                  href={
+                    props.currentChart.git_action_config &&
+                    `https://github.com/${props.currentChart.git_action_config?.git_repo}/actions`
+                  }
+                  target={"_blank"}
+                >
+                  Actions
+                </A>{" "}
+                tab of your GitHub repo to view live build logs.
               </TextWrap>
               <DeployStatus chart={chart} />
             </Placeholder>
@@ -1034,3 +1043,9 @@ const DeploymentTypeIcon = styled(Icon)`
   width: 20px;
   margin-right: 10px;
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 136 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -18,6 +18,8 @@ import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import ValuesYaml from "./ValuesYaml";
 import DeploymentType from "./DeploymentType";
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
 
 type PropsType = WithAuthProps & {
   namespace: string;
@@ -42,6 +44,7 @@ type StateType = {
   saveValuesStatus: string | null;
   formData: any;
   devOpsMode: boolean;
+  upgradeVersion: string;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -59,6 +62,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
+    upgradeVersion: "",
     devOpsMode: localStorage.getItem("devOpsMode") === "true",
   };
 
@@ -436,8 +440,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 <Header>
                   <Spinner src={loading} /> This job is currently being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the
+                <A
+                  href={`https://github.com/${this.props.currentChart?.git_action_config?.git_repo}/actions`}
+                  target={"_blank"}
+                >
+                  Actions tab
+                </A>{" "}
+                of your GitHub repo to view live build logs.
               </TextWrap>
             </Placeholder>
           );
@@ -569,13 +579,91 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
+  handleUpgradeVersion = async (version: string, cb: () => void) => {
+    // convert current values to yaml
+    let values = this.state.currentChart.config;
+
+    let valuesYaml = yaml.dump({
+      ...(this.state.currentChart.config as Object),
+      ...values,
+    });
+
+    _.set(values, "paused", true);
+
+    const { currentChart } = this.state;
+    this.setState({ saveValuesStatus: "loading" });
+    this.getChartData(currentChart, currentChart.version);
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          storage: StorageType.Secret,
+          values: valuesYaml,
+          version: version,
+        },
+        {
+          id: this.context.currentProject.id,
+          name: currentChart.name,
+          cluster_id: this.context.currentCluster.id,
+        }
+      );
+      this.setState({ saveValuesStatus: "successful" });
+
+      window.analytics.track("Chart Upgraded", {
+        chart: currentChart.name,
+        values: valuesYaml,
+      });
+
+      cb && cb();
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+      this.setState({ saveValuesStatus: err });
+      this.context.setCurrentError(parsedErr);
+
+      window.analytics.track("Failed to Upgrade Chart", {
+        chart: currentChart.name,
+        values: valuesYaml,
+        error: err,
+      });
+    }
+  };
+
   render() {
     let { closeChart } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
-
+    const displayUpdateButton =
+      chart.latest_version &&
+      chart.latest_version !== chart.chart.metadata.version;
     return (
       <>
+        {this.state.upgradeVersion && (
+          <Modal
+            onRequestClose={() => this.setState({ upgradeVersion: "" })}
+            width="500px"
+            height="450px"
+          >
+            <UpgradeChartModal
+              currentChart={chart}
+              closeModal={() => {
+                this.setState({ upgradeVersion: "" });
+              }}
+              onSubmit={() => {
+                this.handleUpgradeVersion(this.state.upgradeVersion, () => {
+                  this.setState({ loading: false });
+                });
+                this.setState({ upgradeVersion: "", loading: true });
+              }}
+            />
+          </Modal>
+        )}
         <StyledExpandedChart>
           <HeaderWrapper>
             <BackButton onClick={closeChart}>
@@ -599,6 +687,19 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 {" " + this.readableDate(chart.info.last_deployed)}
               </LastDeployed>
             </InfoWrapper>
+            {displayUpdateButton && (
+              <RevisionUpdateMessage
+                onClick={(e) => {
+                  e.stopPropagation();
+                  this.setState({
+                    upgradeVersion: currentChart.latest_version,
+                  });
+                }}
+              >
+                <i className="material-icons">notification_important</i>
+                Template Update Available
+              </RevisionUpdateMessage>
+            )}
           </HeaderWrapper>
 
           {this.state.deleting ? (
@@ -660,6 +761,31 @@ ExpandedJobChart.contextType = Context;
 
 export default withAuth(ExpandedJobChart);
 
+const RevisionUpdateMessage = styled.button`
+  background: none;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 5px;
+  border: none;
+  margin-bottom: 14px;
+
+  :hover {
+    border: 1px solid white;
+    padding: 3px 9px;
+    cursor: pointer;
+  }
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: none;
+  }
+`;
+
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;
@@ -866,3 +992,10 @@ const TabButton = styled.div`
     margin-right: 9px;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;

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

@@ -161,7 +161,7 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
               <br />
               <SaveButton
                 onClick={() => saveChanges()}
-                text={"Save"}
+                text="Save Notification Settings"
                 clearPosition={true}
                 statusPosition={"right"}
                 disabled={props.disabled || initLoading || saveLoading}

+ 23 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -234,7 +234,23 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{parsedImageTag || "N/A"}</Td>
+          <Td>
+            {!imageTag ? (
+              "N/A"
+            ) : isGithubApp && /^[0-9A-Fa-f]{7}$/g.test(imageTag) ? (
+              <A
+                href={`https://github.com/${this.props.chart.git_action_config?.git_repo}/commit/${imageTag}`}
+                target="_blank"
+                onClick={(e) => {
+                  e.stopPropagation();
+                }}
+              >
+                {parsedImageTag}
+              </A>
+            ) : (
+              parsedImageTag
+            )}
+          </Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -536,3 +552,9 @@ const RevisionUpdateMessage = styled.div`
     transform: none;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 14 - 8
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -210,6 +210,20 @@ const SettingsSection: React.FC<PropsType> = ({
             forceExpanded={true}
             disableImageSelect={true}
           />
+          {!loadingWebhookToken && (
+            <>
+              <Br />
+              <Br />
+              <Br />
+              <SaveButton
+                clearPosition={true}
+                statusPosition="right"
+                text="Save Source Settings"
+                status={saveValuesStatus}
+                onClick={handleSubmit}
+              />
+            </>
+          )}
           <Br />
         </>
 
@@ -264,14 +278,6 @@ const SettingsSection: React.FC<PropsType> = ({
       ) : (
         <Loading />
       )}
-      {!loadingWebhookToken && (
-        <SaveButton
-          text={saveButtonText || "Save Config"}
-          status={saveValuesStatus}
-          onClick={handleSubmit}
-          makeFlush={true}
-        />
-      )}
     </Wrapper>
   );
 };

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

@@ -47,8 +47,8 @@ class JobList extends Component<PropsType, StateType> {
                     onNo: () => {
                       this.setState({ deletionCandidate: null });
                       this.context.setCurrentOverlay(null);
-                    }
-                  })
+                    },
+                  });
                 }}
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
@@ -102,9 +102,7 @@ class JobList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return (
-      <JobListWrapper>{this.renderJobList()}</JobListWrapper>
-    );
+    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
   }
 }
 

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

@@ -140,7 +140,10 @@ export default class JobResource extends Component<PropsType, StateType> {
     let envObject = {} as any;
     envArray &&
       envArray.forEach((env: any, i: number) => {
-        envObject[env.name] = env.value;
+        const secretName = _.get(env, "valueFrom.secretKeyRef.name");
+        envObject[env.name] = secretName
+          ? `PORTERSECRET_${secretName}`
+          : env.value;
       });
 
     // Handle no config to show

+ 1 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -329,6 +329,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       return (
         <WorkflowPage
           name={templateName}
+          namespace={"default"}
           fullActionConfig={fullActionConfig}
           shouldCreateWorkflow={shouldCreateWorkflow}
           setShouldCreateWorkflow={setShouldCreateWorkflow}

+ 2 - 0
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -12,6 +12,7 @@ import SaveButton from "../../../../components/SaveButton";
 
 type PropsType = {
   name: string;
+  namespace: string;
   fullActionConfig: FullActionConfigType;
   shouldCreateWorkflow: boolean;
   setShouldCreateWorkflow: (x: (prevState: boolean) => boolean) => void;
@@ -31,6 +32,7 @@ const WorkflowPage: React.FC<PropsType> = (props) => {
     api
       .generateGHAWorkflow("<token>", props.fullActionConfig, {
         name: props.name,
+        namespace: props.namespace,
         cluster_id: currentCluster.id,
         project_id: currentProject.id,
       })

+ 1 - 1
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -140,7 +140,7 @@ const User = styled.div`
 
 const ListWrapper = styled.div`
   width: 100%;
-  height: 200px;
+  height: 250px;
   background: #ffffff11;
   display: flex;
   align-items: center;

+ 1 - 1
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -33,7 +33,7 @@ export default class UpgradeChartModal extends Component<PropsType, StateType> {
       .toLowerCase()
       .trim();
 
-    if (chartName == "web" || chartName == "worker") {
+    if (chartName == "web" || chartName == "worker" || chartName === "job") {
       repoURL = process.env.APPLICATION_CHART_REPO_URL;
     }
 

+ 12 - 0
dashboard/src/main/home/navbar/Navbar.tsx

@@ -24,6 +24,7 @@ class Navbar extends Component<PropsType, StateType> {
 
   renderSettingsDropdown = () => {
     if (this.state.showDropdown) {
+      let version = this.context?.capabilities?.version;
       return (
         <>
           <CloseOverlay
@@ -45,6 +46,7 @@ class Navbar extends Component<PropsType, StateType> {
             </UserDropdownButton>
             <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
+              {version !== "production" && <VersionTag>{version}</VersionTag>}
             </UserDropdownButton>
           </Dropdown>
         </>
@@ -80,6 +82,14 @@ Navbar.contextType = Context;
 
 export default withAuth(Navbar);
 
+const VersionTag = styled.div`
+  position: absolute;
+  right: 10px;
+  top: 15px;
+  color: #ffffff22;
+  font-weight: 400;
+`;
+
 const SettingsIcon = styled.div`
   > i {
     background: none;
@@ -119,6 +129,7 @@ const CloseOverlay = styled.div`
 
 const UserDropdownButton = styled.button`
   padding: 13px;
+  position: relative;
   height: 40px;
   font-size: 13px;
   font-weight: 500;
@@ -230,6 +241,7 @@ const StyledNavbar = styled.div`
   align-items: center;
   padding-right: 5px;
   justify-content: flex-end;
+  z-index: 1;
 `;
 
 const NavButton = styled.a`

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

@@ -23,6 +23,12 @@ export default class NewProject extends Component<PropsType, StateType> {
     selectedProvider: null as string | null,
   };
 
+  componentDidMount() {
+    window.analytics.track("provision_new-project", {
+      userId: this.context.user?.id,
+    });
+  }
+
   render() {
     let { capabilities } = this.context;
     let { projectName } = this.state;

+ 302 - 341
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -15,27 +15,15 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
-import { RouteComponentProps, withRouter } from "react-router";
-import Tooltip from "@material-ui/core/Tooltip";
+import { useHistory, useLocation } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   projectName: string;
   infras: InfraType[];
   highlightCosts?: boolean;
-};
-
-type StateType = {
-  awsRegion: string;
-  awsMachineType: string;
-  awsAccessId: string;
-  awsSecretKey: string;
-  clusterName: string;
-  clusterNameSet: boolean;
-  selectedInfras: { value: string; label: string }[];
-  buttonStatus: string;
-  provisionConfirmed: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -84,29 +72,40 @@ const costMapping: Record<string, number> = {
   "t3.2xlarge": 240,
 };
 
-// TODO: Consolidate across forms w/ HOC
-class AWSFormSection extends Component<PropsType, StateType> {
-  state = {
-    awsRegion: "us-east-1",
-    awsMachineType: "t2.medium",
-    awsAccessId: "",
-    awsSecretKey: "",
-    clusterName: "",
-    clusterNameSet: false,
-    selectedInfras: [...provisionOptions],
-    buttonStatus: "",
-    provisionConfirmed: false,
-  };
+const AWSFormSectionFC: React.FC<PropsType> = (props) => {
+  const [awsRegion, setAwsRegion] = useState("us-east-1");
+  const [awsMachineType, setAwsMachineType] = useState("t2.medium");
+  const [awsAccessId, setAwsAccessId] = useState("");
+  const [awsSecretKey, setAwsSecretKey] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  const context = useContext(Context);
+
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "aws",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -114,45 +113,30 @@ class AWSFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let {
-      awsRegion,
-      awsAccessId,
-      awsSecretKey,
-      selectedInfras,
-      clusterName,
-    } = this.state;
-    let { projectName } = this.props;
+    const { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -176,172 +160,144 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
   };
 
   // Step 1: Create a project
   // TODO: promisify this function
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) => {
-        let proj = res.data;
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        api
-          .getProjects(
-            "<token>",
-            {},
-            {
-              id: user.userId,
-            }
-          )
-          .then((res) => {
-            setProjects(res.data);
-            setCurrentProject(proj, () => {
-              callback && callback();
-            });
-          })
-          .catch(this.catchError);
-      })
-      .catch(this.catchError);
+  const createProject = async () => {
+    const { projectName } = props;
+    const { user, setProjects, setCurrentProject } = context;
+    try {
+      const project = await api
+        .createProject("<token>", { name: projectName }, {})
+        .then((res) => res.data);
+
+      // Need to set project list for dropdown
+      // TODO: consolidate into ProjectSection (case on exists in list on set)
+      const projectList = await api
+        .getProjects(
+          "<token>",
+          {},
+          {
+            id: user.userId,
+          }
+        )
+        .then((res) => res.data);
+      setProjects(projectList);
+      setCurrentProject(project);
+    } catch (error) {
+      catchError(error);
+    }
   };
 
-  provisionECR = () => {
-    console.log("Provisioning ECR");
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-    let { currentProject } = this.context;
-
-    return api
-      .createAWSIntegration(
+  const getAwsIntegrationId = async () => {
+    const { currentProject } = context;
+    try {
+      const res = await api.createAWSIntegration(
         "<token>",
         {
           aws_region: awsRegion,
           aws_access_key_id: awsAccessId,
           aws_secret_access_key: awsSecretKey,
+          aws_cluster_id: clusterName,
         },
         { id: currentProject.id }
-      )
-      .then((res) =>
-        api.provisionECR(
-          "<token>",
-          {
-            aws_integration_id: res.data.id,
-            ecr_name: `${currentProject.name}-registry`,
-          },
-          { id: currentProject.id }
-        )
-      )
-      .catch(this.catchError);
+      );
+      return res.data;
+    } catch (error) {
+      catchError(error);
+    }
   };
 
-  provisionEKS = () => {
-    let {
-      awsAccessId,
-      awsSecretKey,
-      awsRegion,
-      awsMachineType,
-      clusterName,
-    } = this.state;
-    let { currentProject } = this.context;
-
-    api
-      .createAWSIntegration(
+  const provisionECR = async (awsIntegrationId: string) => {
+    console.log("Started provision ECR");
+    const { currentProject } = context;
+    try {
+      await api.provisionECR(
         "<token>",
         {
-          aws_region: awsRegion,
-          aws_access_key_id: awsAccessId,
-          aws_secret_access_key: awsSecretKey,
-          aws_cluster_id: clusterName,
+          aws_integration_id: awsIntegrationId,
+          ecr_name: `${currentProject.name}-registry`,
         },
         { id: currentProject.id }
-      )
-      .then((res) =>
-        api.provisionEKS(
-          "<token>",
-          {
-            aws_integration_id: res.data.id,
-            eks_name: clusterName,
-            machine_type: awsMachineType,
-          },
-          { id: currentProject.id }
-        )
-      )
-      .then(() =>
-        pushFiltered(this.props, "/dashboard", ["project_id"], {
-          tab: "provisioner",
-        })
-      )
-      .catch(this.catchError);
+      );
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const provisionEKS = async (awsIntegrationId: string) => {
+    const { currentProject } = context;
+    try {
+      await api.provisionEKS(
+        "<token>",
+        {
+          aws_integration_id: awsIntegrationId,
+          eks_name: clusterName,
+          machine_type: awsMachineType,
+        },
+        { id: currentProject.id }
+      );
+    } catch (error) {
+      catchError(error);
+    }
   };
 
   // TODO: handle generically (with > 2 steps)
-  onCreateAWS = () => {
-    this.setState({ buttonStatus: "loading" });
-    let { projectName } = this.props;
-    let { selectedInfras } = this.state;
-
-    if (!projectName) {
-      if (selectedInfras.length === 2) {
-        // Case: project exists, provision ECR + EKS
-        this.provisionECR().then(this.provisionEKS);
-      } else if (selectedInfras[0].value === "ecr") {
-        // Case: project exists, only provision ECR
-        this.provisionECR().then(() =>
-          pushFiltered(this.props, "/dashboard", ["project_id"], {
-            tab: "provisioner",
-          })
-        );
-      } else {
-        // Case: project exists, only provision EKS
-        this.provisionEKS();
-      }
-    } else {
-      if (selectedInfras.length === 2) {
-        // Case: project DNE, provision ECR + EKS
-        this.createProject(() => this.provisionECR().then(this.provisionEKS));
-      } else if (selectedInfras[0].value === "ecr") {
-        // Case: project DNE, only provision ECR
-        this.createProject(() =>
-          this.provisionECR().then(() =>
-            pushFiltered(this.props, "/dashboard", ["project_id"], {
-              tab: "provisioner",
-            })
-          )
-        );
-      } else {
-        // Case: project DNE, only provision EKS
-        this.createProject(this.provisionEKS);
-      }
+  const onCreateAWS = async () => {
+    // Track to segment the intent of provision cluster
+    props?.trackOnSave();
+    setButtonStatus("loading");
+    const { projectName } = props;
+
+    if (projectName) {
+      await createProject();
     }
+
+    const awsIntegrationId = await getAwsIntegrationId();
+
+    const filterNonAWSInfras = (infra: any) =>
+      ["ecr", "eks"].includes(infra.value);
+
+    const infraCreationRequests = selectedInfras
+      // Check that we don't include any other key into the infra creation than ecr and eks
+      .filter(filterNonAWSInfras)
+      .map((infra) => {
+        if (infra.value === "ecr") {
+          return provisionECR(awsIntegrationId?.id);
+        }
+        return provisionEKS(awsIntegrationId?.id);
+      });
+    // Wait for all promises to be completed (could be just one)
+    await Promise.all(infraCreationRequests);
+
+    pushFiltered({ history, location }, "/dashboard", ["project_id"], {
+      tab: "provisioner",
+    });
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
     if (
-      !this.state.awsAccessId ||
-      !this.state.awsSecretKey ||
-      !this.state.provisionConfirmed ||
-      !this.state.clusterName ||
-      this.props.projectName === ""
+      !awsAccessId ||
+      !awsSecretKey ||
+      !provisionConfirmed ||
+      !clusterName ||
+      props.projectName === ""
     ) {
       return "Required fields missing";
     }
-    return this.state.buttonStatus;
+    return buttonStatus;
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "eks")
@@ -350,9 +306,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setClusterName(x);
+            setClusterNameSet(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -362,152 +320,155 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let {
-      awsRegion,
-      awsMachineType,
-      awsAccessId,
-      awsSecretKey,
-      selectedInfras,
-    } = this.state;
-
-    return (
-      <StyledAWSFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>
-            AWS Credentials
-            <GuideButton
-              onClick={() => window.open("https://docs.getporter.dev/docs/getting-started-with-porter-on-aws")}
-            >
-              <i className="material-icons-outlined">help</i>
-              Guide
-            </GuideButton>
-          </Heading>
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            value={awsRegion}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ awsRegion: x })}
-            label="📍 AWS Region"
-          />
-          <SelectRow
-            options={machineTypeOptions}
-            width="100%"
-            value={awsMachineType}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
-            label="⚙️ AWS Machine Type"
-          />
-          {/*
-          <Helper>
-            Estimated Cost:{" "}
-            <CostHighlight highlight={this.props.highlightCosts}>
-              {`\$${
-                70 + 3 * costMapping[this.state.awsMachineType] + 30
-              }/Month`}
-            </CostHighlight>
-            <Tooltip
-              title={
-                <div
-                  style={{
-                    fontFamily: "Work Sans, sans-serif",
-                    fontSize: "12px",
-                    fontWeight: "normal",
-                    padding: "5px 6px",
-                  }}
-                >
-                  EKS cost: ~$70/month <br />
-                  Machine (x3) cost: ~$
-                  {`${3 * costMapping[this.state.awsMachineType]}`}/month <br />
-                  Networking cost: ~$30/month
-                </div>
-              }
-              placement="top"
-            >
-              <StyledInfoTooltip>
-                <i className="material-icons">help_outline</i>
-              </StyledInfoTooltip>
-            </Tooltip>
-          </Helper>
-          */}
-          <InputRow
-            type="text"
-            value={awsAccessId}
-            setValue={(x: string) => this.setState({ awsAccessId: x })}
-            label="👤 AWS Access ID"
-            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
-            width="100%"
-            isRequired={true}
-          />
-          <InputRow
-            type="password"
-            value={awsSecretKey}
-            setValue={(x: string) => this.setState({ awsSecretKey: x })}
-            label="🔒 AWS Secret Key"
-            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
-            width="100%"
-            isRequired={true}
-          />
-          <Br />
-          <Heading>AWS Resources</Heading>
-          <Helper>
-            Porter will provision the following AWS resources in your own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three t2.medium instances
-            (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
-            resources. Learn more about EKS pricing
-            <Highlight
-              href="https://aws.amazon.com/eks/pricing/"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
-            }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={
-            this.checkFormDisabled() || this.state.buttonStatus === "loading"
-          }
-          onClick={this.onCreateAWS}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
-        />
-      </StyledAWSFormSection>
+  const goToGuide = () => {
+    window?.analytics?.track("provision_go-to-guide", {
+      hosting: "aws",
+    });
+
+    window.open(
+      "https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
     );
-  }
-}
+  };
 
-AWSFormSection.contextType = Context;
+  return (
+    <StyledAWSFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>
+          AWS Credentials
+          <GuideButton onClick={() => goToGuide()}>
+            <i className="material-icons-outlined">help</i>
+            Guide
+          </GuideButton>
+        </Heading>
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          value={awsRegion}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsRegion(x);
+          }}
+          label="📍 AWS Region"
+        />
+        <SelectRow
+          options={machineTypeOptions}
+          width="100%"
+          value={awsMachineType}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsMachineType(x);
+          }}
+          label="⚙️ AWS Machine Type"
+        />
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            {`\$${
+              70 + 3 * costMapping[this.state.awsMachineType] + 30
+            }/Month`}
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                EKS cost: ~$70/month <br />
+                Machine (x3) cost: ~$
+                {`${3 * costMapping[this.state.awsMachineType]}`}/month <br />
+                Networking cost: ~$30/month
+              </div>
+            }
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <InputRow
+          type="text"
+          value={awsAccessId}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsAccessId(x);
+          }}
+          label="👤 AWS Access ID"
+          placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="password"
+          value={awsSecretKey}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsSecretKey(x);
+          }}
+          label="🔒 AWS Secret Key"
+          placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+          width="100%"
+          isRequired={true}
+        />
+        <Br />
+        <Heading>AWS Resources</Heading>
+        <Helper>
+          Porter will provision the following AWS resources in your own cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setIsFormDirty(true);
+            console.log(x);
+            setSelectedInfras(x);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three t2.medium instances
+          (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
+          resources. Learn more about EKS pricing
+          <Highlight href="https://aws.amazon.com/eks/pricing/" target="_blank">
+            here
+          </Highlight>
+          .
+        </Helper>
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
+        />
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled() || buttonStatus === "loading"}
+        onClick={onCreateAWS}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledAWSFormSection>
+  );
+};
 
-export default withRouter(AWSFormSection);
+export default AWSFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;

+ 169 - 189
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -14,8 +14,6 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
-import InfoTooltip from "../../../components/InfoTooltip";
-import Tooltip from "@material-ui/core/Tooltip";
 
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
@@ -23,15 +21,7 @@ type PropsType = {
   projectName: string;
   highlightCosts?: boolean;
   infras: InfraType[];
-};
-
-type StateType = {
-  selectedInfras: { value: string; label: string }[];
-  subscriptionTier: string;
-  doRegion: string;
-  clusterName: string;
-  clusterNameSet: boolean;
-  provisionConfirmed: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -58,25 +48,35 @@ const regionOptions = [
 ];
 
 // TODO: Consolidate across forms w/ HOC
-export default class DOFormSection extends Component<PropsType, StateType> {
-  state = {
-    selectedInfras: [...provisionOptions],
-    subscriptionTier: "basic",
-    doRegion: "nyc1",
-    clusterName: "",
-    clusterNameSet: false,
-    provisionConfirmed: false,
-  };
+const DOFormSectionFC: React.FC<PropsType> = (props) => {
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [subscriptionTier, setSubscriptionTier] = useState("basic");
+  const [doRegion, setDoRegion] = useState("nyc1");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+
+  const context = useContext(Context);
+
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "do",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -84,39 +84,30 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let { selectedInfras, clusterName } = this.state;
-    let { projectName } = this.props;
+    let { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -128,16 +119,16 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
     return;
   };
 
   // Step 1: Create a project
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
+  const createProject = (callback?: any) => {
+    let { projectName } = props;
+    let { user, setProjects, setCurrentProject } = context;
 
     api
       .createProject("<token>", { name: projectName }, {})
@@ -156,16 +147,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
         setProjects(res_1.data);
         setCurrentProject(proj, () => callback && callback(proj.id));
       })
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  doRedirect = (projectId: number) => {
-    let {
-      subscriptionTier,
-      doRegion,
-      selectedInfras,
-      clusterName,
-    } = this.state;
+  const doRedirect = (projectId: number) => {
     let redirectUrl = `/api/oauth/projects/${projectId}/digitalocean?project_id=${projectId}&provision=do`;
     redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}&cluster_name=${clusterName}`;
     selectedInfras.forEach((option: { value: string; label: string }) => {
@@ -176,36 +161,30 @@ export default class DOFormSection extends Component<PropsType, StateType> {
   };
 
   // TODO: handle generically (with > 2 steps)
-  onCreateDO = () => {
-    let { projectName } = this.props;
-    let { selectedInfras } = this.state;
-    let { currentProject } = this.context;
+  const onCreateDO = () => {
+    props?.trackOnSave();
+    let { projectName } = props;
+    let { currentProject } = context;
 
     if (!projectName) {
-      this.doRedirect(currentProject.id);
+      doRedirect(currentProject.id);
     } else {
-      this.createProject((projectId: number) => this.doRedirect(projectId));
+      createProject((projectId: number) => doRedirect(projectId));
     }
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
-    if (
-      !this.state.provisionConfirmed ||
-      this.props.projectName === "" ||
-      !this.state.clusterName
-    ) {
+    if (!provisionConfirmed || props.projectName === "" || !clusterName) {
       return "Required fields missing";
     }
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "doks")
@@ -214,9 +193,11 @@ export default class DOFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setClusterName(x);
+            setClusterNameSet(true);
+            setIsFormDirty(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -226,115 +207,114 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let { selectedInfras, subscriptionTier, doRegion } = this.state;
-
-    return (
-      <StyledAWSFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>DigitalOcean Settings</Heading>
-          <SelectRow
-            options={tierOptions}
-            width="100%"
-            value={subscriptionTier}
-            setActiveValue={(x: string) =>
-              this.setState({ subscriptionTier: x })
-            }
-            label="💰 Subscription Tier"
-          />
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            dropdownMaxHeight="240px"
-            value={doRegion}
-            setActiveValue={(x: string) => this.setState({ doRegion: x })}
-            label="📍 DigitalOcean Region"
-          />
-          <Br />
-          <Heading>DigitalOcean Resources</Heading>
-          <Helper>
-            Porter will provision the following DigitalOcean resources in your
-            own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three Standard (2vCPUs /
-            2GB RAM) droplets. DigitalOcean will bill you for any provisioned
-            resources. Learn more about DOKS pricing
-            <Highlight
-              href="https://www.digitalocean.com/products/kubernetes/"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          {/*
-          <Helper>
-            Estimated Cost:{" "}
-            <CostHighlight highlight={this.props.highlightCosts}>
-              $90/Month
-            </CostHighlight>
-            <Tooltip
-              title={
-                <div
-                  style={{
-                    fontFamily: "Work Sans, sans-serif",
-                    fontSize: "12px",
-                    fontWeight: "normal",
-                    padding: "5px 6px",
-                  }}
-                >
-                  Cluster cost: ~$10/month <br />
-                  Machine (x3) cost: ~$60/month <br />
-                  Networking cost: ~$20/month
-                </div>
-              }
-              placement="top"
-            >
-              <StyledInfoTooltip>
-                <i className="material-icons">help_outline</i>
-              </StyledInfoTooltip>
-            </Tooltip>
-          </Helper>
-          */}
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
+  return (
+    <StyledAWSFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>DigitalOcean Settings</Heading>
+        <SelectRow
+          options={tierOptions}
+          width="100%"
+          value={subscriptionTier}
+          setActiveValue={(x: string) => {
+            setSubscriptionTier(x);
+            setIsFormDirty(true);
+          }}
+          label="💰 Subscription Tier"
+        />
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          dropdownMaxHeight="240px"
+          value={doRegion}
+          setActiveValue={(x: string) => {
+            setDoRegion(x);
+            setIsFormDirty(true);
+          }}
+          label="📍 DigitalOcean Region"
+        />
+        <Br />
+        <Heading>DigitalOcean Resources</Heading>
+        <Helper>
+          Porter will provision the following DigitalOcean resources in your own
+          cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setSelectedInfras(x);
+            setIsFormDirty(true);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three Standard (2vCPUs / 2GB
+          RAM) droplets. DigitalOcean will bill you for any provisioned
+          resources. Learn more about DOKS pricing
+          <Highlight
+            href="https://www.digitalocean.com/products/kubernetes/"
+            target="_blank"
+          >
+            here
+          </Highlight>
+          .
+        </Helper>
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            $90/Month
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                Cluster cost: ~$10/month <br />
+                Machine (x3) cost: ~$60/month <br />
+                Networking cost: ~$20/month
+              </div>
             }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={this.checkFormDisabled()}
-          onClick={this.onCreateDO}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
         />
-      </StyledAWSFormSection>
-    );
-  }
-}
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled()}
+        onClick={onCreateDO}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledAWSFormSection>
+  );
+};
 
-DOFormSection.contextType = Context;
+export default DOFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;

+ 2 - 0
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -12,6 +12,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 type PropsType = RouteComponentProps & {
   projectName: string;
+  trackOnSave: () => void;
 };
 
 type StateType = {
@@ -24,6 +25,7 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
   };
 
   onCreateProject = () => {
+    this.props?.trackOnSave();
     let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
 

+ 227 - 232
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -16,26 +16,15 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
-import { RouteComponentProps, withRouter } from "react-router";
-import Tooltip from "@material-ui/core/Tooltip";
+import { useHistory, useLocation } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   projectName: string;
   highlightCosts?: boolean;
   infras: InfraType[];
-};
-
-type StateType = {
-  gcpRegion: string;
-  gcpProjectId: string;
-  gcpKeyData: any;
-  clusterName: string;
-  clusterNameSet: boolean;
-  selectedInfras: { value: string; label: string }[];
-  buttonStatus: string;
-  provisionConfirmed: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -70,27 +59,38 @@ const regionOptions = [
   { value: "us-west4", label: "us-west4" },
 ];
 
-class GCPFormSection extends Component<PropsType, StateType> {
-  state = {
-    gcpRegion: "us-east1",
-    gcpProjectId: "",
-    gcpKeyData: "",
-    clusterName: "",
-    clusterNameSet: false,
-    selectedInfras: [...provisionOptions],
-    buttonStatus: "",
-    provisionConfirmed: false,
-  };
+const GCPFormSectionFC: React.FC<PropsType> = (props) => {
+  const [gcpRegion, setGcpRegion] = useState("us-east1");
+  const [gcpProjectId, setGcpProjectId] = useState("");
+  const [gcpKeyData, setGcpKeyData] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  const context = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "gcp",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -98,45 +98,30 @@ class GCPFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let {
-      gcpRegion,
-      gcpProjectId,
-      gcpKeyData,
-      selectedInfras,
-      clusterName,
-    } = this.state;
-    let { projectName } = this.props;
+    let { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -160,15 +145,15 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
   };
 
   // Step 1: Create a project
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
+  const createProject = (callback?: any) => {
+    let { projectName } = props;
+    let { user, setProjects, setCurrentProject } = context;
 
     api
       .createProject("<token>", { name: projectName }, {})
@@ -189,14 +174,14 @@ class GCPFormSection extends Component<PropsType, StateType> {
             setProjects(res.data);
             setCurrentProject(proj, () => callback && callback());
           })
-          .catch(this.catchError);
+          .catch(catchError);
       })
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  provisionGCR = (id: number, callback?: any) => {
+  const provisionGCR = (id: number, callback?: any) => {
     console.log("Provisioning GCR");
-    let { currentProject } = this.context;
+    let { currentProject } = context;
 
     return api
       .createGCR(
@@ -206,34 +191,32 @@ class GCPFormSection extends Component<PropsType, StateType> {
         },
         { project_id: currentProject.id }
       )
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  provisionGKE = (id: number) => {
+  const provisionGKE = (id: number) => {
     console.log("Provisioning GKE");
-    let { handleError } = this.props;
-    let { currentProject } = this.context;
+    let { currentProject } = context;
 
     api
       .createGKE(
         "<token>",
         {
-          gke_name: this.state.clusterName,
+          gke_name: clusterName,
           gcp_integration_id: id,
         },
         { project_id: currentProject.id }
       )
       .then((res) =>
-        pushFiltered(this.props, "/dashboard", ["project_id"], {
+        pushFiltered({ history, location }, "/dashboard", ["project_id"], {
           tab: "provisioner",
         })
       )
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  handleCreateFlow = () => {
-    let { selectedInfras, gcpKeyData, gcpProjectId, gcpRegion } = this.state;
-    let { currentProject } = this.context;
+  const handleCreateFlow = () => {
+    let { currentProject } = context;
     api
       .createGCPIntegration(
         "<token>",
@@ -250,56 +233,59 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
           if (selectedInfras.length === 2) {
             // Case: project exists, provision GCR + GKE
-            this.provisionGCR(id).then(() => this.provisionGKE(id));
+            provisionGCR(id).then(() => provisionGKE(id));
           } else if (selectedInfras[0].value === "gcr") {
             // Case: project exists, only provision GCR
-            this.provisionGCR(id).then(() =>
-              pushFiltered(this.props, "/dashboard", ["project_id"], {
-                tab: "provisioner",
-              })
+            provisionGCR(id).then(() =>
+              pushFiltered(
+                { location, history },
+                "/dashboard",
+                ["project_id"],
+                {
+                  tab: "provisioner",
+                }
+              )
             );
           } else {
             // Case: project exists, only provision GKE
-            this.provisionGKE(id);
+            provisionGKE(id);
           }
         }
       })
       .catch(console.log);
   };
 
-  // TODO: handle generically (with > 2 steps)
-  onCreateGCP = () => {
-    this.setState({ buttonStatus: "loading" });
-    let { projectName } = this.props;
+  const onCreateGCP = () => {
+    props?.trackOnSave();
+    setButtonStatus("loading");
+    let { projectName } = props;
 
     if (!projectName) {
-      this.handleCreateFlow();
+      handleCreateFlow();
     } else {
-      this.createProject(this.handleCreateFlow);
+      createProject(handleCreateFlow);
     }
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
     if (
-      !this.state.gcpProjectId ||
-      !this.state.gcpKeyData ||
-      !this.state.provisionConfirmed ||
-      !this.state.clusterName ||
-      this.props.projectName === ""
+      !gcpProjectId ||
+      !gcpKeyData ||
+      !provisionConfirmed ||
+      !clusterName ||
+      props.projectName === ""
     ) {
       return "Required fields missing";
     }
-    return this.state.buttonStatus;
+    return buttonStatus;
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "gke")
@@ -308,9 +294,11 @@ class GCPFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setClusterName(x);
+            setClusterNameSet(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -320,134 +308,141 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-    return (
-      <StyledGCPFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>
-            GCP Credentials
-            <GuideButton
-              onClick={() => window.open("https://docs.getporter.dev/docs/getting-started-on-gcp")}
-            >
-              <i className="material-icons-outlined">help</i>
-              Guide
-            </GuideButton>
-          </Heading>
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            value={gcpRegion}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ gcpRegion: x })}
-            label="📍 GCP Region"
-          />
-          <InputRow
-            type="text"
-            value={gcpProjectId}
-            setValue={(x: string) => this.setState({ gcpProjectId: x })}
-            label="🏷️ GCP Project ID"
-            placeholder="ex: blindfold-ceiling-24601"
-            width="100%"
-            isRequired={true}
-          />
-          <UploadArea
-            setValue={(x: any) => this.setState({ gcpKeyData: x })}
-            label="🔒 GCP Key Data (JSON)"
-            placeholder="Choose a file or drag it here."
-            width="100%"
-            height="100%"
-            isRequired={true}
-          />
-
-          <Br />
-          <Heading>GCP Resources</Heading>
-          <Helper>
-            Porter will provision the following GCP resources in your own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three custom-2-4096
-            instances (2 CPU, 4 GB RAM each). Google Cloud will bill you for any
-            provisioned resources. Learn more about GKE pricing
-            <Highlight
-              href="https://cloud.google.com/kubernetes-engine/pricing"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          {/*
-          <Helper>
-            Estimated Cost:{" "}
-            <CostHighlight highlight={this.props.highlightCosts}>
-              $250/Month
-            </CostHighlight>
-            <Tooltip
-              title={
-                <div
-                  style={{
-                    fontFamily: "Work Sans, sans-serif",
-                    fontSize: "12px",
-                    fontWeight: "normal",
-                    padding: "5px 6px",
-                  }}
-                >
-                  GKE cost: ~$70/month <br />
-                  Machine (x3) cost: ~$150/month <br />
-                  Networking cost: ~$30/month
-                </div>
-              }
-              placement="top"
-            >
-              <StyledInfoTooltip>
-                <i className="material-icons">help_outline</i>
-              </StyledInfoTooltip>
-            </Tooltip>
-          </Helper>
-          */}
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
-            }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={
-            this.checkFormDisabled() || this.state.buttonStatus === "loading"
-          }
-          onClick={this.onCreateGCP}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
+  const goToGuide = () => {
+    window?.analytics?.track("provision_go-to-guide", {
+      hosting: "gcp",
+    });
+
+    window.open("https://docs.getporter.dev/docs/getting-started-on-gcp");
+  };
+
+  return (
+    <StyledGCPFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>
+          GCP Credentials
+          <GuideButton onClick={() => goToGuide()}>
+            <i className="material-icons-outlined">help</i>
+            Guide
+          </GuideButton>
+        </Heading>
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          value={gcpRegion}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setGcpRegion(x);
+          }}
+          label="📍 GCP Region"
+        />
+        <InputRow
+          type="text"
+          value={gcpProjectId}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setGcpProjectId(x);
+          }}
+          label="🏷️ GCP Project ID"
+          placeholder="ex: blindfold-ceiling-24601"
+          width="100%"
+          isRequired={true}
+        />
+        <UploadArea
+          setValue={(x: any) => {
+            setIsFormDirty(true);
+            setGcpKeyData(x);
+          }}
+          label="🔒 GCP Key Data (JSON)"
+          placeholder="Choose a file or drag it here."
+          width="100%"
+          height="100%"
+          isRequired={true}
         />
-      </StyledGCPFormSection>
-    );
-  }
-}
 
-GCPFormSection.contextType = Context;
+        <Br />
+        <Heading>GCP Resources</Heading>
+        <Helper>
+          Porter will provision the following GCP resources in your own cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setIsFormDirty(true);
+            setSelectedInfras(x);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three custom-2-4096
+          instances (2 CPU, 4 GB RAM each). Google Cloud will bill you for any
+          provisioned resources. Learn more about GKE pricing
+          <Highlight
+            href="https://cloud.google.com/kubernetes-engine/pricing"
+            target="_blank"
+          >
+            here
+          </Highlight>
+          .
+        </Helper>
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            $250/Month
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                GKE cost: ~$70/month <br />
+                Machine (x3) cost: ~$150/month <br />
+                Networking cost: ~$30/month
+              </div>
+            }
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
+        />
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled() || buttonStatus === "loading"}
+        onClick={onCreateGCP}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledGCPFormSection>
+  );
+};
 
-export default withRouter(GCPFormSection);
+export default GCPFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;

+ 183 - 181
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -11,140 +11,171 @@ import GCPFormSection from "./GCPFormSection";
 import DOFormSection from "./DOFormSection";
 import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
-import { RouteComponentProps, withRouter } from "react-router";
+import { useHistory, useLocation } from "react-router";
 import { pushFiltered } from "shared/routing";
-import InfoTooltip from "../../../components/InfoTooltip";
 
-type PropsType = RouteComponentProps & {
+type Props = {
   isInNewProject?: boolean;
   projectName?: string;
   infras?: InfraType[];
   provisioner?: boolean;
 };
 
-type StateType = {
-  selectedProvider: string | null;
-  highlightCosts: boolean;
-  infras: InfraType[];
-};
-
 const providers = ["aws", "gcp", "do"];
 
-class NewProject extends Component<PropsType, StateType> {
-  state = {
-    selectedProvider: null as string | null,
-    highlightCosts: true,
-    infras: [] as InfraType[],
+const ProvisionerSettings: React.FC<Props> = ({
+  provisioner,
+  projectName,
+  infras,
+  isInNewProject,
+}) => {
+  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
+  const [highlightCosts, setHighlightCosts] = useState(true);
+
+  const { setCurrentError } = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!provisioner) {
+      handleSelectProvider("skipped");
+    }
+  }, [provisioner]);
+
+  const handleSelectProvider = (newSelectedProvider: string) => {
+    if (!isInNewProject) {
+      setSelectedProvider(newSelectedProvider);
+      return;
+    }
+    if (newSelectedProvider === selectedProvider) {
+      return;
+    }
+
+    if (selectedProvider && !newSelectedProvider) {
+      window?.analytics?.track("provision_unselect-provider", {
+        unselectedProvider: selectedProvider,
+      });
+      setSelectedProvider(newSelectedProvider);
+      return;
+    }
+
+    window?.analytics?.track("provision_select-provider", {
+      selectedProvider: newSelectedProvider,
+    });
+    setSelectedProvider(newSelectedProvider);
   };
 
-  // Handle any submission (pre-status) error
-  handleError = () => {
-    let { setCurrentError } = this.context;
-    this.setState({ selectedProvider: null });
+  const handleError = () => {
+    handleSelectProvider(null);
+
     setCurrentError(
       "Provisioning failed. Check your credentials and try again."
     );
-    pushFiltered(this.props, "/dashboard", ["project_id"], { tab: "overview" });
+    pushFiltered({ location, history }, "/dashboard", ["project_id"], {
+      tab: "overview",
+    });
   };
 
-  renderSelectedProvider = (override?: string) => {
-    let { selectedProvider } = this.state;
-    let { projectName, infras } = this.props;
+  const trackOnSave = (provider: string) => {
+    window?.analytics?.track("provision_created-project", {
+      choosenProvider: provider,
+    });
+  };
 
-    if (override) {
-      selectedProvider = override;
+  const renderSkipHelper = () => {
+    if (!provisioner) {
+      return;
     }
 
-    let renderSkipHelper = () => {
-      if (!this.props.provisioner) {
-        return;
-      }
-
-      return (
-        <>
-          {selectedProvider === "skipped" ? (
+    return (
+      <>
+        {selectedProvider === "skipped" ? (
+          <Helper>
+            Don't have a Kubernetes cluster?
+            <Highlight onClick={() => handleSelectProvider(null)}>
+              Provision through Porter
+            </Highlight>
+          </Helper>
+        ) : (
+          <PositionWrapper selectedProvider={selectedProvider}>
             <Helper>
-              Don't have a Kubernetes cluster?
-              <Highlight
-                onClick={() => this.setState({ selectedProvider: null })}
-              >
-                Provision through Porter
+              Already have a Kubernetes cluster?
+              <Highlight onClick={() => handleSelectProvider("skipped")}>
+                Skip
               </Highlight>
             </Helper>
-          ) : (
-            <PositionWrapper selectedProvider={selectedProvider}>
-              <Helper>
-                Already have a Kubernetes cluster?
-                <Highlight
-                  onClick={() =>
-                    this.setState({
-                      selectedProvider: "skipped",
-                    })
-                  }
-                >
-                  Skip
-                </Highlight>
-              </Helper>
-            </PositionWrapper>
-          )}
-        </>
+          </PositionWrapper>
+        )}
+      </>
+    );
+  };
+
+  const renderSelectedProvider = (override?: string) => {
+    let currentSelectedProvider = selectedProvider;
+    if (override) {
+      currentSelectedProvider = override;
+    }
+
+    if (selectedProvider === "aws") {
+      return (
+        <AWSFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        >
+          {renderSkipHelper()}
+        </AWSFormSection>
       );
-    };
-
-    switch (selectedProvider) {
-      case "aws":
-        return (
-          <AWSFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            highlightCosts={this.state.highlightCosts}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          >
-            {renderSkipHelper()}
-          </AWSFormSection>
-        );
-      case "gcp":
-        return (
-          <GCPFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            highlightCosts={this.state.highlightCosts}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          >
-            {renderSkipHelper()}
-          </GCPFormSection>
-        );
-      case "do":
-        return (
-          <DOFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            highlightCosts={this.state.highlightCosts}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          />
-        );
-      default:
-        return (
-          <ExistingClusterSection projectName={projectName}>
-            {renderSkipHelper()}
-          </ExistingClusterSection>
-        );
     }
+
+    if (selectedProvider === "gcp") {
+      return (
+        <GCPFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        >
+          {renderSkipHelper()}
+        </GCPFormSection>
+      );
+    }
+
+    if (selectedProvider === "do") {
+      return (
+        <DOFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        />
+      );
+    }
+
+    return (
+      <ExistingClusterSection
+        projectName={projectName}
+        trackOnSave={() => trackOnSave(selectedProvider)}
+      >
+        {renderSkipHelper()}
+      </ExistingClusterSection>
+    );
   };
 
-  renderFooter = () => {
-    let { selectedProvider } = this.state;
-    let { isInNewProject } = this.props;
-    let { provisioner } = this.props;
+  const renderFooter = () => {
     let helper = provisioner
       ? "Note: Provisioning can take up to 15 minutes"
       : "";
@@ -154,9 +185,7 @@ class NewProject extends Component<PropsType, StateType> {
         <>
           <Helper>
             Already have a Kubernetes cluster?
-            <Highlight
-              onClick={() => this.setState({ selectedProvider: "skipped" })}
-            >
+            <Highlight onClick={() => handleSelectProvider("skipped")}>
               Skip
             </Highlight>
           </Helper>
@@ -173,24 +202,7 @@ class NewProject extends Component<PropsType, StateType> {
     }
   };
 
-  componentDidMount() {
-    let { provisioner } = this.props;
-
-    if (!provisioner) {
-      this.setState({ selectedProvider: "skipped" });
-    }
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.provisioner !== this.props.provisioner) {
-      if (!this.props.provisioner) {
-        this.setState({ selectedProvider: "skipped" });
-      }
-    }
-  }
-
-  renderHelperText = () => {
-    let { isInNewProject, provisioner } = this.props;
+  const renderHelperText = () => {
     if (!provisioner) {
       return;
     }
@@ -206,61 +218,51 @@ class NewProject extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { selectedProvider } = this.state;
-
-    return (
-      <StyledProvisionerSettings>
-        <Helper>{this.renderHelperText()}</Helper>
-        {!selectedProvider ? (
-          <BlockList>
-            {providers.map((provider: string, i: number) => {
-              let providerInfo = integrationList[provider];
-              return (
-                <Block
-                  key={i}
-                  onClick={() => {
-                    this.setState({
-                      selectedProvider: provider,
-                      highlightCosts: false,
-                    });
+  return (
+    <StyledProvisionerSettings>
+      <Helper>{renderHelperText()}</Helper>
+      {!selectedProvider ? (
+        <BlockList>
+          {providers.map((provider: string, i: number) => {
+            let providerInfo = integrationList[provider];
+            return (
+              <Block
+                key={i}
+                onClick={() => {
+                  handleSelectProvider(provider);
+                  setHighlightCosts(false);
+                }}
+              >
+                <Icon src={providerInfo.icon} />
+                <BlockTitle>{providerInfo.label}</BlockTitle>
+                <CostSection
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    handleSelectProvider(provider);
+                    setHighlightCosts(true);
                   }}
                 >
-                  <Icon src={providerInfo.icon} />
-                  <BlockTitle>{providerInfo.label}</BlockTitle>
-                  <CostSection
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      this.setState({
-                        selectedProvider: provider,
-                        highlightCosts: true,
-                      });
-                    }}
-                  >
-                    {/*
-                    {provider == "aws" && "$205/Month"}
-                    {provider == "gcp" && "$250/Month"}
-                    {provider == "do" && "$90/Month"}
-                    <InfoTooltip text={""} />
-                    */}
-                  </CostSection>
-                  <BlockDescription>Hosted in your own cloud.</BlockDescription>
-                </Block>
-              );
-            })}
-          </BlockList>
-        ) : (
-          <>{this.renderSelectedProvider()}</>
-        )}
-        {this.renderFooter()}
-      </StyledProvisionerSettings>
-    );
-  }
-}
-
-NewProject.contextType = Context;
+                  {/*
+                  {provider == "aws" && "$205/Month"}
+                  {provider == "gcp" && "$250/Month"}
+                  {provider == "do" && "$90/Month"}
+                  <InfoTooltip text={""} />
+                  */}
+                </CostSection>
+                <BlockDescription>Hosted in your own cloud.</BlockDescription>
+              </Block>
+            );
+          })}
+        </BlockList>
+      ) : (
+        <>{renderSelectedProvider()}</>
+      )}
+      {renderFooter()}
+    </StyledProvisionerSettings>
+  );
+};
 
-export default withRouter(NewProject);
+export default ProvisionerSettings;
 
 const Br = styled.div`
   width: 100%;

+ 28 - 5
dashboard/src/shared/api.tsx

@@ -279,11 +279,12 @@ const generateGHAWorkflow = baseApi<
     cluster_id: number;
     project_id: number;
     name: string;
+    namespace: string;
   }
 >("POST", (pathParams) => {
-  const { name, cluster_id, project_id } = pathParams;
+  const { name, namespace, cluster_id, project_id } = pathParams;
 
-  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}&namespace=${namespace}`;
 });
 
 const deployTemplate = baseApi<
@@ -356,7 +357,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -372,7 +377,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -388,7 +397,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -782,6 +795,15 @@ const getCapabilities = baseApi<{}, {}>("GET", () => {
   return `/api/capabilities`;
 });
 
+const getWelcome = baseApi<{
+  email: string,
+  isCompany: boolean,
+  company: string,
+  role: string
+}, {}>("GET", () => {
+  return `/api/welcome`;
+});
+
 const linkGithubProject = baseApi<
   {},
   {
@@ -1084,6 +1106,7 @@ export default {
   getBranchContents,
   getBranches,
   getCapabilities,
+  getWelcome,
   getChart,
   getCharts,
   getChartComponents,

+ 83 - 0
dashboard/src/shared/baseApi.ts

@@ -0,0 +1,83 @@
+import axios, { AxiosPromise, AxiosRequestConfig, Method } from "axios";
+import qs from "qs";
+
+type EndpointParam<PathParamsType> =
+  | string
+  | ((pathParams: PathParamsType) => string);
+
+type BuildAxiosConfigFunction = (
+  method: Method,
+  endpoint: EndpointParam<unknown>,
+  token: string,
+  params: unknown,
+  pathParams: unknown
+) => AxiosRequestConfig;
+
+const buildAxiosConfig: BuildAxiosConfigFunction = (
+  method,
+  endpoint,
+  token,
+  params,
+  pathParams
+) => {
+  const config: AxiosRequestConfig = {
+    method,
+    url: typeof endpoint === "function" ? endpoint(pathParams) : endpoint,
+  };
+
+  const AuthHeaders = {
+    Authorization: `Bearer ${token}`,
+  };
+
+  if (method.toUpperCase() === "POST") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "PUT") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "DELETE") {
+    const queryParams = qs.stringify(params, {
+      arrayFormat: "repeat",
+    });
+    return {
+      ...config,
+      url: `${config.url}?${queryParams}`,
+    };
+  }
+
+  if (method.toUpperCase() === "GET") {
+    return {
+      ...config,
+      params: params,
+      paramsSerializer: (params) =>
+        qs.stringify(params, { arrayFormat: "repeat" }),
+    };
+  }
+
+  return config;
+};
+
+const apiQueryBuilder = <ParamsType extends {}, PathParamsType = {}>(
+  method: Method = "GET",
+  endpoint: EndpointParam<PathParamsType>
+) => <ResponseType = any>(
+  token: string,
+  params: ParamsType,
+  pathParams: PathParamsType
+) =>
+  axios(
+    buildAxiosConfig(method, endpoint, token, params, pathParams)
+  ) as AxiosPromise<ResponseType>;
+
+export { apiQueryBuilder as baseApi };
+export default apiQueryBuilder;

+ 0 - 46
dashboard/src/shared/baseApi.tsx

@@ -1,46 +0,0 @@
-import axios from "axios";
-import qs from "qs";
-
-// axios.defaults.timeout = 10000;
-
-// Partial function that accepts a generic params type and returns an api method
-export const baseApi = <T extends {}, S = {}>(
-  requestType: string,
-  endpoint: ((pathParams: S) => string) | string
-) => {
-  return (token: string, params: T, pathParams: S) => {
-    // Generate endpoint literal
-    let endpointString: ((pathParams: S) => string) | string;
-    if (typeof endpoint === "string") {
-      endpointString = endpoint;
-    } else {
-      endpointString = endpoint(pathParams);
-    }
-
-    // Handle request type (can refactor)
-    if (requestType === "POST") {
-      return axios.post(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
-      });
-    } else if (requestType === "PUT") {
-      return axios.put(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
-      });
-    } else if (requestType === "DELETE") {
-      return axios.delete(
-        endpointString + "?" + qs.stringify(params, { arrayFormat: "repeat" })
-      );
-    } else {
-      return axios.get(endpointString, {
-        params,
-        paramsSerializer: function (params) {
-          return qs.stringify(params, { arrayFormat: "repeat" });
-        },
-      });
-    }
-  };
-};

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

@@ -297,3 +297,14 @@ export interface ContextProps {
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
 }
+
+export enum JobStatusType {
+  Succeeded = "succeeded",
+  Running = "active",
+  Failed = "failed",
+}
+
+export interface JobStatusWithTimeType {
+  status: JobStatusType;
+  start_time: string;
+}

+ 0 - 3
dashboard/webpack.config.js

@@ -78,7 +78,6 @@ module.exports = () => {
       publicPath: "/",
     },
     devServer: {
-      port: env["PORT"],
       historyApiFallback: true,
       disableHostCheck: true,
       host: "0.0.0.0",
@@ -134,9 +133,7 @@ module.exports = () => {
   if (env.ENABLE_ANALYZER) {
     config.plugins.push(new BundleAnalyzerPlugin());
   }
-  console.log(env);
   if (env.ENABLE_PROXY) {
-    console.log("WORKED!");
     if (!env.API_SERVER) {
       throw new Error(
         "API_SERVER is not present on .env! Please setup the api server url if you want the proxy to work! API_SERVER example: http://localhost:8080"

+ 3 - 1
docker/Dockerfile

@@ -20,9 +20,11 @@ RUN --mount=type=cache,target=$GOPATH/pkg/mod \
 # --------------------
 FROM base AS build-go
 
+ARG version=production
+
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=$GOPATH/pkg/mod \
-    go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
+    go build -ldflags="-w -s -X 'main.Version=${version}'" -a -o ./bin/app ./cmd/app && \
     go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate && \
     go build -ldflags '-w -s' -a -o ./bin/ready ./cmd/ready
 

+ 14 - 1
docs/deploy/applications/deploying-from-the-cli.md

@@ -118,6 +118,19 @@ If you would only like to update the configuration for your application via a `v
 porter update config --app [app-name] --values [values-file]
 ```
 
+For example, to update the app `web-test`, and to programmatically set the environment variables for that application, create a file called `web-test-values.yaml` with the following structure:
+
+```yaml
+container:
+  env:
+    normal:
+      TESTING: test-from-cli
+```
+
+If I then run `porter update config --app web-test --values web-test-values.yaml`, I will now see the new values in the application:
+
+![CLI env vars](https://files.readme.io/1c30b1c-Screen_Shot_2021-08-20_at_11.51.41_AM.png "Screen Shot 2021-08-20 at 11.51.41 AM.png")
+
 # Common Configuration Options
 
 ## Container Port
@@ -150,7 +163,7 @@ This configuration only applies to `web` applications.
 ```yaml
 ingress:
   custom_domain: true
-  custom_paths:
+  hosts:
   - my-app.example.com
 ```
 

+ 3 - 2
internal/analytics/track_events.go

@@ -4,8 +4,9 @@ type SegmentEvent string
 
 const (
 	// onboarding flow
-	UserCreate    SegmentEvent = "New User"
-	ProjectCreate SegmentEvent = "New Project Event"
+	UserCreate      SegmentEvent = "New User"
+	UserVerifyEmail SegmentEvent = "User Verified Email"
+	ProjectCreate   SegmentEvent = "New Project Event"
 
 	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
 	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"

+ 21 - 0
internal/analytics/tracks.go

@@ -79,11 +79,14 @@ func (p segmentProperties) addAdditionalProperties(props map[string]interface{})
 // UserCreateTrackOpts are the options for creating a track when a user is created
 type UserCreateTrackOpts struct {
 	*UserScopedTrackOpts
+
+	Email string
 }
 
 // UserCreateTrack returns a track for when a user is created
 func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
 	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
 
 	return getSegmentUserTrack(
 		opts.UserScopedTrackOpts,
@@ -91,6 +94,24 @@ func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
 	)
 }
 
+// UserCreateTrackOpts are the options for creating a track when a user's email is verified
+type UserVerifyEmailTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email string
+}
+
+// UserVerifyEmailTrack returns a track for when a user's email is verified
+func UserVerifyEmailTrack(opts *UserVerifyEmailTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserVerifyEmail),
+	)
+}
+
 // ProjectCreateTrackOpts are the options for creating a track when a project is created
 type ProjectCreateTrackOpts struct {
 	*ProjectScopedTrackOpts

+ 2 - 0
internal/config/config.go

@@ -71,6 +71,8 @@ type ServerConf struct {
 	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
 	IngressCluster     string `env:"INGRESS_CLUSTER"`
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
+
+	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 1 - 1
internal/forms/git_action.go

@@ -10,7 +10,7 @@ type CreateGitAction struct {
 	Release *models.Release
 
 	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
+	GitBranch      string `json:"branch"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
 	DockerfilePath string `json:"dockerfile_path"`
 	FolderPath     string `json:"folder_path"`

+ 29 - 2
internal/helm/agent.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/pkg/errors"
+	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -36,13 +37,39 @@ func (a *Agent) ListReleases(
 func (a *Agent) GetRelease(
 	name string,
 	version int,
+	getDeps bool,
 ) (*release.Release, error) {
 	// Namespace is already known by the RESTClientGetter.
 	cmd := action.NewGet(a.ActionConfig)
 
 	cmd.Version = version
 
-	return cmd.Run(name)
+	release, err := cmd.Run(name)
+
+	if getDeps {
+		for _, dep := range release.Chart.Metadata.Dependencies {
+			depExists := false
+
+			for _, currDep := range release.Chart.Dependencies() {
+				// we just case on name for now -- there might be edge cases we're missing
+				// but this will cover 99% of cases
+				if dep.Name == currDep.Name() {
+					depExists = true
+					break
+				}
+			}
+
+			if !depExists {
+				depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+
+				if err == nil {
+					release.Chart.AddDependency(depChart)
+				}
+			}
+		}
+	}
+
+	return release, err
 }
 
 // GetReleaseHistory returns a list of charts for a specific release
@@ -88,7 +115,7 @@ func (a *Agent) UpgradeReleaseByValues(
 	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	// grab the latest release
-	rel, err := a.GetRelease(conf.Name, 0)
+	rel, err := a.GetRelease(conf.Name, 0, true)
 
 	if err != nil {
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)

+ 24 - 1
internal/helm/loader/loader.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
@@ -140,7 +141,11 @@ func LoadChart(client *BasicAuthClient, repoURL, chartName, chartVersion string)
 	}
 
 	trimmedRepoURL := strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
-	chartURL := trimmedRepoURL + "/" + strings.TrimPrefix(cv.URLs[0], "/")
+	chartURL := cv.URLs[0]
+
+	if !isValidURL(chartURL) {
+		chartURL = trimmedRepoURL + "/" + strings.TrimPrefix(cv.URLs[0], "/")
+	}
 
 	// download tgz
 	req, err := http.NewRequest("GET", chartURL, nil)
@@ -178,3 +183,21 @@ func LoadChart(client *BasicAuthClient, repoURL, chartName, chartVersion string)
 func LoadChartPublic(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
 	return LoadChart(&BasicAuthClient{}, repoURL, chartName, chartVersion)
 }
+
+// Helper method to test if chart repo URL is valid, or is a path. Chartmuseum saves URLs
+// as paths, other Helm repositories do not.
+func isValidURL(testURI string) bool {
+	_, err := url.ParseRequestURI(testURI)
+
+	if err != nil {
+		return false
+	}
+
+	u, err := url.Parse(testURI)
+
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return false
+	}
+
+	return true
+}

+ 33 - 17
internal/integrations/ci/actions/actions.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/Masterminds/semver/v3"
 	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/google/go-github/v33/github"
 	"github.com/porter-dev/porter/internal/models"
@@ -32,11 +33,12 @@ type GithubActions struct {
 	GithubAppSecretPath  string
 	GithubInstallationID uint
 
-	PorterToken string
-	BuildEnv    map[string]string
-	ProjectID   uint
-	ClusterID   uint
-	ReleaseName string
+	PorterToken      string
+	BuildEnv         map[string]string
+	ProjectID        uint
+	ClusterID        uint
+	ReleaseName      string
+	ReleaseNamespace string
 
 	GitBranch      string
 	DockerFilePath string
@@ -50,6 +52,10 @@ type GithubActions struct {
 	ShouldCreateWorkflow bool
 }
 
+var (
+	deleteWebhookAndEnvSecretsConstraint, _ = semver.NewConstraint(" < 0.1.0")
+)
+
 func (g *GithubActions) Setup() ([]byte, error) {
 	client, err := g.getClient()
 
@@ -113,18 +119,21 @@ func (g *GithubActions) Cleanup() error {
 
 	g.defaultBranch = repo.GetDefaultBranch()
 
-	// delete the webhook token secret
-	err = g.deleteGithubSecret(client, g.getWebhookSecretName())
-
+	actionVersion, err := semver.NewVersion(g.Version)
 	if err != nil {
 		return err
 	}
 
-	// delete the env secret
-	err = g.deleteGithubSecret(client, g.getBuildEnvSecretName())
+	if deleteWebhookAndEnvSecretsConstraint.Check(actionVersion) {
+		// delete the webhook token secret
+		if err := g.deleteGithubSecret(client, g.getWebhookSecretName()); err != nil {
+			return err
+		}
 
-	if err != nil {
-		return err
+		// delete the env secret
+		if err := g.deleteGithubSecret(client, g.getBuildEnvSecretName()); err != nil {
+			return err
+		}
 	}
 
 	return g.deleteGithubFile(client, g.getPorterYMLFileName())
@@ -164,7 +173,8 @@ type GithubActionYAML struct {
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
-		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.Version),
+		getSetTagStep(),
+		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
 	}
 
 	branch := g.GitBranch
@@ -400,25 +410,31 @@ func (g *GithubActions) deleteGithubFile(
 	client *github.Client,
 	filename string,
 ) error {
-	filepath := ".github/workflows/" + filename
-	sha := ""
+	branch := g.GitBranch
+	if branch == "" {
+		branch = g.defaultBranch
+	}
 
+	filepath := ".github/workflows/" + filename
 	// get contents of a file if it exists
 	fileData, _, _, _ := client.Repositories.GetContents(
 		context.TODO(),
 		g.GitRepoOwner,
 		g.GitRepoName,
 		filepath,
-		&github.RepositoryContentGetOptions{},
+		&github.RepositoryContentGetOptions{
+			Ref: branch,
+		},
 	)
 
+	sha := ""
 	if fileData != nil {
 		sha = *fileData.SHA
 	}
 
 	opts := &github.RepositoryContentFileOptions{
 		Message: github.String(fmt.Sprintf("Delete %s file", filename)),
-		Branch:  github.String(g.defaultBranch),
+		Branch:  &branch,
 		SHA:     &sha,
 	}
 

+ 16 - 6
internal/integrations/ci/actions/steps.go

@@ -13,16 +13,26 @@ func getCheckoutCodeStep() GithubActionYAMLStep {
 	}
 }
 
-func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, actionVersion string) GithubActionYAMLStep {
+func getSetTagStep() GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Set Github tag",
+		ID:   "vars",
+		Run:  `echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"`,
+	}
+}
+
+func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, appNamespace, actionVersion string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Update Porter App",
 		Uses: fmt.Sprintf("%s@%s", updateAppActionName, actionVersion),
 		With: map[string]string{
-			"app":     appName,
-			"cluster": fmt.Sprintf("%d", clusterID),
-			"host":    serverURL,
-			"project": fmt.Sprintf("%d", projectID),
-			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"app":       appName,
+			"cluster":   fmt.Sprintf("%d", clusterID),
+			"host":      serverURL,
+			"project":   fmt.Sprintf("%d", projectID),
+			"token":     fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"tag":       "${{ steps.vars.outputs.sha_short }}",
+			"namespace": appNamespace,
 		},
 		Timeout: 20,
 	}

+ 2 - 1
internal/kubernetes/agent.go

@@ -704,7 +704,7 @@ func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Relea
 	return helm_object, false, nil
 }
 
-func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chartList []string, selectors string) error {
 	tweakListOptionsFunc := func(options *metav1.ListOptions) {
 		options.LabelSelector = selectors
 	}
@@ -713,6 +713,7 @@ func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, sel
 		a.Clientset,
 		0,
 		informers.WithTweakListOptions(tweakListOptionsFunc),
+		informers.WithNamespace(namespace),
 	)
 
 	informer := factory.Core().V1().Secrets().Informer()

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

@@ -169,10 +169,6 @@ func QueryPrometheus(
 	if opts.ShouldSum {
 		query = fmt.Sprintf("sum(%s)", query)
 	}
-	
-	fmt.Println("QUERY IS:", query)
-
-	fmt.Println("QUERY IS", query)
 
 	queryParams := map[string]string{
 		"query": query,

+ 28 - 11
internal/oauth/config.go

@@ -28,13 +28,24 @@ type GithubAppConf struct {
 	oauth2.Config
 }
 
+const (
+	GithubAuthURL  string = "https://github.com/login/oauth/authorize"
+	GithubTokenURL string = "https://github.com/login/oauth/access_token"
+	DOAuthURL      string = "https://cloud.digitalocean.com/v1/oauth/authorize"
+	DOTokenURL     string = "https://cloud.digitalocean.com/v1/oauth/token"
+	GoogleAuthURL  string = "https://accounts.google.com/o/oauth2/v2/auth"
+	GoogleTokenURL string = "https://oauth2.googleapis.com/token"
+	SlackAuthURL   string = "https://slack.com/oauth/v2/authorize"
+	SlackTokenURL  string = "https://slack.com/api/oauth.v2.access"
+)
+
 func NewGithubClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://github.com/login/oauth/authorize",
-			TokenURL: "https://github.com/login/oauth/access_token",
+			AuthURL:  GithubAuthURL,
+			TokenURL: GithubTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/github/callback",
 		Scopes:      cfg.Scopes,
@@ -51,8 +62,8 @@ func NewGithubAppClient(cfg *Config, name string, secret string, secretPath stri
 			ClientID:     cfg.ClientID,
 			ClientSecret: cfg.ClientSecret,
 			Endpoint: oauth2.Endpoint{
-				AuthURL:  "https://github.com/login/oauth/authorize",
-				TokenURL: "https://github.com/login/oauth/access_token",
+				AuthURL:  GithubAuthURL,
+				TokenURL: GithubTokenURL,
 			},
 			RedirectURL: cfg.BaseURL + "/api/oauth/github-app/callback",
 			Scopes:      cfg.Scopes,
@@ -65,8 +76,8 @@ func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://cloud.digitalocean.com/v1/oauth/authorize",
-			TokenURL: "https://cloud.digitalocean.com/v1/oauth/token",
+			AuthURL:  DOAuthURL,
+			TokenURL: DOTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/digitalocean/callback",
 		Scopes:      cfg.Scopes,
@@ -78,8 +89,8 @@ func NewGoogleClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
-			TokenURL: "https://oauth2.googleapis.com/token",
+			AuthURL:  GoogleAuthURL,
+			TokenURL: GoogleTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/google/callback",
 		Scopes:      cfg.Scopes,
@@ -91,8 +102,8 @@ func NewSlackClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://slack.com/oauth/v2/authorize",
-			TokenURL: "https://slack.com/api/oauth.v2.access",
+			AuthURL:  SlackAuthURL,
+			TokenURL: SlackTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/slack/callback",
 		Scopes:      cfg.Scopes,
@@ -147,11 +158,17 @@ func GetAccessToken(
 	conf *oauth2.Config,
 	updateToken func(accessToken []byte, refreshToken []byte, expiry time.Time) error,
 ) (string, *time.Time, error) {
+	expiry := prevToken.Expiry
+	if conf.Endpoint.AuthURL == DOAuthURL && expiry.IsZero() {
+		// manually set the expiry so refresh token is used
+		expiry = time.Now().Add(-1 * time.Minute)
+	}
+
 	tokSource := conf.TokenSource(context.TODO(), &oauth2.Token{
 		AccessToken:  string(prevToken.AccessToken),
 		RefreshToken: string(prevToken.RefreshToken),
 		TokenType:    "Bearer",
-		Expiry:       prevToken.Expiry,
+		Expiry:       expiry,
 	})
 
 	token, err := tokSource.Token()

+ 22 - 18
server/api/api.go

@@ -39,6 +39,7 @@ type TestAgents struct {
 
 // AppConfig is the configuration required for creating a new App
 type AppConfig struct {
+	Version    string
 	DB         *gorm.DB
 	Logger     *lr.Logger
 	Repository *repository.Repository
@@ -105,14 +106,15 @@ type App struct {
 }
 
 type AppCapabilities struct {
-	Provisioning       bool `json:"provisioner"`
-	Github             bool `json:"github"`
-	BasicLogin         bool `json:"basic_login"`
-	GithubLogin        bool `json:"github_login"`
-	GoogleLogin        bool `json:"google_login"`
-	SlackNotifications bool `json:"slack_notifs"`
-	Email              bool `json:"email"`
-	Analytics          bool `json:"analytics"`
+	Version            string `json:"version"`
+	Provisioning       bool   `json:"provisioner"`
+	Github             bool   `json:"github"`
+	BasicLogin         bool   `json:"basic_login"`
+	GithubLogin        bool   `json:"github_login"`
+	GoogleLogin        bool   `json:"google_login"`
+	SlackNotifications bool   `json:"slack_notifs"`
+	Email              bool   `json:"email"`
+	Analytics          bool   `json:"analytics"`
 }
 
 // New returns a new App instance
@@ -129,16 +131,18 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:       conf.Logger,
-		Repo:         conf.Repository,
-		ServerConf:   conf.ServerConf,
-		RedisConf:    conf.RedisConf,
-		DBConf:       conf.DBConf,
-		TestAgents:   conf.TestAgents,
-		Capabilities: &AppCapabilities{},
-		db:           conf.DB,
-		validator:    validator,
-		translator:   &translator,
+		Logger:     conf.Logger,
+		Repo:       conf.Repository,
+		ServerConf: conf.ServerConf,
+		RedisConf:  conf.RedisConf,
+		DBConf:     conf.DBConf,
+		TestAgents: conf.TestAgents,
+		Capabilities: &AppCapabilities{
+			Version: conf.Version,
+		},
+		db:         conf.DB,
+		validator:  validator,
+		translator: &translator,
 	}
 
 	// if repository not specified, default to in-memory

+ 3 - 1
server/api/deploy_handler.go

@@ -199,7 +199,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
+		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, form.ReleaseForm.Form.Namespace, gaForm, w, r)
 	}
 
 	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
@@ -442,12 +442,14 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
 					ClusterID:              release.ClusterID,
+					Version:                gitAction.Version,
 				}
 
 				err = gaRunner.Cleanup()

+ 5 - 3
server/api/git_action_handler.go

@@ -32,6 +32,7 @@ func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request)
 
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	name := vals["name"][0]
+	namespace := vals["namespace"][0]
 
 	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
 
@@ -53,7 +54,7 @@ func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, namespace, form, w, r)
 
 	w.WriteHeader(http.StatusOK)
 
@@ -106,7 +107,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, namespace, form, w, r)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -119,7 +120,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 func (app *App) createGitActionFromForm(
 	projID,
 	clusterID uint64,
-	name string,
+	name, namespace string,
 	form *forms.CreateGitAction,
 	w http.ResponseWriter,
 	r *http.Request,
@@ -208,6 +209,7 @@ func (app *App) createGitActionFromForm(
 		ProjectID:              uint(projID),
 		ClusterID:              uint(clusterID),
 		ReleaseName:            name,
+		ReleaseNamespace:       namespace,
 		GitBranch:              form.GitBranch,
 		DockerFilePath:         form.DockerfilePath,
 		FolderPath:             form.FolderPath,

+ 18 - 3
server/api/git_repo_handler.go

@@ -355,7 +355,12 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
-	branch := chi.URLParam(r, "branch")
+	branch, err := url.QueryUnescape(chi.URLParam(r, "branch"))
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
 
 	repoContentOptions := github.RepositoryContentGetOptions{}
 	repoContentOptions.Ref = branch
@@ -394,7 +399,12 @@ func (app *App) HandleGetProcfileContents(w http.ResponseWriter, r *http.Request
 
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
-	branch := chi.URLParam(r, "branch")
+	branch, err := url.QueryUnescape(chi.URLParam(r, "branch"))
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
 
 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
 
@@ -455,7 +465,12 @@ func (app *App) HandleGetRepoZIPDownloadURL(w http.ResponseWriter, r *http.Reque
 
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
-	branch := chi.URLParam(r, "branch")
+	branch, err := url.QueryUnescape(chi.URLParam(r, "branch"))
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
 
 	branchResp, _, err := client.Repositories.GetBranch(
 		context.TODO(),

+ 7 - 2
server/api/k8s_handler.go

@@ -35,7 +35,7 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 	if err != nil {
-		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
@@ -1228,7 +1228,12 @@ func (app *App) HandleStreamHelmReleases(w http.ResponseWriter, r *http.Request)
 		chartList = vals["charts"]
 	}
 
-	err = agent.StreamHelmReleases(conn, chartList, selectors)
+	namespace := v1.NamespaceAll
+	if vals["namespace"] != nil {
+		namespace = vals["namespace"][0]
+	}
+
+	err = agent.StreamHelmReleases(conn, namespace, chartList, selectors)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)

+ 1 - 0
server/api/oauth_do_handler.go

@@ -79,6 +79,7 @@ func (app *App) HandleDOOAuthCallback(w http.ResponseWriter, r *http.Request) {
 		SharedOAuthModel: integrations.SharedOAuthModel{
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
 		},
 		Client:    integrations.OAuthDigitalOcean,
 		UserID:    userID,

+ 5 - 4
server/api/oauth_github_handler.go

@@ -133,10 +133,6 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 		// send to segment
 		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
-		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
-			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
-		}))
-
 		// log the user in
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
 
@@ -235,6 +231,11 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 				return nil, err
 			}
 
+			app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+				UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+				Email:               user.Email,
+			}))
+
 			if !verified {
 				// non-fatal email verification flow
 				app.startEmailVerificationFlow(user)

+ 1 - 0
server/api/oauth_google_handler.go

@@ -99,6 +99,7 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 
 	app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		Email:               user.Email,
 	}))
 
 	// log the user in

+ 12 - 10
server/api/release_handler.go

@@ -121,7 +121,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	release, err := agent.GetRelease(form.Name, form.Revision)
+	release, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -281,7 +281,7 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	release, err := agent.GetRelease(form.Name, form.Revision)
+	release, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -338,7 +338,7 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	release, err := agent.GetRelease(form.Name, form.Revision)
+	release, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -478,7 +478,7 @@ func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	release, err := agent.GetRelease(form.Name, form.Revision)
+	release, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -636,7 +636,7 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	release, err := agent.GetRelease(form.Name, form.Revision)
+	release, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -844,7 +844,7 @@ func (app *App) HandleCreateWebhookToken(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	rel, err := agent.GetRelease(name, 0)
+	rel, err := agent.GetRelease(name, 0, false)
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -970,7 +970,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 	// if the chart version is set, load a chart from the repo
 	if form.ChartVersion != "" {
-		release, err := agent.GetRelease(form.Name, 0)
+		release, err := agent.GetRelease(form.Name, 0, false)
 
 		if err != nil {
 			app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -1136,6 +1136,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,
@@ -1219,7 +1220,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	rel, err := agent.GetRelease(form.Name, 0)
+	rel, err := agent.GetRelease(form.Name, 0, false)
 
 	// repository is set to current repository by default
 	commit := vals["commit"][0]
@@ -1401,7 +1402,7 @@ func (app *App) HandleReleaseUpdateJobImages(w http.ResponseWriter, r *http.Requ
 		go func() {
 			defer wg.Done()
 			// read release via agent
-			rel, err := agent.GetRelease(releases[index].Name, 0)
+			rel, err := agent.GetRelease(releases[index].Name, 0, false)
 
 			if err != nil {
 				mu.Lock()
@@ -1494,7 +1495,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// get the full release data for GHA updating
-	rel, err := agent.GetRelease(form.Name, form.Revision)
+	rel, err := agent.GetRelease(form.Name, form.Revision, false)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusNotFound, HTTPError{
@@ -1586,6 +1587,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,

+ 8 - 2
server/api/user_handler.go

@@ -58,6 +58,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
 		}))
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -402,8 +403,8 @@ func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request)
 	w.WriteHeader(http.StatusOK)
 }
 
-// FinalizEmailVerifyUser completes the email verification flow for a user.
-func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+// FinalizeEmailVerifyUser completes the email verification flow for a user.
+func (app *App) FinalizeEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
 	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
@@ -488,6 +489,11 @@ func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.UserVerifyEmailTrack(&analytics.UserVerifyEmailTrackOpts{
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		Email:               user.Email,
+	}))
+
 	http.Redirect(w, r, "/dashboard", 302)
 	return
 }

+ 34 - 0
server/api/welcome_handler.go

@@ -0,0 +1,34 @@
+package api
+
+import (
+	"net/http"
+	"net/url"
+)
+
+// HandleGetCapabilities gets the capabilities of the server
+func (app *App) HandleWelcome(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		return
+	}
+
+	req, err := http.NewRequest("GET", app.ServerConf.WelcomeFormWebhook, nil)
+
+	if err != nil {
+		return
+	}
+
+	q := req.URL.Query()
+	q.Add("email", vals["email"][0])
+	q.Add("isCompany", vals["isCompany"][0])
+	q.Add("company", vals["company"][0])
+	q.Add("role", vals["role"][0])
+	req.URL.RawQuery = q.Encode()
+
+	_, err = http.Get(req.URL.String())
+
+	if err != nil {
+		return
+	}
+}

+ 18 - 3
server/router/router.go

@@ -128,7 +128,7 @@ func New(a *api.App) *chi.Mux {
 				"GET",
 				"/email/verify/finalize",
 				auth.BasicAuthenticateWithRedirect(
-					requestlog.NewHandler(a.FinalizEmailVerifyUser, l),
+					requestlog.NewHandler(a.FinalizeEmailVerifyUser, l),
 				),
 			)
 
@@ -915,7 +915,11 @@ func New(a *api.App) *chi.Mux {
 				"POST",
 				"/projects/{project_id}/releases/{name}/notifications",
 				auth.DoesUserHaveProjectAccess(
-					requestlog.NewHandler(a.HandleUpdateNotificationConfig, l),
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleUpdateNotificationConfig, l),
+						mw.URLParam,
+						mw.BodyParam,
+					),
 					mw.URLParam,
 					mw.WriteAccess,
 				),
@@ -925,7 +929,11 @@ func New(a *api.App) *chi.Mux {
 				"GET",
 				"/projects/{project_id}/releases/{name}/notifications",
 				auth.DoesUserHaveProjectAccess(
-					requestlog.NewHandler(a.HandleGetNotificationConfig, l),
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetNotificationConfig, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
 					mw.URLParam,
 					mw.WriteAccess,
 				),
@@ -1725,6 +1733,13 @@ func New(a *api.App) *chi.Mux {
 				http.HandlerFunc(a.HandleGetCapabilities),
 			)
 
+			// welcome form
+			r.Method(
+				"GET",
+				"/welcome",
+				http.HandlerFunc(a.HandleWelcome),
+			)
+
 			// /api/projects/{project_id}/deploy routes
 			r.Method(
 				"POST",