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

Merge pull request #1142 from porter-dev/master

provisioning metrics tracking
abelanger5 4 лет назад
Родитель
Сommit
d0c335bb94

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

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

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

@@ -316,9 +316,9 @@ const ChartList: React.FunctionComponent<Props> = ({
         const status: JobStatusWithTimeAndVersion = _.get(
           jobStatus,
           getChartKey(chart.name, chart.namespace),
-          null
+          { status: null } as any
         );
-        return !status || status.status === lastRunStatus;
+        return status.status === lastRunStatus;
       });
 
     if (sortType == "Newest") {

+ 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

+ 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>
         </>
@@ -53,6 +55,7 @@ class Navbar extends Component<PropsType, StateType> {
   };
 
   renderFeedbackButton = () => {
+    console.log("hi", this.context?.capabilities)
     if (this.context?.capabilities?.provisioner) {
       return <Feedback currentView={this.props.currentView} />;
     }
@@ -80,6 +83,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 +130,7 @@ const CloseOverlay = styled.div`
 
 const UserDropdownButton = styled.button`
   padding: 13px;
+  position: relative;
   height: 40px;
   font-size: 13px;
   font-weight: 500;

+ 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 - 345
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);
+        }
+        return provisionEKS(awsIntegrationId);
+      });
+    // 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,156 +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 - 236
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,138 +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%;

+ 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
 

+ 10 - 4
internal/integrations/ci/actions/actions.go

@@ -415,25 +415,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,
 	}
 

+ 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