Browse Source

Merge pull request #2720 from porter-dev/capi-provisioner-fe

Capi provisioner fe
jusrhee 3 years ago
parent
commit
5774bdce98
36 changed files with 1928 additions and 270 deletions
  1. 35 0
      api/server/handlers/project/create_cluster.go
  2. 28 0
      api/server/router/project.go
  3. BIN
      dashboard/src/assets/add-circle.png
  4. BIN
      dashboard/src/assets/creds.png
  5. BIN
      dashboard/src/assets/lightning-square-contained.png
  6. BIN
      dashboard/src/assets/lightning.png
  7. 21 0
      dashboard/src/assets/settings-centered.svg
  8. 341 0
      dashboard/src/components/CredentialsForm.tsx
  9. 78 0
      dashboard/src/components/ExpandableSection.tsx
  10. 157 0
      dashboard/src/components/ProvisionerFlow.tsx
  11. 75 0
      dashboard/src/components/ProvisionerForm.tsx
  12. 223 0
      dashboard/src/components/ProvisionerSettings.tsx
  13. 1 1
      dashboard/src/components/SaveButton.tsx
  14. 22 33
      dashboard/src/components/Selector.tsx
  15. 3 1
      dashboard/src/components/TitleSection.tsx
  16. 2 0
      dashboard/src/components/form-components/SelectRow.tsx
  17. 2 2
      dashboard/src/components/repo-selector/ActionDetails.tsx
  18. 4 4
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  19. 111 31
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  20. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  21. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  22. 68 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx
  23. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  24. 4 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  25. 142 143
      dashboard/src/main/home/dashboard/ClusterList.tsx
  26. 1 1
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  27. 173 0
      dashboard/src/main/home/dashboard/ClusterSection.tsx
  28. 68 27
      dashboard/src/main/home/dashboard/Dashboard.tsx
  29. 264 0
      dashboard/src/main/home/dashboard/OldClusterList.tsx
  30. 1 1
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  31. 46 1
      dashboard/src/main/home/onboarding/Onboarding.tsx
  32. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  33. 35 0
      dashboard/src/shared/api.tsx
  34. 6 4
      dashboard/src/shared/common.tsx
  35. 3 0
      dashboard/src/shared/types.tsx
  36. 9 8
      internal/models/project.go

+ 35 - 0
api/server/handlers/project/create_cluster.go

@@ -0,0 +1,35 @@
+package project
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type CreateClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewProvisionClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateClusterHandler {
+	return &CreateClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// TODO: implement
+func (c *CreateClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	bytes, err := io.ReadAll(r.Body)
+	if err != nil {
+		return
+	}
+	fmt.Println("Provisioning attempt received:")
+	fmt.Println(string(bytes))
+}

+ 28 - 0
api/server/router/project.go

@@ -1261,5 +1261,33 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// POST /api/project/{project_id}/provision/cluster -> project.NewProvisionClusterHandler
+	provisionClusterEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/provision/cluster",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	provisionClusterHandler := project.NewProvisionClusterHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: provisionClusterEndpoint,
+		Handler:  provisionClusterHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

BIN
dashboard/src/assets/add-circle.png


BIN
dashboard/src/assets/creds.png


BIN
dashboard/src/assets/lightning-square-contained.png


BIN
dashboard/src/assets/lightning.png


+ 21 - 0
dashboard/src/assets/settings-centered.svg

@@ -0,0 +1,21 @@
+<svg width="28" height="30" viewBox="0 0 28 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_4_2)">
+<g filter="url(#filter0_d_4_2)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21.9024 16.58C22.26 16.77 22.536 17.07 22.7301 17.37C23.1083 17.99 23.0776 18.75 22.7097 19.42L21.9943 20.62C21.6162 21.26 20.9111 21.66 20.1855 21.66C19.8278 21.66 19.4292 21.56 19.1022 21.36C18.8365 21.19 18.5299 21.13 18.2029 21.13C17.1911 21.13 16.3429 21.96 16.3122 22.95C16.3122 24.1 15.372 25 14.1968 25H12.8069C11.6215 25 10.6813 24.1 10.6813 22.95C10.6608 21.96 9.81259 21.13 8.80085 21.13C8.4636 21.13 8.15702 21.19 7.90153 21.36C7.5745 21.56 7.16572 21.66 6.81825 21.66C6.08244 21.66 5.37729 21.26 4.99917 20.62L4.29402 19.42C3.91589 18.77 3.89545 17.99 4.27358 17.37C4.43709 17.07 4.74368 16.77 5.09115 16.58C5.37729 16.44 5.56125 16.21 5.73498 15.94C6.24596 15.08 5.93937 13.95 5.07071 13.44C4.05897 12.87 3.73194 11.6 4.31446 10.61L4.99917 9.43C5.5919 8.44 6.85913 8.09 7.88109 8.67C8.77019 9.15 9.92499 8.83 10.4462 7.98C10.6097 7.7 10.7017 7.4 10.6813 7.1C10.6608 6.71 10.7732 6.34 10.9674 6.04C11.3455 5.42 12.0302 5.02 12.7763 5H14.2172C14.9735 5 15.6582 5.42 16.0363 6.04C16.2203 6.34 16.3429 6.71 16.3122 7.1C16.2918 7.4 16.3838 7.7 16.5473 7.98C17.0685 8.83 18.2233 9.15 19.1226 8.67C20.1344 8.09 21.4118 8.44 21.9943 9.43L22.679 10.61C23.2718 11.6 22.9448 12.87 21.9228 13.44C21.0541 13.95 20.7475 15.08 21.2687 15.94C21.4323 16.21 21.6162 16.44 21.9024 16.58ZM10.6097 15.01C10.6097 16.58 11.9076 17.83 13.5121 17.83C15.1166 17.83 16.3838 16.58 16.3838 15.01C16.3838 13.44 15.1166 12.18 13.5121 12.18C11.9076 12.18 10.6097 13.44 10.6097 15.01Z" fill="white"/>
+</g>
+</g>
+<defs>
+<filter id="filter0_d_4_2" x="0" y="5" width="27" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="4"/>
+<feGaussianBlur stdDeviation="2"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_2"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_2" result="shape"/>
+</filter>
+<clipPath id="clip0_4_2">
+<rect width="28" height="30" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 341 - 0
dashboard/src/components/CredentialsForm.tsx

@@ -0,0 +1,341 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import aws from "assets/aws.png";
+import credsIcon from "assets/creds.png";
+import addCircle from "assets/add-circle.png";
+
+import { Context } from "shared/Context";
+
+import Heading from "components/form-components/Heading";
+import Helper from "./form-components/Helper";
+import InputRow from "./form-components/InputRow";
+import SaveButton from "./SaveButton";
+
+type Props = {
+  goBack: () => void;
+  proceed: (x: any) => void;
+};
+
+type AWSCredential = {
+  created_at: string;
+  id: number;
+  user_id: number;
+  project_id: number;
+  aws_arn: string;
+};
+
+
+const CredentialsForm: React.FC<Props> = ({
+  goBack,
+  proceed,
+}) => {
+  const { currentProject } = useContext(Context);
+  const [awsCredentials, setAWSCredentials] = useState<AWSCredential[]>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [awsAccessKeyID, setAWSAccessKeyID] = useState("");
+  const [awsSecretAccessKey, setAWSSecretAccessKey] = useState("");
+  const [selectedCredentials, setSelectedCredentials] = useState<AWSCredential>(null);
+  const [showCreateForm, setShowCreateForm] = useState(false);
+  const [createStatus, setCreateStatus] = useState("");
+
+  useEffect(() => {
+    api
+      .getAWSIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+        setAWSCredentials(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject]);
+
+  const createCreds = () => {
+    setCreateStatus("loading");
+
+    api
+      .createAWSIntegration(
+        "<token>",
+        {
+          // Hardcoded for backward-compatibility
+          // TODO: remove
+          aws_region: "us-east-f",
+
+          aws_access_key_id: awsAccessKeyID,
+          aws_secret_access_key: awsSecretAccessKey,
+          aws_assume_role_arn: "",
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setCreateStatus("successful");
+        proceed(data.id);
+      })
+      .catch((err) => {
+        console.error(err);
+        setCreateStatus("Error creating credentials");
+      });
+  };
+
+  const renderContent = () => {
+    if (awsCredentials.length > 0 && !showCreateForm) {
+      return (
+        <>
+          <CredentialList>
+            {
+              awsCredentials.map((cred: AWSCredential, i: number) => {
+                return (
+                  <Credential 
+                    key={cred.id}
+                    isSelected={cred.id === selectedCredentials?.id}
+                    onClick={() => {
+                      if (cred.id === selectedCredentials?.id) {
+                        setSelectedCredentials(null);
+                      } else {
+                        setSelectedCredentials(cred);
+                      }
+                    }}
+                  >
+                    <Icon src={credsIcon} />
+                    <Name>{cred.aws_arn || "n/a"}</Name>
+                  </Credential>
+                );
+              })
+            }
+            <CreateRow onClick={() => {
+              setShowCreateForm(true);
+              setSelectedCredentials(null);
+            }}>
+              <Icon src={addCircle} />
+              Add new AWS credentials
+            </CreateRow>
+          </CredentialList>
+          <Br height="34px" />
+          <SaveButton
+            disabled={!selectedCredentials && true}
+            onClick={() => proceed(selectedCredentials.id)}
+            clearPosition
+            text="Continue"
+          />
+        </>
+      );
+    }
+    return (
+      <>
+        <StyledForm>
+          {
+            awsCredentials.length > 0 && (
+              <CloseButton width="172px" onClick={() => setShowCreateForm(false)}>
+                <i className="material-icons">close</i>
+              </CloseButton>
+            )
+          }
+          <InputRow 
+            type="string"
+            value={awsAccessKeyID}
+            setValue={(e: string) => setAWSAccessKeyID(e)}
+            label="👤 AWS access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            isRequired
+          />
+          <InputRow 
+            type="password"
+            value={awsSecretAccessKey}
+            setValue={(e: string) => setAWSSecretAccessKey(e)}
+            label="🔒 AWS secret key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            isRequired
+          />
+        </StyledForm>
+        <SaveButton
+          disabled={awsAccessKeyID === "" || awsSecretAccessKey === ""}
+          onClick={createCreds}
+          status={createStatus}
+          statusPosition="right"
+          clearPosition
+          text="Continue"
+        />
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Heading isAtTop>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer />
+        <Img src={aws} />
+        Set AWS credentials
+      </Heading>
+      <Helper>
+        Select your credentials from the list below, or add a new set of credentials:
+      </Helper>
+      {
+        isLoading ? (
+          <>Loading . . .</>
+        ) : (
+          renderContent()
+        )
+      }
+    </>
+  );
+};
+
+export default CredentialsForm;
+
+const CloseButton = styled.div`
+  position: absolute;
+  top: 15px;
+  right: 15px;
+  padding: 5px;
+  border-radius: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+    > i {
+      color: #ffffff;
+    }
+  }
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const Spacer = styled.div`
+  height: 1px;
+  width: 17px;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 15px;
+`;
+
+const CreateRow = styled.div`
+  height: 50px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  padding: 20px;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff18; 
+  }
+`;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "20px"};
+`;
+
+const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const BackArrow = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: -5px;
+  margin-right: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 10px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const Name = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+`;
+
+const Credential = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  height: 50px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 20px;
+  border-bottom: ${props => props.isLast ? "" : "1px solid #7a7b80"};
+  background: ${props => props.isSelected ? "#ffffff33" : "#ffffff11"};
+
+  :hover {
+    background: ${props => props.isSelected ? "" : "#ffffff18"}; 
+  }
+`;
+
+const CredentialList = styled.div`
+  width: 100%;
+  border: 1px solid #7a7b80;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding: 15px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+`;

+ 78 - 0
dashboard/src/components/ExpandableSection.tsx

@@ -0,0 +1,78 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  Header: any;
+  ExpandedSection: any;
+};
+
+const ExpandableSection: React.FC<Props> = ({
+  Header,
+  ExpandedSection,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledExpandableSection isExpanded={isExpanded}>
+      <HeaderRow 
+        isExpanded={isExpanded}
+        onClick={() => setIsExpanded(!isExpanded)}
+      >
+        <i className="material-icons">arrow_drop_down</i> 
+        {Header}
+      </HeaderRow>
+      {
+        isExpanded && (
+          ExpandedSection
+        )
+      }
+    </StyledExpandableSection>
+  );
+};
+
+export default ExpandableSection;
+
+const HeaderRow = styled.div<{ isExpanded: boolean }>`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: ${props => props.isExpanded && "#ffffff18"};
+  }
+
+  > i {
+    margin-right: 8px;
+    color: #ffffff66;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const StyledExpandableSection = styled.div<{ isExpanded: boolean }>`
+  width: 100%;
+  height: ${props => props.isExpanded ? "" : "40px"};
+  max-height: 255px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${props => props.isExpanded ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;

+ 157 - 0
dashboard/src/components/ProvisionerFlow.tsx

@@ -0,0 +1,157 @@
+import React, { useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+
+import { integrationList } from "shared/common";
+import { Context } from "shared/Context";
+
+import ProvisionerForm from "components/ProvisionerForm";
+import CredentialsForm from "components/CredentialsForm";
+import Helper from "components/form-components/Helper";
+
+const providers = ["aws", "gcp", "azure"];
+
+type Props = {
+};
+
+const ProvisionerFlow: React.FC<Props> = ({
+}) => {
+  const { usage, hasBillingEnabled } = useContext(Context);
+  const [currentStep, setCurrentStep] = useState("cloud");
+  const [credentialId, setCredentialId] = useState(null);
+
+  const isUsageExceeded = useMemo(() => {
+    if (!hasBillingEnabled) {
+      return false;
+    }
+    return usage.current.clusters >= usage.limit.clusters;
+  }, [usage]);
+
+  if (currentStep === "cloud") {
+    return (
+      <StyledProvisionerFlow>
+        <Helper>
+          Select your hosting backend:
+        </Helper>
+        <BlockList>
+          {providers.map((provider: string, i: number) => {
+            let providerInfo = integrationList[provider];
+            return (
+              <Block
+                key={i}
+                disabled={isUsageExceeded || provider === "gcp" || provider === "azure"}
+                onClick={() => {
+                  if (!(isUsageExceeded || provider === "gcp" || provider === "azure")) {
+                    setCurrentStep("credentials");
+                  }
+                }}
+              >
+                <Icon src={providerInfo.icon} />
+                <BlockTitle>{providerInfo.label}</BlockTitle>
+                <BlockDescription>{providerInfo.tagline || "Hosted in your own cloud"}</BlockDescription>
+              </Block>
+            );
+          })}
+        </BlockList>
+      </StyledProvisionerFlow>
+    );
+  } else if (currentStep === "credentials") {
+    return (
+      <CredentialsForm 
+        goBack={() => setCurrentStep("cloud")}
+        proceed={(id) => {
+          setCredentialId(id);
+          setCurrentStep("cluster");
+        }}
+      />
+    );
+  } else if (currentStep === "cluster") {
+    return (
+      <ProvisionerForm
+        goBack={() => setCurrentStep("credentials")}
+        credentialId={credentialId}
+      />
+    );
+  }
+};
+
+export default ProvisionerFlow;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: 400;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  height: 170px;
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8) grayscale(1)" : "")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledProvisionerFlow = styled.div`
+  margin-top: -24px;
+`;

+ 75 - 0
dashboard/src/components/ProvisionerForm.tsx

@@ -0,0 +1,75 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import aws from "assets/aws.png";
+
+import Heading from "components/form-components/Heading";
+import Helper from "./form-components/Helper";
+import ProvisionerSettings from "./ProvisionerSettings";
+
+type Props = {
+  goBack: () => void;
+  credentialId: any;
+};
+
+const ProvisionerForm: React.FC<Props> = ({
+  goBack,
+  credentialId,
+}) => {
+  return (
+    <>
+      <Heading isAtTop>
+        <BackButton width="155px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Set credentials
+        </BackButton>
+        <Spacer />
+        <Img src={aws} />
+        Configure settings
+      </Heading>
+      <Helper>
+        Configure settings for your new cluster. 
+      </Helper>
+      <ProvisionerSettings credentialId={credentialId} />
+    </>
+  );
+};
+
+export default ProvisionerForm;
+
+const Spacer = styled.div`
+  height: 1px;
+  width: 17px;
+`;
+
+const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;

+ 223 - 0
dashboard/src/components/ProvisionerSettings.tsx

@@ -0,0 +1,223 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import aws from "assets/aws.png";
+
+import { Context } from "shared/Context";
+
+import SelectRow from "components/form-components/SelectRow";
+import Heading from "components/form-components/Heading";
+import InputRow from "./form-components/InputRow";
+import SaveButton from "./SaveButton";
+
+const regionOptions = [
+  { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
+  { value: "us-east-2", label: "US East (Ohio) us-east-2" },
+  { value: "us-west-1", label: "US West (N. California) us-west-1" },
+  { value: "us-west-2", label: "US West (Oregon) us-west-2" },
+  { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
+  { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
+  { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
+  { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
+  { value: "ap-southeast-2", label: "Asia Pacific (Sydney) ap-southeast-2" },
+  { value: "ap-northeast-1", label: "Asia Pacific (Tokyo) ap-northeast-1" },
+  { value: "ca-central-1", label: "Canada (Central) ca-central-1" },
+  { value: "eu-central-1", label: "Europe (Frankfurt) eu-central-1" },
+  { value: "eu-west-1", label: "Europe (Ireland) eu-west-1" },
+  { value: "eu-west-2", label: "Europe (London) eu-west-2" },
+  { value: "eu-south-1", label: "Europe (Milan) eu-south-1" },
+  { value: "eu-west-3", label: "Europe (Paris) eu-west-3" },
+  { value: "eu-north-1", label: "Europe (Stockholm) eu-north-1" },
+  { value: "me-south-1", label: "Middle East (Bahrain) me-south-1" },
+  { value: "sa-east-1", label: "South America (São Paulo) sa-east-1" },
+];
+
+const machineTypeOptions = [
+  { value: "t3.medium", label: "t3.medium" },
+  { value: "t3.xlarge", label: "t3.xlarge" },
+  { value: "t3.2xlarge", label: "t3.2xlarge" },
+];
+
+type Props = {
+  credentialId: any;
+  clusterId?: number;
+};
+
+const ProvisionerForm: React.FC<Props> = ({
+  credentialId,
+  clusterId,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [createStatus, setCreateStatus] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [awsRegion, setAwsRegion] = useState("us-east-1");
+  const [machineType, setMachineType] = useState("t3.medium");
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [minInstances, setMinInstances] = useState(1);
+  const [maxInstances, setMaxInstances] = useState(10);
+  const [cidrRange, setCidrRange] = useState("172.0.0.0/16");
+  const [isReadOnly, setIsReadOnly] = useState(false);
+
+  const createCluster = async () => {
+    var data: any = {
+      project_id: currentProject.id,
+      cloud_provider: "aws",
+      cloud_provider_credentials_id: credentialId,
+      cluster_settings: {
+        cluster_name: clusterName,
+        cluster_version: "v1.24.0",
+        cidr_range: cidrRange || "172.0.0.0/16",
+        region: awsRegion,
+        node_groups: [
+          {
+            instance_type: "t3.medium",
+            min_instances: 1,
+            max_instances: 5,
+            node_group_type: 1
+          },
+          {
+            instance_type: machineType,
+            min_instances: minInstances || 1,
+            max_instances: maxInstances || 10,
+            node_group_type: 3
+          }
+        ]
+      }
+    };
+
+    if (clusterId) {
+      data["cluster_id"] = clusterId;
+    }
+
+    try {
+      await api.provisionCluster(
+        "<token>",
+        data,
+        { project_id: currentProject.id }
+      );
+    } catch (err) {
+      console.log(err);
+    }
+  }
+
+  useEffect(() => {
+    setIsReadOnly(
+      currentCluster.status === "UPDATING" || 
+      currentCluster.status === "UPDATING_UNAVAILABLE"
+    );
+  }, []);
+
+  return (
+    <>
+      <StyledForm>
+        <Heading isAtTop>EKS configuration</Heading>
+        <InputRow
+          width="350px"
+          isRequired
+          disabled={isReadOnly}
+          type="string"
+          value={clusterName}
+          setValue={(x: string) => setClusterName(x)}
+          label="🏷️ Cluster name"
+          placeholder="ex: total-perspective-vortex"
+        />
+        <SelectRow
+          options={regionOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={awsRegion}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setAwsRegion}
+          label="📍 AWS Region"
+        />
+        <SelectRow
+          options={machineTypeOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={machineType}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setMachineType}
+          label="⚙️ Machine type"
+        />
+
+        <Heading>
+          <ExpandHeader
+            onClick={() => setIsExpanded(!isExpanded)}
+            isExpanded={isExpanded}
+          >
+            <i className="material-icons">arrow_drop_down</i>
+            Advanced settings
+          </ExpandHeader>
+        </Heading>
+        {
+          isExpanded && (
+            <>
+              <InputRow
+                width="350px"
+                type="number"
+                disabled={isReadOnly}
+                value={minInstances}
+                setValue={(x: number) => setMinInstances(x)}
+                label="Minimum number of application EC2 instances"
+                placeholder="ex: 1"
+              />
+              <InputRow
+                width="350px"
+                type="number"
+                disabled={isReadOnly}
+                value={maxInstances}
+                setValue={(x: number) => setMaxInstances(x)}
+                label="Minimum number of application EC2 instances"
+                placeholder="ex: 1"
+              />
+              <InputRow
+                width="350px"
+                type="string"
+                disabled={isReadOnly}
+                value={cidrRange}
+                setValue={(x: string) => setCidrRange(x)}
+                label="VPC CIDR range"
+                placeholder="ex: 172.0.0.0/16"
+              />
+            </>
+          )
+        }
+      </StyledForm>
+      <SaveButton
+        disabled={(!clusterName && true) || isReadOnly}
+        onClick={createCluster}
+        clearPosition
+        text="Provision"
+        statusPosition="right"
+        status={isReadOnly && "Provisioning is still in progress"}
+      />
+    </>
+  );
+};
+
+export default ProvisionerForm;
+
+const ExpandHeader = styled.div<{ isExpanded: boolean }>`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  > i {
+    margin-right: 7px;
+    margin-left: -7px;
+    transform: ${(props) => props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+  }
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding: 30px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+`;

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

@@ -200,7 +200,7 @@ const Button = styled.button<{
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
   user-select: none;
   :focus {
     outline: 0;

+ 22 - 33
dashboard/src/components/Selector.tsx

@@ -10,6 +10,7 @@ export type SelectorPropsType = {
   setActiveValue: (x: string) => void;
   width: string;
   height?: string;
+  disabled?: boolean;
   dropdownLabel?: string;
   dropdownWidth?: string;
   dropdownMaxHeight?: string;
@@ -162,11 +163,14 @@ export default class Selector extends Component<SelectorPropsType, StateType> {
       <StyledSelector width={this.props.width}>
         <MainSelector
           ref={this.parentRef}
+          disabled={this.props.disabled}
           onClick={() => {
-            if (this.props.refreshOptions) {
-              this.props.refreshOptions();
+            if (!this.props.disabled) {
+              if (this.props.refreshOptions) {
+                this.props.refreshOptions();
+              }
+              this.setState({ expanded: !this.state.expanded });
             }
-            this.setState({ expanded: !this.state.expanded });
           }}
           expanded={this.state.expanded}
           width={this.props.width}
@@ -306,15 +310,6 @@ const Option = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-`;
-
 const Dropdown = styled.div`
   background: #26282f;
   width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
@@ -333,40 +328,34 @@ const StyledSelector = styled.div<{ width: string }>`
   width: ${(props) => props.width};
 `;
 
-const MainSelector = styled.div`
-  width: ${(props: { expanded: boolean; width: string; height?: string }) =>
-    props.width};
-  height: ${(props: { expanded: boolean; width: string; height?: string }) =>
-    props.height ? props.height : "35px"};
+const MainSelector = styled.div<{ 
+  disabled?: boolean;
+  expanded: boolean;
+  width: string;
+  height?: string;
+}>`
+  width: ${props => props.width};
+  height: ${props => props.height ? props.height : "35px"};
   border: 1px solid #ffffff55;
   font-size: 13px;
   padding: 5px 10px;
   padding-left: 15px;
   border-radius: 3px;
   display: flex;
+  color: ${props => props.disabled ? "#ffffff44" : "#ffffff"};
   justify-content: space-between;
   align-items: center;
-  cursor: pointer;
-  background: ${(props: {
-    expanded: boolean;
-    width: string;
-    height?: string;
-  }) => (props.expanded ? "#ffffff33" : "#ffffff11")};
+  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
+  background: ${props => props.expanded ? "#ffffff33" : "#ffffff11"};
   :hover {
-    background: ${(props: {
-      expanded: boolean;
-      width: string;
-      height?: string;
-    }) => (props.expanded ? "#ffffff33" : "#ffffff22")};
+    background: ${props => props.expanded ? "#ffffff33" : (
+      props.disabled ? "#ffffff11" : "#ffffff22"
+    )};
   }
 
   > i {
     font-size: 20px;
-    transform: ${(props: {
-      expanded: boolean;
-      width: string;
-      height?: string;
-    }) => (props.expanded ? "rotate(180deg)" : "")};
+    transform: ${props => props.expanded ? "rotate(180deg)" : ""};
   }
 `;
 

+ 3 - 1
dashboard/src/components/TitleSection.tsx

@@ -57,7 +57,7 @@ const BackButton = styled.div`
   > i {
     cursor: pointer;
     font-size: 24px;
-    color: #969fbbaa;
+    color: #aaaabb;
     margin-right: 10px;
     padding: 3px;
     margin-left: 0px;
@@ -78,6 +78,8 @@ const StyledTitleSection = styled.div`
 const Icon = styled.img<{ width: string }>`
   width: ${(props) => props.width || "25px"};
   margin-right: 16px;
+  display: flex;
+  align-items: center;
 `;
 
 const MaterialIcon = styled.span<{ width: string }>`

+ 2 - 0
dashboard/src/components/form-components/SelectRow.tsx

@@ -13,6 +13,7 @@ type PropsType = {
   dropdownMaxHeight?: string;
   scrollBuffer?: boolean;
   doc?: string;
+  disabled?: boolean;
   selectorProps?: Partial<SelectorPropsType>;
 };
 
@@ -32,6 +33,7 @@ export default class SelectRow extends Component<PropsType, StateType> {
         </Wrapper>
         <SelectWrapper>
           <Selector
+            disabled={this.props.disabled}
             scrollBuffer={this.props.scrollBuffer}
             activeValue={this.props.value}
             setActiveValue={this.props.setActiveValue}

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

@@ -153,7 +153,7 @@ const ActionDetails: React.FC<PropsType> = (props) => {
               onClick={() => setShowBuildpacksConfig((prev) => !prev)}
               isExpanded={showBuildpacksConfig}
             >
-              Buildpacks Settings
+              Buildpacks settings
               <i className="material-icons">arrow_drop_down</i>
             </ExpandHeader>
           </Heading>
@@ -217,7 +217,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
   cursor: pointer;
   > i {
     margin-left: 10px;
-    transform: ${(props) => (props.isExpanded ? "" : "rotate(180deg)")};
+    transform: ${(props) => (props.isExpanded ? "rotate(180deg)" : "")};
   }
 `;
 

+ 4 - 4
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -68,9 +68,9 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #8b949f;
+  color: #aaaabb;
   margin-top: 13px;
-  margin-left: 2px;
+  margin-left: 1px;
   font-size: 13px;
 `;
 
@@ -79,10 +79,10 @@ const InfoLabel = styled.div`
   height: 20px;
   display: flex;
   align-items: center;
-  color: #8b949f;
+  color: #aaaabb;
   font-size: 13px;
   > i {
-    color: #8b949f;
+    color: #aaaabb;
     font-size: 18px;
     margin-right: 5px;
   }

+ 111 - 31
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,10 +1,12 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
+import settings from "assets/settings-centered.svg";
 
+import DashboardHeader from "../DashboardHeader";
 import { Context } from "shared/Context";
 import TabSelector from "components/TabSelector";
-import Heading from "components/form-components/Heading";
-import TitleSection from "components/TitleSection";
+import ProvisionerSettings from "components/ProvisionerSettings";
+import ProvisionerStatus from "./ProvisionerStatus";
 import api from "shared/api";
 
 import NodeList from "./NodeList";
@@ -20,23 +22,13 @@ import CopyToClipboard from "components/CopyToClipboard";
 import Loading from "components/Loading";
 
 import { DetailedIngressError } from "shared/types";
-import SelectRow from "components/form-components/SelectRow";
 
 type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents" | "configuration";
 
-const tabOptions: {
+var tabOptions: {
   label: string;
   value: TabEnum;
-}[] = [
-  // { label: "Configuration", value: "configuration" },
-  { label: "Nodes", value: "nodes" },
-  /*
-  { label: "Incidents", value: "incidents" },
-  */
-  { label: "Metrics", value: "metrics" },
-  { label: "Namespaces", value: "namespaces" },
-  { label: "Settings", value: "settings" },
-];
+}[] = [{ label: "Additional settings", value: "settings" }];
 
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
@@ -55,30 +47,40 @@ export const Dashboard: React.FunctionComponent = () => {
         return <Metrics />;
       case "namespaces":
         return <NamespaceList />;
-      /*
       case "configuration":
         return (
-          <FormWrapper>
-            <Heading isAtTop>
-              Cluster configuration
-            </Heading>
-            <SelectRow
-              value={"us-east-1"}
-              width="150px"
-              options={[
-                { label: "us-east-1", value: "us-east-1" }
-              ]}
-              setActiveValue={(option) => null}
-              label="AWS region"
+          <>
+            <Br />
+            <ProvisionerSettings
+              clusterId={context.currentCluster.id}
+              credentialId={context.currentCluster.cloud_provider_credential_identifier}
             />
-          </FormWrapper>
+            <Div />
+          </>
         );
-      */
       default:
         return <NodeList />;
     }
   };
 
+  useEffect(() => {
+    if (
+      context.currentCluster.status !== "UPDATING_UNAVAILABLE" &&
+      !tabOptions.find((tab) => tab.value === "nodes")
+    ) {      
+      tabOptions.unshift({ label: "Namespaces", value: "namespaces" });
+      tabOptions.unshift({ label: "Metrics", value: "metrics" });
+      tabOptions.unshift({ label: "Nodes", value: "nodes" }); 
+    }
+    
+    if (
+      context.currentProject.capi_provisioner_enabled &&
+      !tabOptions.find((tab) => tab.value === "configuration")
+    ) {
+      tabOptions.unshift({ value: "configuration", label: "Configuration" });
+    } 
+  }, []);
+
   useEffect(() => {
     setCurrentTabOptions(
       tabOptions.filter((option) => {
@@ -99,7 +101,11 @@ export const Dashboard: React.FunctionComponent = () => {
 
   // Need to reset tab to reset views that don't auto-update on cluster switch (esp namespaces + settings)
   useEffect(() => {
-    setCurrentTab("nodes");
+    if (context.currentProject.capi_provisioner_enabled) {
+      setCurrentTab("configuration");
+    } else {
+      setCurrentTab("nodes");
+    }
   }, [context.currentCluster]);
 
   const renderIngressIp = (
@@ -172,13 +178,21 @@ export const Dashboard: React.FunctionComponent = () => {
 
   return (
     <>
+      <DashboardHeader
+        image={settings}
+        title={context.currentCluster.name}
+        description={`Cluster settings and status for ${context.currentCluster.name}.`}
+        disableLineBreak
+        capitalize={false}
+      />
+
+      {/*
       <TitleSection>
         <DashboardIcon>
           <i className="material-icons">device_hub</i>
         </DashboardIcon>
         {context.currentCluster.name}
       </TitleSection>
-
       <InfoSection>
         <TopRow>
           <InfoLabel>
@@ -187,6 +201,17 @@ export const Dashboard: React.FunctionComponent = () => {
         </TopRow>
         <Description>{renderIngressIp(ingressIp, ingressError)}</Description>
       </InfoSection>
+      */}
+
+      {
+        context.currentProject.capi_provisioner_enabled &&
+        (
+          context.currentCluster.status === "UPDATING" ||
+          context.currentCluster.status === "UPDATING_UNAVAILABLE"
+        ) && (
+          <ProvisionerStatus />
+        )
+      }
 
       <TabSelector
         options={currentTabOptions}
@@ -198,6 +223,61 @@ export const Dashboard: React.FunctionComponent = () => {
   );
 };
 
+const Div = styled.div`
+  width: 100%;
+  height: 50px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  :hover {
+    background: ${props => props.showRevisions && "#ffffff18"};
+    > div > i {
+      background: ${props => props.showRevisions && "#ffffff22"};
+    }
+  }
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  margin-top: 25px;
+  margin-bottom: 22px;
+
+  > div > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "#ffffff18" : ""};
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "rotate(180deg)" : ""};
+  }
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const DashboardIcon = styled.div`
   height: 35px;
   min-width: 35px;

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

@@ -566,7 +566,7 @@ const StyledMetricsSection = styled.div`
   flex-direction: column;
   position: relative;
   font-size: 13px;
-  border-radius: 8px;
+  border-radius: 5px;
   border: 1px solid #ffffff33;
   padding: 18px 22px;
   animation: floatIn 0.3s;

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

@@ -155,7 +155,7 @@ const StyledChart = styled.div`
   :not(:last-child) {
     margin-bottom: 25px;
   }
-  border-radius: 8px;
+  border-radius: 5px;
   background: #26292e;
   border: 1px solid #494b4f;
 `;

+ 68 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx

@@ -0,0 +1,68 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import loading from "assets/loading.gif";
+
+import { Context } from "shared/Context";
+import ExpandableSection from "components/ExpandableSection";
+
+type Props = {};
+
+const ProvisionerStatus: React.FC<Props> = ({}) => {
+  const { currentProject, setCurrentCluster } = useContext(Context);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledProvisionerStatus>
+      <ExpandableSection
+        Header={(
+          <>
+            <Icon src="https://img.stackshare.io/service/7991/amazon-eks.png" />
+            Elastic Kubernetes Service
+            <Status>
+              <Img src={loading} /> Updating
+            </Status>
+          </>
+        )}
+        ExpandedSection={(
+          <DummyLogs>[Logs unimplemented]</DummyLogs>
+        )}
+      />
+    </StyledProvisionerStatus>
+  );
+};
+
+export default ProvisionerStatus;
+
+const DummyLogs = styled.div`
+  height: 150px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  background: #101420;
+  font-family: monospace;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+  margin-right: 10px;
+`;
+
+const Img = styled.img`
+  height: 15px;
+  margin-right: 7px;
+`;
+
+const Status = styled.div`
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+  margin-left: 15px;
+`;
+
+const StyledProvisionerStatus = styled.div`
+  margin-bottom: 22px;
+`;

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

@@ -575,7 +575,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo && !isStack) {
+    //if (currentChart?.git_action_config?.git_repo && !isStack) {
+    if (true) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",

+ 4 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -323,11 +323,11 @@ class RevisionSection extends Component<PropsType, StateType> {
           }}
         >
           <RevisionPreview>
+            <i className="material-icons">arrow_drop_down</i>
             {isCurrent
               ? `Current revision`
               : `Previewing revision (not deployed)`}{" "}
             - <Revision>No. {this.props.chart.version}</Revision>
-            <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
           {this.props.shouldUpdate && isCurrent && (
             <div>
@@ -470,24 +470,19 @@ const RevisionHeader = styled.div`
   height: 40px;
   font-size: 13px;
   width: 100%;
-  padding-left: 15px;
+  padding-left: 10px;
   cursor: pointer;
   :hover {
     background: ${props => props.showRevisions && "#ffffff18"};
-    > div > i {
-      background: ${props => props.showRevisions && "#ffffff22"};
-    }
   }
 
   > div > i {
-    margin-left: 12px;
+    margin-right: 8px;
     font-size: 20px;
     cursor: pointer;
     border-radius: 20px;
-    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "#ffffff18" : ""};
     transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "rotate(180deg)" : ""};
+      props.showRevisions ? "" : "rotate(-90deg)"};
   }
 `;
 

+ 142 - 143
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -1,68 +1,82 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ClusterType, DetailedClusterType } from "shared/types";
-import Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
 
-import { RouteComponentProps, withRouter } from "react-router";
-
-import Modal from "../modals/Modal";
-import Heading from "components/form-components/Heading";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
-
-type StateType = {
-  loading: boolean;
-  error: string;
-  clusters: DetailedClusterType[];
-  showErrorModal?: {
-    clusterId: number;
-    show: boolean;
-  };
-};
-
-class Templates extends Component<PropsType, StateType> {
-  state: StateType = {
-    loading: true,
-    error: "",
-    clusters: [],
-    showErrorModal: undefined,
-  };
+import api from "shared/api";
+import loading from "assets/loading.gif";
+import Loading from "components/Loading";
 
-  componentDidMount() {
-    this.updateClusterList();
-  }
+import { Context } from "shared/Context";
 
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.currentCluster?.name != this.props.currentCluster?.name) {
-      this.updateClusterList();
-    }
-  }
+type Props = {};
 
-  updateClusterList = async () => {
-    try {
-      const res = await api.getClusters(
-        "<token>",
-        {},
-        { id: this.context.currentProject.id }
-      );
+const ClusterList: React.FC<Props> = ({}) => {
+  const { currentProject, setCurrentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [clusters, setClusters] = useState(null);
+  const location = useLocation();
+  const history = useHistory();
 
-      if (res.data) {
-        this.setState({ clusters: res.data, loading: false, error: "" });
-      } else {
-        this.setState({ loading: false, error: "Response data missing" });
-      }
-    } catch (err) {
-      this.setState(err);
-    }
-  };
+  useEffect(() => {
+    api.getClusters(
+      "<token>",
+      {},
+      { id: currentProject.id },
+    )
+      .then(({ data }) => {
+        setClusters(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        setIsLoading(false);
+      });
+   /*
+    const dummyData = [
+      {
+        id: 3,
+        project_id: 2,
+        name: "dummy-cluster-one",
+        server: "https://73727E5A0EF0FD07D24D7C1FDCE041E6.gr7.us-east-1.eks.amazonaws.com",
+        service: "eks",
+        agent_integration_enabled: false,
+        infra_id: 0,
+        aws_integration_id: 5,
+        preview_envs_enabled: true,
+        status: "READY",
+      },
+      {
+        id: 4,
+        project_id: 2,
+        name: "dummy-cluster-two",
+        server: "https://73727E5A0EF0FD07D24D7C1FDCE041E6.gr7.us-east-1.eks.amazonaws.com",
+        service: "eks",
+        agent_integration_enabled: false,
+        infra_id: 0,
+        aws_integration_id: 5,
+        preview_envs_enabled: true,
+        status: "UPDATING",
+      },
+      {
+        id: 5,
+        project_id: 2,
+        name: "dummy-cluster-three",
+        server: "https://73727E5A0EF0FD07D24D7C1FDCE041E6.gr7.us-east-1.eks.amazonaws.com",
+        service: "eks",
+        agent_integration_enabled: false,
+        infra_id: 0,
+        aws_integration_id: 5,
+        preview_envs_enabled: true,
+        status: "UPDATING_UNAVAILABLE",
+      },
+    ];
+    setClusters(dummyData);
+    setIsLoading(false);
+    */
+  }, [currentProject]);
 
-  renderIcon = () => {
+  const renderIcon = () => {
     return (
       <DashboardIcon>
         <svg
@@ -123,85 +137,73 @@ class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderClusters = () => {
-    return this.state.clusters.map(
-      (cluster: DetailedClusterType, i: number) => {
-        return (
-          <TemplateBlock
-            onClick={() => {
-              this.context.setCurrentCluster(cluster);
-              pushFiltered(this.props, "/applications", ["project_id"], {
-                cluster: cluster.name,
-              });
-            }}
-            key={i}
-          >
-            {this.renderIcon()}
-            <TemplateTitle>{cluster.name}</TemplateTitle>
-          </TemplateBlock>
-        );
+  return (
+    <>
+      {
+        isLoading ? (
+          <LoadingWrapper><Loading /></LoadingWrapper>
+        ) : (
+          <StyledClusterList>
+            {clusters.map((cluster: any) => {
+              return (
+                <ClusterRow
+                  onClick={() => {
+                    setCurrentCluster(cluster);
+                    pushFiltered({ location, history }, "/applications", ["project_id"], {
+                      cluster: cluster.name,
+                    });
+                  }}
+                >
+                  {renderIcon()}
+                  {cluster.name}
+                  {
+                    cluster.status === "UPDATING" && (
+                      <Status
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          setCurrentCluster(cluster);
+                          pushFiltered({ location, history }, "/cluster-dashboard", ["project_id"], {
+                            cluster: cluster.name,
+                          });
+                        }}
+                      >
+                        <Img src={loading} /> Updating
+                      </Status>
+                    )
+                  }
+                </ClusterRow>
+              )
+            })}
+          </StyledClusterList>
+        )
       }
-    );
-  };
-
-  renderErrorModal = () => {
-    const clusterError =
-      this.state.showErrorModal?.show &&
-      this.state.clusters.find(
-        (c) => c.id === this.state.showErrorModal?.clusterId
-      );
-    const ingressError = clusterError?.ingress_error;
-    return (
-      <>
-        {clusterError && (
-          <Modal
-            onRequestClose={() => this.setState({ showErrorModal: undefined })}
-            width="665px"
-            height="min-content"
-          >
-            Porter encountered an error. Full error log:
-            <CodeBlock>{ingressError.error}</CodeBlock>
-          </Modal>
-        )}
-      </>
-    );
-  };
-
-  render() {
-    return (
-      <StyledClusterList>
-        {/* <Heading isAtTop>Connected clusters</Heading> */}
-        <TemplateList>{this.renderClusters()}</TemplateList>
-        {this.renderErrorModal()}
-      </StyledClusterList>
-    );
-  }
-}
-
-Templates.contextType = Context;
+    </>
+  );
+};
 
-export default withRouter(Templates);
+export default ClusterList;
 
-const CodeBlock = styled.span`
-  display: block;
-  background-color: #1b1d26;
-  color: white;
-  border-radius: 5px;
-  font-family: monospace;
-  user-select: text;
-  max-height: 400px;
-  width: 90%;
-  margin-left: 5%;
-  margin-top: 20px;
-  overflow-y: auto;
-  padding: 10px;
-  overflow-wrap: break-word;
+const Img = styled.img`
+  height: 15px;
+  margin-right: 7px;
 `;
 
-const StyledClusterList = styled.div`
-  margin-top: -7px;
-  padding-left: 2px;
-  overflow: visible;
+const Status = styled.div`
+  margin-left: 15px;
+  border-radius: 50px;
+  padding: 5px 10px;
+  background: #ffffff11;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    background: #ffffff22;
+    border: 1px solid #7a7b80;
+    margin-top: -1px;
+    margin-bottom: -1px;
+    margin-left: 14px;
+  }
 `;
 
 const DashboardIcon = styled.div`
@@ -221,15 +223,7 @@ const DashboardIcon = styled.div`
   }
 `;
 
-const TemplateTitle = styled.div`
-  text-align: center;
-  white-space: nowrap;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const TemplateBlock = styled.div`
+const ClusterRow = styled.div`
   align-items: center;
   user-select: none;
   display: flex;
@@ -259,7 +253,12 @@ const TemplateBlock = styled.div`
   }
 `;
 
-const TemplateList = styled.div`
-  overflow-y: auto;
-  overflow: visible;
+const StyledClusterList = styled.div`
 `;
+
+const LoadingWrapper = styled.div`
+  height: calc(100vh - 450px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -4,7 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
-import ClusterList from "./ClusterList";
+import ClusterList from "./OldClusterList";
 import Loading from "components/Loading";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 

+ 173 - 0
dashboard/src/main/home/dashboard/ClusterSection.tsx

@@ -0,0 +1,173 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+
+import Banner from "components/Banner";
+
+import ProvisionerFlow from "components/ProvisionerFlow";
+import ClusterList from "./ClusterList";
+import TitleSection from "components/TitleSection";
+
+type Props = {
+};
+
+const ClusterSection = (props: Props) => {
+  const { usage } = useContext(Context);
+
+  const [currentStep, setCurrentStep] = useState("");
+
+  if (currentStep === "cloud") {
+    return (
+      <>
+        <TitleSection handleNavBack={() => setCurrentStep("")}>
+          <Title>
+            <ClusterIcon>
+              <svg
+                width="19"
+                height="19"
+                viewBox="0 0 19 19"
+                fill="none"
+                xmlns="http://www.w3.org/2000/svg"
+              >
+                <path
+                  d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+              </svg>
+            </ClusterIcon>
+            Provision a new cluster
+          </Title>
+        </TitleSection>
+        <Br height="7px" />
+        <Banner>
+          You have currently provisioned {usage?.current.cluster || "0"} out of {usage?.limit.clusters || "0"} clusters for this project.
+        </Banner>
+        <Br />
+        <ProvisionerFlow />
+      </>
+    );
+  }
+  return (
+    <>
+      <Button onClick={() => setCurrentStep("cloud")}>
+        <i className="material-icons">add</i> Create a cluster
+      </Button>
+      <ClusterList />
+    </>
+  );
+};
+
+export default ClusterSection;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "30px"};
+`;
+
+const ClusterIcon = styled.div`
+  > svg {
+    width: 20px;
+    display: flex;
+    align-items: center;
+    margin-bottom: -1x;
+    margin-right: 15px;
+    color: #ffffff;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 5px;
+  border-radius: 2px;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  width: 147px;
+  margin-bottom: 30px;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 68 - 27
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -6,18 +6,19 @@ import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 import api from "shared/api";
 
+import { RouteComponentProps, withRouter } from "react-router";
+
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
-import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
-import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
+import ClusterSection from "./ClusterSection";
+import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
+import Banner from "components/Banner";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
-import Banner from "components/Banner";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -25,9 +26,6 @@ type PropsType = RouteComponentProps &
     setRefreshClusters: (x: boolean) => void;
   };
 
-// TODO: rethink this list, should be coupled with tabOptions
-const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
-
 type StateType = {
   infras: InfraType[];
   pressingCtrl: boolean;
@@ -93,16 +91,8 @@ class Dashboard extends Component<PropsType, StateType> {
     if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
       this.refreshInfras();
     }
-
-    if (!tabOptionStrings.includes(this.currentTab())) {
-      this.setCurrentTab("overview");
-    }
   }
 
-  onShowProjectSettings = () => {
-    pushFiltered(this.props, "/project-settings", ["project_id"]);
-  };
-
   currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
 
   renderTabContents = () => {
@@ -138,10 +128,12 @@ class Dashboard extends Component<PropsType, StateType> {
     }
   };
 
-  setCurrentTab = (x: string) => {
-    pushQueryParams(this.props, { tab: x });
+  onShowProjectSettings = () => {
+    pushFiltered(this.props, "/project-settings", ["project_id"]);
   };
 
+  setCurrentTab = (x: string) => pushQueryParams(this.props, { tab: x });
+
   render() {
     let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
@@ -200,13 +192,19 @@ class Dashboard extends Component<PropsType, StateType> {
                     .
                   </Description>
                 </InfoSection>
-                <TabRegion
-                  currentTab={this.currentTab()}
-                  setCurrentTab={this.setCurrentTab}
-                  options={tabOptions}
-                >
-                  {this.renderTabContents()}
-                </TabRegion>
+                {
+                  currentProject.capi_provisioner_enabled ? (
+                    <ClusterSection />
+                  ) : (
+                    <TabRegion
+                      currentTab={this.currentTab()}
+                      setCurrentTab={this.setCurrentTab}
+                      options={tabOptions}
+                    >
+                      {this.renderTabContents()}
+                    </TabRegion>
+                  )
+                }
               </>
             )}
           </DashboardWrapper>
@@ -220,6 +218,49 @@ Dashboard.contextType = Context;
 
 export default withRouter(withAuth(Dashboard));
 
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  width: 147px;
+  margin-bottom: 30px;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 1px;
@@ -252,7 +293,7 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #8b949f;
+  color: #aaaabb;
   margin-top: 13px;
   margin-left: 2px;
   font-size: 13px;
@@ -263,10 +304,10 @@ const InfoLabel = styled.div`
   height: 20px;
   display: flex;
   align-items: center;
-  color: #8b949f;
+  color: #aaaabb;
   font-size: 13px;
   > i {
-    color: #8b949f;
+    color: #aaaabb;
     font-size: 18px;
     margin-right: 5px;
   }

+ 264 - 0
dashboard/src/main/home/dashboard/OldClusterList.tsx

@@ -0,0 +1,264 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ClusterType, DetailedClusterType } from "shared/types";
+import Helper from "components/form-components/Helper";
+import { pushFiltered } from "shared/routing";
+
+import { RouteComponentProps, withRouter } from "react-router";
+
+import Modal from "../modals/Modal";
+import Heading from "components/form-components/Heading";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  loading: boolean;
+  error: string;
+  clusters: DetailedClusterType[];
+  showErrorModal?: {
+    clusterId: number;
+    show: boolean;
+  };
+};
+
+class Templates extends Component<PropsType, StateType> {
+  state: StateType = {
+    loading: true,
+    error: "",
+    clusters: [],
+    showErrorModal: undefined,
+  };
+
+  componentDidMount() {
+    this.updateClusterList();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentCluster?.name != this.props.currentCluster?.name) {
+      this.updateClusterList();
+    }
+  }
+
+  updateClusterList = async () => {
+    try {
+      const res = await api.getClusters(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      );
+
+      if (res.data) {
+        this.setState({ clusters: res.data, loading: false, error: "" });
+      } else {
+        this.setState({ loading: false, error: "Response data missing" });
+      }
+    } catch (err) {
+      this.setState(err);
+    }
+  };
+
+  renderIcon = () => {
+    return (
+      <DashboardIcon>
+        <svg
+          width="16"
+          height="16"
+          viewBox="0 0 19 19"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+      </DashboardIcon>
+    );
+  };
+
+  renderClusters = () => {
+    return this.state.clusters.map(
+      (cluster: DetailedClusterType, i: number) => {
+        return (
+          <TemplateBlock
+            onClick={() => {
+              this.context.setCurrentCluster(cluster);
+              pushFiltered(this.props, "/applications", ["project_id"], {
+                cluster: cluster.name,
+              });
+            }}
+            key={i}
+          >
+            {this.renderIcon()}
+            <TemplateTitle>{cluster.name}</TemplateTitle>
+          </TemplateBlock>
+        );
+      }
+    );
+  };
+
+  renderErrorModal = () => {
+    const clusterError =
+      this.state.showErrorModal?.show &&
+      this.state.clusters.find(
+        (c) => c.id === this.state.showErrorModal?.clusterId
+      );
+    const ingressError = clusterError?.ingress_error;
+    return (
+      <>
+        {clusterError && (
+          <Modal
+            onRequestClose={() => this.setState({ showErrorModal: undefined })}
+            width="665px"
+            height="min-content"
+          >
+            Porter encountered an error. Full error log:
+            <CodeBlock>{ingressError.error}</CodeBlock>
+          </Modal>
+        )}
+      </>
+    );
+  };
+
+  render() {
+    return (
+      <StyledClusterList>
+        {/* <Heading isAtTop>Connected clusters</Heading> */}
+        <TemplateList>{this.renderClusters()}</TemplateList>
+        {this.renderErrorModal()}
+      </StyledClusterList>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+export default withRouter(Templates);
+
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
+`;
+
+const StyledClusterList = styled.div`
+  padding-left: 2px;
+  overflow: visible;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 25px;
+  min-width: 25px;
+  width: 25px;
+  border-radius: 200px;
+  margin-right: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 1px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TemplateTitle = styled.div`
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 15px;
+  margin-bottom: 20px;
+  align-item: center;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateList = styled.div`
+  overflow-y: auto;
+  overflow: visible;
+`;

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

@@ -48,7 +48,7 @@ const AccountSettingsModal = () => {
       />
 
       <Heading>
-        <GitIcon src={github} /> Github
+        <GitIcon src={github} /> GitHub
       </Heading>
       {accessLoading ? (
         <LoadingWrapper>

+ 46 - 1
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -1,4 +1,5 @@
 import Loading from "components/Loading";
+import ProvisionerFlow from "components/ProvisionerFlow";
 import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -9,6 +10,10 @@ import { OFState } from "./state";
 import { useSteps } from "./state/StepHandler";
 import { Onboarding as OnboardingSaveType } from "./types";
 
+import lightning from "assets/lightning.png";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+
 const Onboarding = () => {
   const context = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
@@ -147,13 +152,53 @@ const Onboarding = () => {
     checkIfUserHasClusters();
   }, [context?.currentProject?.id]);
 
+  const renderOnboarding = () => {
+    if (context?.currentProject?.capi_provisioner_enabled) {
+      return (
+        <Wrapper>
+          <DashboardHeader
+            image={lightning}
+            title="Getting started"
+            description="Create a new cluster in your own cloud provider to get started with Porter."
+            disableLineBreak
+            capitalize={false}
+          />
+          <Br />
+          <ProvisionerFlow />
+          <Div />
+        </Wrapper>
+      )
+    } else {
+      return (
+        <StyledOnboarding>
+          {isLoading ? <Loading /> : <Routes />}
+        </StyledOnboarding>
+      )
+    }
+  };
+
   return (
-    <StyledOnboarding>{isLoading ? <Loading /> : <Routes />}</StyledOnboarding>
+    <>{renderOnboarding()}</>
   );
 };
 
 export default Onboarding;
 
+const Div = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+  margin-top: -1px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+`;
+
 const ViewWrapper = styled.div`
   width: 100%;
   overflow-y: auto;

+ 1 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -134,7 +134,7 @@ export const ClusterSection: React.FC<Props> = ({
               window.location.pathname.startsWith("/cluster-dashboard")
             }
           >
-            <Icon className="material-icons">device_hub</Icon>
+            <Img enlarge={true} src={settings} />
             Cluster settings
           </NavButton>
         </Relative>

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

@@ -854,6 +854,40 @@ const getInfraTemplate = baseApi<
   return `/api/projects/${project_id}/infras/templates/${name}/${version}`;
 });
 
+const provisionCluster = baseApi<
+  {
+    project_id: number,
+    cluster_id?: number,
+    cloud_provider: string,
+    cloud_provider_credentials_id: string,
+    cluster_settings: {
+      cluster_name: string,
+      cluster_version: string,
+      cidr_range: string,
+      region: string,
+      node_groups: [
+        {
+          instance_type: string,
+          min_instances: number,
+          max_instances: number,
+          node_group_type: number
+        },
+        {
+          instance_type: string,
+          min_instances: number,
+          max_instances: number,
+          node_group_type: number
+        }
+      ]
+    }
+  },
+  {
+    project_id: number;
+  }
+>("POST", ({ project_id }) => {
+  return `/api/projects/${project_id}/provision/cluster`;
+});
+
 const provisionInfra = baseApi<
   {
     kind: string;
@@ -2392,6 +2426,7 @@ export default {
   listInfraTemplates,
   getInfraTemplate,
   getInfra,
+  provisionCluster,
   provisionInfra,
   deleteInfra,
   updateInfra,

+ 6 - 4
dashboard/src/shared/common.tsx

@@ -2,6 +2,7 @@ import aws from "../assets/aws.png";
 import digitalOcean from "../assets/do.png";
 import gcp from "../assets/gcp.png";
 import github from "../assets/github.png";
+import azure from "assets/azure.png";
 
 export const infraNames: any = {
   ecr: "Elastic Container Registry (ECR)",
@@ -101,11 +102,12 @@ export const integrationList: any = {
   gcp: {
     icon: gcp,
     label: "GCP",
+    tagline: "Coming soon"
   },
-  gar: {
-    icon:
-      "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
-    label: "Google Artifact Registry (GAR)",
+  azure: {
+    icon: azure,
+    label: "Azure",
+    tagline: "Coming soon"
   },
   do: {
     icon: digitalOcean,

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

@@ -11,6 +11,8 @@ export interface ClusterType {
   aws_integration_id?: number;
   aws_cluster_id?: string;
   preview_envs_enabled?: boolean;
+  cloud_provider_credential_identifier?: string;
+  status?: string;
 }
 
 export interface DetailedClusterType extends ClusterType {
@@ -261,6 +263,7 @@ export interface ProjectType {
   preview_envs_enabled: boolean;
   enable_rds_databases: boolean;
   managed_infra_enabled: boolean;
+  capi_provisioner_enabled: boolean;
   api_tokens_enabled: boolean;
   stacks_enabled: boolean;
   roles: {

+ 9 - 8
internal/models/project.go

@@ -75,13 +75,14 @@ func (p *Project) ToProjectType() *types.Project {
 	}
 
 	return &types.Project{
-		ID:                  p.ID,
-		Name:                p.Name,
-		Roles:               roles,
-		PreviewEnvsEnabled:  p.PreviewEnvsEnabled,
-		RDSDatabasesEnabled: p.RDSDatabasesEnabled,
-		ManagedInfraEnabled: p.ManagedInfraEnabled,
-		StacksEnabled:       p.StacksEnabled,
-		APITokensEnabled:    p.APITokensEnabled,
+		ID:                     p.ID,
+		Name:                   p.Name,
+		Roles:                  roles,
+		PreviewEnvsEnabled:     p.PreviewEnvsEnabled,
+		RDSDatabasesEnabled:    p.RDSDatabasesEnabled,
+		ManagedInfraEnabled:    p.ManagedInfraEnabled,
+		StacksEnabled:          p.StacksEnabled,
+		APITokensEnabled:       p.APITokensEnabled,
+		CapiProvisionerEnabled: p.CapiProvisionerEnabled,
 	}
 }