Feroze Mohideen 2 år sedan
förälder
incheckning
d968c18594
74 ändrade filer med 6604 tillägg och 877 borttagningar
  1. 13 15
      api/server/handlers/cluster/cluster_status.go
  2. 1 2
      api/server/handlers/cluster/get.go
  3. 9 2
      api/server/handlers/cluster/rename.go
  4. 73 4
      api/server/handlers/project_integration/preflight_check.go
  5. 29 29
      api/server/router/cluster.go
  6. 2 0
      api/types/project.go
  7. 9 0
      dashboard/src/assets/bolt.svg
  8. 9 0
      dashboard/src/assets/world.svg
  9. 41 19
      dashboard/src/components/ClusterProvisioningPlaceholder.tsx
  10. 3 4
      dashboard/src/components/porter/DashboardPlaceholder.tsx
  11. 24 14
      dashboard/src/components/porter/Error.tsx
  12. 4 2
      dashboard/src/components/porter/Expandable.tsx
  13. 3 3
      dashboard/src/components/porter/Input.tsx
  14. 32 0
      dashboard/src/components/porter/RequestToEnable.tsx
  15. 28 13
      dashboard/src/components/porter/Select.tsx
  16. 1 1
      dashboard/src/components/porter/StatusBar.tsx
  17. 1 0
      dashboard/src/components/porter/Tag.tsx
  18. 1 1
      dashboard/src/components/porter/Text.tsx
  19. 1074 5
      dashboard/src/lib/clusters/constants.ts
  20. 269 0
      dashboard/src/lib/clusters/index.ts
  21. 424 19
      dashboard/src/lib/clusters/types.ts
  22. 86 0
      dashboard/src/lib/hooks/useCloudProvider.ts
  23. 301 17
      dashboard/src/lib/hooks/useCluster.ts
  24. 1 1
      dashboard/src/lib/hooks/useClusterResourceLimits.ts
  25. 19 29
      dashboard/src/main/home/Home.tsx
  26. 1 1
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  27. 0 8
      dashboard/src/main/home/cluster-dashboard/DashboardRouter.tsx
  28. 0 75
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesHome.tsx
  29. 0 364
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  30. 0 31
      dashboard/src/main/home/cluster-dashboard/databases/mock_data.ts
  31. 0 33
      dashboard/src/main/home/cluster-dashboard/databases/routes.tsx
  32. 0 114
      dashboard/src/main/home/cluster-dashboard/databases/static_data.ts
  33. 193 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterContextProvider.tsx
  34. 342 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterDashboard.tsx
  35. 181 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx
  36. 54 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterHeader.tsx
  37. 36 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterProvisioningIndicator.tsx
  38. 36 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterSaveButton.tsx
  39. 124 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterTabs.tsx
  40. 69 0
      dashboard/src/main/home/infrastructure-dashboard/ClusterView.tsx
  41. 160 0
      dashboard/src/main/home/infrastructure-dashboard/forms/CloudProviderSelect.tsx
  42. 144 0
      dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx
  43. 120 0
      dashboard/src/main/home/infrastructure-dashboard/forms/aws/ConfigureEKSCluster.tsx
  44. 93 0
      dashboard/src/main/home/infrastructure-dashboard/forms/aws/CreateEKSClusterForm.tsx
  45. 407 0
      dashboard/src/main/home/infrastructure-dashboard/forms/aws/GrantAWSPermissions.tsx
  46. 156 0
      dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx
  47. 95 0
      dashboard/src/main/home/infrastructure-dashboard/forms/azure/CreateAKSClusterForm.tsx
  48. 218 0
      dashboard/src/main/home/infrastructure-dashboard/forms/azure/GrantAzurePermissions.tsx
  49. 129 0
      dashboard/src/main/home/infrastructure-dashboard/forms/gcp/ConfigureGKECluster.tsx
  50. 95 0
      dashboard/src/main/home/infrastructure-dashboard/forms/gcp/CreateGKEClusterForm.tsx
  51. 242 0
      dashboard/src/main/home/infrastructure-dashboard/forms/gcp/GrantGCPPermissions.tsx
  52. 168 0
      dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx
  53. 88 0
      dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/AWSCostConsentModalContents.tsx
  54. 92 0
      dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/AzureCostConsentModalContents.tsx
  55. 61 0
      dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/CostConsentModal.tsx
  56. 89 0
      dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/GCPCostConsentModalContents.tsx
  57. 84 0
      dashboard/src/main/home/infrastructure-dashboard/modals/help/permissions/GrantAWSPermissionsHelpModal.tsx
  58. 45 0
      dashboard/src/main/home/infrastructure-dashboard/modals/help/preflight/ResolutionStepsModalContents.tsx
  59. 121 0
      dashboard/src/main/home/infrastructure-dashboard/shared/NodeGroups.tsx
  60. 156 0
      dashboard/src/main/home/infrastructure-dashboard/tabs/Settings.tsx
  61. 83 0
      dashboard/src/main/home/infrastructure-dashboard/tabs/overview/AKSClusterOverview.tsx
  62. 35 0
      dashboard/src/main/home/infrastructure-dashboard/tabs/overview/ClusterOverview.tsx
  63. 58 0
      dashboard/src/main/home/infrastructure-dashboard/tabs/overview/EKSClusterOverview.tsx
  64. 57 0
      dashboard/src/main/home/infrastructure-dashboard/tabs/overview/GKEClusterOverview.tsx
  65. 12 9
      dashboard/src/main/home/onboarding/Onboarding.tsx
  66. 29 15
      dashboard/src/main/home/sidebar/ClusterList.tsx
  67. 0 1
      dashboard/src/main/home/sidebar/ProvisionClusterModal.tsx
  68. 52 24
      dashboard/src/main/home/sidebar/Sidebar.tsx
  69. 2 2
      dashboard/src/shared/DeploymentTargetContext.tsx
  70. 6 6
      dashboard/src/shared/api.tsx
  71. 1 0
      dashboard/src/shared/themes/standard.ts
  72. 3 2
      dashboard/src/shared/types.tsx
  73. 0 11
      dashboard/src/utils/infrastructure.tsx
  74. 10 1
      internal/models/project.go

+ 13 - 15
api/server/handlers/cluster/cluster_status.go

@@ -1,7 +1,6 @@
 package cluster
 
 import (
-	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -9,7 +8,6 @@ import (
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -45,32 +43,32 @@ func (c *ClusterStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	defer span.End()
 
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	req := connect.NewRequest(&porterv1.ClusterStatusRequest{
 		ProjectId: int64(cluster.ProjectID),
 		ClusterId: int64(cluster.ID),
 	})
+	resp := ClusterStatusResponse{
+		ProjectID: int(project.ID),
+		ClusterID: int(cluster.ID),
+	}
+
 	status, err := c.Config().ClusterControlPlaneClient.ClusterStatus(ctx, req)
 	if err != nil {
-		err := fmt.Errorf("unable to retrieve status for cluster: %w", err)
-		err = telemetry.Error(ctx, span, err, err.Error())
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		_ = telemetry.Error(ctx, span, err, "error getting cluster status")
+		c.WriteResult(w, r, resp)
 		return
 	}
 	if status.Msg == nil {
-		err := fmt.Errorf("unable to parse status for cluster: %w", err)
-		err = telemetry.Error(ctx, span, err, err.Error())
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		_ = telemetry.Error(ctx, span, nil, "error getting cluster status")
+		c.WriteResult(w, r, resp)
 		return
 	}
 	statusResp := status.Msg
 
-	resp := ClusterStatusResponse{
-		ProjectID:             int(statusResp.ProjectId),
-		ClusterID:             int(statusResp.ClusterId),
-		Phase:                 statusResp.Phase,
-		IsInfrastructureReady: statusResp.InfrastructureStatus,
-		IsControlPlaneReady:   statusResp.ControlPlaneStatus,
-	}
+	resp.Phase = statusResp.Phase
+	resp.IsInfrastructureReady = statusResp.InfrastructureStatus
+	resp.IsControlPlaneReady = statusResp.ControlPlaneStatus
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "cluster-phase", Value: statusResp.Phase},

+ 1 - 2
api/server/handlers/cluster/get.go

@@ -6,7 +6,6 @@ import (
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -38,7 +37,7 @@ func (c *ClusterGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.WriteResult(w, r, res)
 		return
 	}
 

+ 9 - 2
api/server/handlers/cluster/rename.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type RenameClusterHandler struct {
@@ -29,10 +30,15 @@ func NewRenameClusterHandler(
 }
 
 func (c *RenameClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-rename-cluster")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 	request := &types.UpdateClusterRequest{}
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
@@ -42,7 +48,8 @@ func (c *RenameClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	cluster, err := c.Repo().Cluster().UpdateCluster(cluster, c.Config().LaunchDarklyClient)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "error updating cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 

+ 73 - 4
api/server/handlers/project_integration/preflight_check.go

@@ -1,7 +1,6 @@
 package project_integration
 
 import (
-	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -32,10 +31,42 @@ func NewCreatePreflightCheckHandler(
 	}
 }
 
+// PorterError is the error response for the preflight check endpoint
+type PorterError struct {
+	Code     string            `json:"code"`
+	Message  string            `json:"message"`
+	Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// PreflightCheckError is the error response for the preflight check endpoint
+type PreflightCheckError struct {
+	Name  string      `json:"name"`
+	Error PorterError `json:"error"`
+}
+
+// PreflightCheckResponse is the response to the preflight check endpoint
+type PreflightCheckResponse struct {
+	Errors []PreflightCheckError `json:"errors"`
+}
+
+var recognizedPreflightCheckKeys = []string{
+	"eip",
+	"vcpu",
+	"vpc",
+	"natGateway",
+	"apiEnabled",
+	"cidrAvailability",
+	"iamPermissions",
+	"resourceProviders",
+}
+
 func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "preflight-checks")
 	defer span.End()
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	betaFeaturesEnabled := project.GetFeatureFlag(models.BetaFeaturesEnabled, p.Config().LaunchDarklyClient)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "beta-features-enabled", Value: betaFeaturesEnabled})
 
 	cloudValues := &porterv1.PreflightCheckRequest{}
 	err := helpers.UnmarshalContractObjectFromReader(r.Body, cloudValues)
@@ -45,6 +76,8 @@ func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
+	var resp PreflightCheckResponse
+
 	input := porterv1.PreflightCheckRequest{
 		ProjectId:                  int64(project.ID),
 		CloudProvider:              cloudValues.CloudProvider,
@@ -60,10 +93,46 @@ func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	checkResp, err := p.Config().ClusterControlPlaneClient.PreflightCheck(ctx, connect.NewRequest(&input))
 	if err != nil {
-		e := fmt.Errorf("Pre-provision check failed: %w", err)
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+		err = telemetry.Error(ctx, span, err, "error calling preflight checks")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if checkResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "no message received from preflight checks")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if !betaFeaturesEnabled {
+		p.WriteResult(w, r, checkResp)
 		return
 	}
 
-	p.WriteResult(w, r, checkResp)
+	errors := []PreflightCheckError{}
+	for key, val := range checkResp.Msg.PreflightChecks {
+		if val.Message == "" || !contains(recognizedPreflightCheckKeys, key) {
+			continue
+		}
+
+		errors = append(errors, PreflightCheckError{
+			Name: key,
+			Error: PorterError{
+				Code:     val.Code,
+				Message:  val.Message,
+				Metadata: val.Metadata,
+			},
+		})
+	}
+	resp.Errors = errors
+	p.WriteResult(w, r, resp)
+}
+
+func contains(slice []string, elem string) bool {
+	for _, item := range slice {
+		if item == elem {
+			return true
+		}
+	}
+	return false
 }

+ 29 - 29
api/server/router/cluster.go

@@ -262,6 +262,35 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/rename -> cluster.NewRenameClusterHandler
+	renameClusterEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/rename",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	renameClusterHandler := cluster.NewRenameClusterHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: renameClusterEndpoint,
+		Handler:  renameClusterHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/databases -> database.NewDatabaseListHandler
 	listDatabaseEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -770,35 +799,6 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
-		// POST /api/projects/{project_id}/clusters/{cluster_id}/rename -> cluster.NewRenameClusterHandler
-		renameClusterEndpoint := factory.NewAPIEndpoint(
-			&types.APIRequestMetadata{
-				Verb:   types.APIVerbCreate,
-				Method: types.HTTPVerbPost,
-				Path: &types.Path{
-					Parent:       basePath,
-					RelativePath: relPath + "/rename",
-				},
-				Scopes: []types.PermissionScope{
-					types.UserScope,
-					types.ProjectScope,
-					types.ClusterScope,
-				},
-			},
-		)
-
-		renameClusterHandler := cluster.NewRenameClusterHandler(
-			config,
-			factory.GetDecoderValidator(),
-			factory.GetResultWriter(),
-		)
-
-		routes = append(routes, &router.Route{
-			Endpoint: renameClusterEndpoint,
-			Handler:  renameClusterHandler,
-			Router:   r,
-		})
-
 		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
 		// environment.NewDeleteDeploymentHandler
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(

+ 2 - 0
api/types/project.go

@@ -22,6 +22,7 @@ type ProjectList struct {
 	FullAddOns             bool   `json:"full_add_ons"`
 	EnableReprovision      bool   `json:"enable_reprovision"`
 	ValidateApplyV2        bool   `json:"validate_apply_v2"`
+	AdvancedInfraEnabled   bool   `json:"advanced_infra_enabled"`
 }
 
 // Project type for entries in api responses for everything other than `GET /projects`
@@ -50,6 +51,7 @@ type Project struct {
 	StacksEnabled                   bool    `json:"stacks_enabled"`
 	ValidateApplyV2                 bool    `json:"validate_apply_v2"`
 	ManagedDeploymentTargetsEnabled bool    `json:"managed_deployment_targets_enabled"`
+	AdvancedInfraEnabled            bool    `json:"advanced_infra_enabled"`
 }
 
 // FeatureFlags is a struct that contains old feature flag representations

+ 9 - 0
dashboard/src/assets/bolt.svg

@@ -0,0 +1,9 @@
+<svg width="27" height="34" viewBox="0 0 27 34" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M24.8382 15.5729L13.6457 31.5627C13.082 32.3573 11.7555 32.0005 11.7555 31.0437L11.7389 21.8974C11.7389 20.8434 10.8269 20.0001 9.69938 19.9839L3.01705 19.9028C2.20457 19.8866 1.72371 19.0595 2.17141 18.4271L13.3639 2.43734C13.9276 1.64272 15.2541 1.9995 15.2541 2.95629L15.2707 12.1026C15.2707 13.1566 16.1827 13.9999 17.3102 14.0161L23.9925 14.0972C24.7884 14.0972 25.2693 14.9405 24.8382 15.5729Z" stroke="url(#paint0_linear_1402_6)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1402_6" x1="2" y1="-2.6875" x2="24.2493" y2="32.475" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 9 - 0
dashboard/src/assets/world.svg

@@ -0,0 +1,9 @@
+<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.50667 1.62167C5.50667 5.115 8.5 3.005 9.33333 6.41667C9.33333 7.10667 10.1667 7.66667 11 7.66667C11.8333 7.66667 12.6667 7.10667 12.6667 6.41667C12.6667 6.08515 12.7984 5.7672 13.0328 5.53278C13.2672 5.29836 13.5851 5.16667 13.9167 5.16667H15.1667M1.81667 5.55917C5.16667 7.4325 3.29667 9.0725 5.88167 10.2633C8.58417 11.5083 7.60083 16 7.60083 16M15.4058 11H13.5C12.837 11 12.2011 11.2634 11.7322 11.7322C11.2634 12.2011 11 12.837 11 13.5V15.1667M16 8.5C16 9.48491 15.806 10.4602 15.4291 11.3701C15.0522 12.2801 14.4997 13.1069 13.8033 13.8033C13.1069 14.4997 12.2801 15.0522 11.3701 15.4291C10.4602 15.806 9.48491 16 8.5 16C7.51509 16 6.53982 15.806 5.62987 15.4291C4.71993 15.0522 3.89314 14.4997 3.1967 13.8033C2.50026 13.1069 1.94781 12.2801 1.5709 11.3701C1.19399 10.4602 1 9.48491 1 8.5C1 6.51088 1.79018 4.60322 3.1967 3.1967C4.60322 1.79018 6.51088 1 8.5 1C10.4891 1 12.3968 1.79018 13.8033 3.1967C15.2098 4.60322 16 6.51088 16 8.5Z" stroke="url(#paint0_linear_1409_6)" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1409_6" x1="1" y1="1" x2="12" y2="16" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 41 - 19
dashboard/src/components/ClusterProvisioningPlaceholder.tsx

@@ -1,41 +1,63 @@
-import React, { useEffect, useState, useContext } from "react";
+import React, { useContext, useEffect, useState } from "react";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
-import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
-
-import loading from "assets/loading.gif";
 
-import { Context } from "shared/Context";
 import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
-import Text from "./porter/Text";
-import Spacer from "./porter/Spacer";
-import DashboardPlaceholder from "./porter/DashboardPlaceholder";
 import PorterLink from "components/porter/Link";
+
+import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
+import loading from "assets/loading.gif";
+
 import Button from "./porter/Button";
+import DashboardPlaceholder from "./porter/DashboardPlaceholder";
+import Spacer from "./porter/Spacer";
+import Text from "./porter/Text";
 
 type Props = {};
 
-const ClusterProvisioningPlaceholder: React.FC<RouteComponentProps> = (props) => {
-  const { currentCluster } = useContext(Context);
+const ClusterProvisioningPlaceholder: React.FC<RouteComponentProps> = (
+  props
+) => {
+  const { currentCluster, currentProject } = useContext(Context);
 
   return (
     <DashboardPlaceholder>
       <Text size={16}>
         <Img src={loading} /> Your cluster is being created
       </Text>
-      <Spacer y={.5} />
+      <Spacer y={0.5} />
       <Text color="helper">
         You can proceed as soon as your cluster is ready.
       </Text>
       <Spacer y={1} />
-      <PorterLink onClick={() => {
-        pushFiltered(props, "/cluster-dashboard", ["project_id"], {
-          cluster: currentCluster?.name,
-        });
-      }}>
+      <PorterLink
+        onClick={() => {
+          if (
+            currentProject?.capi_provisioner_enabled &&
+            currentProject?.simplified_view_enabled &&
+            currentProject?.beta_features_enabled
+          ) {
+            pushFiltered(
+              props,
+              currentCluster?.id
+                ? `/infrastructure/${currentCluster.id}`
+                : "/infrastructure",
+              []
+            );
+          } else {
+            pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+              cluster: currentCluster?.name,
+            });
+          }
+        }}
+      >
         <Button alt height="35px">
-          View status <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+          View status <Spacer inline x={1} />{" "}
+          <i className="material-icons" style={{ fontSize: "18px" }}>
+            east
+          </i>
         </Button>
       </PorterLink>
     </DashboardPlaceholder>
@@ -52,7 +74,7 @@ const Img = styled.img`
 const ClusterPlaceholder = styled.div`
   padding: 25px;
   border-radius: 5px;
-  background: ${props => props.theme.fg};
+  background: ${(props) => props.theme.fg};
   border: 1px solid #494b4f;
   padding-bottom: 35px;
 `;

+ 3 - 4
dashboard/src/components/porter/DashboardPlaceholder.tsx

@@ -7,9 +7,7 @@ type Props = {
   children: React.ReactNode;
 };
 
-const DashboardPlaceholder: React.FC<Props> = ({
-  children,
-}) => {
+const DashboardPlaceholder: React.FC<Props> = ({ children }) => {
   return (
     <StyledDashboardPlaceholder>
       <Bg src={placeholder} />
@@ -39,4 +37,5 @@ const StyledDashboardPlaceholder = styled.div`
   border-radius: 10px;
   position: relative;
   overflow: hidden;
-`;
+  height: fit-content;
+`;

+ 24 - 14
dashboard/src/components/porter/Error.tsx

@@ -1,8 +1,8 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
-import Spacer from "./Spacer";
 import Modal from "./Modal";
+import Spacer from "./Spacer";
 
 type Props = {
   message: string;
@@ -28,20 +28,29 @@ export const Error: React.FC<Props> = ({
         <Block>
           <Text>Error: {message}</Text>
           {ctaText && (errorModalContents != null || ctaOnClick != null) && (
-            <Cta onClick={() => {
-              errorModalContents ? setErrorModalOpen(true) : ctaOnClick();
-            }}>
-              <Underline>{ctaText}</Underline>
-              <i className="material-icons">open_in_new</i>
-            </Cta>
+            <>
+              <Spacer y={0.5} />
+              <Cta
+                onClick={() => {
+                  errorModalContents ? setErrorModalOpen(true) : ctaOnClick?.();
+                }}
+              >
+                <Underline>{ctaText}</Underline>
+                <i className="material-icons">open_in_new</i>
+              </Cta>
+            </>
           )}
         </Block>
       </StyledError>
-      {errorModalOpen &&
-        <Modal closeModal={() => setErrorModalOpen(false)}>
+      {errorModalOpen && (
+        <Modal
+          closeModal={() => {
+            setErrorModalOpen(false);
+          }}
+        >
           {errorModalContents}
         </Modal>
-      }
+      )}
     </>
   );
 };
@@ -65,7 +74,8 @@ const Cta = styled.span`
   cursor: pointer;
   display: inline-flex;
   align-items: center;
-  
+  color: #fff;
+
   > i {
     margin-left: 5px;
     font-size: 15px;
@@ -76,7 +86,7 @@ const StyledError = styled.div<{ maxWidth?: string }>`
   line-height: 1.5;
   color: #ff385d;
   font-size: 13px;
-  display: flex; 
+  display: flex;
   align-items: center;
   position: relative;
   padding-left: 25px;
@@ -90,4 +100,4 @@ const StyledError = styled.div<{ maxWidth?: string }>`
     left: 0;
   }
   max-width: ${(props) => props.maxWidth || "100%"};
-`;
+`;

+ 4 - 2
dashboard/src/components/porter/Expandable.tsx

@@ -5,6 +5,7 @@ type Props = {
   header: React.ReactNode;
   children: React.ReactNode;
   style?: React.CSSProperties;
+  preExpanded?: boolean;
 };
 
 // TODO: support footer for consolidation w/ app services
@@ -12,8 +13,9 @@ const Expandable: React.FC<Props> = ({
   header,
   children,
   style,
+  preExpanded
 }) => {
-  const [isExpanded, setIsExpanded] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(preExpanded || false);
 
   return (
     <StyledExpandable style={style}>
@@ -44,7 +46,7 @@ const ExpandedContents = styled.div<{ isExpanded: boolean }>`
   padding: ${({ isExpanded }) => isExpanded ? "20px" : "0"};
   border-bottom-left-radius: 5px;
   border-bottom-right-radius: 5px;
-  background: ${(props) => props.theme.fg};
+  background: ${(props) => props.theme.fg + "66"};
   border: ${({ isExpanded }) => isExpanded && "1px solid #494b4f"};
   border-top: 0;
   color: ${(props) => props.theme.text.primary};

+ 3 - 3
dashboard/src/components/porter/Input.tsx

@@ -130,16 +130,16 @@ const StyledInput = styled.input<{
   disabled: boolean;
   hideCursor: boolean;
 }>`
-  height: ${(props) => props.height || "35px"};
+  height: ${(props) => props.height || "30px"};
   padding: 5px 10px;
   width: ${(props) => props.width || "200px"};
   color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
   font-size: 13px;
   outline: none;
   border-radius: 5px;
-  background: #26292e;
-  cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
   transition: all 0.2s;
+  background: ${(props) => props.theme.fg};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
 
   border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
   ${(props) =>

+ 32 - 0
dashboard/src/components/porter/RequestToEnable.tsx

@@ -0,0 +1,32 @@
+import React from "react";
+
+import DashboardPlaceholder from "./DashboardPlaceholder";
+import ShowIntercomButton from "./ShowIntercomButton";
+import Spacer from "./Spacer";
+import Text from "./Text";
+
+type Props = {
+  title: string;
+  subtitle: string;
+  intercomText: string;
+};
+
+const RequestToEnable: React.FC<Props> = ({
+  title,
+  subtitle,
+  intercomText,
+}) => {
+  return (
+    <DashboardPlaceholder>
+      <Text size={16}>{title}</Text>
+      <Spacer y={0.5} />
+      <Text color={"helper"}>{subtitle}</Text>
+      <Spacer y={1} />
+      <ShowIntercomButton alt message={intercomText} height="35px">
+        Request to enable
+      </ShowIntercomButton>
+    </DashboardPlaceholder>
+  );
+};
+
+export default RequestToEnable;

+ 28 - 13
dashboard/src/components/porter/Select.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import styled from "styled-components";
+import styled, { css } from "styled-components";
 
 import arrow from "assets/arrow-down.svg";
 
@@ -19,6 +19,7 @@ type Props = {
   value?: string;
   setValue?: (value: string) => void;
   prefix?: React.ReactNode;
+  width?: string;
 };
 
 const Select: React.FC<Props> = ({
@@ -30,11 +31,12 @@ const Select: React.FC<Props> = ({
   value,
   setValue,
   prefix,
+  width,
 }) => {
   return (
-    <div>
+    <Div width={width}>
       {label && <Label color={labelColor}>{label}</Label>}
-      <SelectWrapper>
+      <SelectWrapper isDisabled={disabled ?? false}>
         {prefix && (
           <>
             <Prefix>{prefix}</Prefix>
@@ -59,7 +61,7 @@ const Select: React.FC<Props> = ({
             setValue?.(e.target.value);
           }}
           hasError={(error && true) || error === ""}
-          disabled={disabled || false}
+          disabled={disabled}
         >
           {options.map((option, i) => {
             return (
@@ -76,12 +78,16 @@ const Select: React.FC<Props> = ({
           {error}
         </Error>
       )}
-    </div>
+    </Div>
   );
 };
 
 export default Select;
 
+const Div = styled.div<{ width?: string }>`
+  width: ${({ width }) => width || ""};
+`;
+
 const Img = styled.img`
   height: 16px;
   margin-right: 10px;
@@ -122,7 +128,7 @@ const Error = styled.div`
   }
 `;
 
-const SelectWrapper = styled.div`
+const SelectWrapper = styled.div<{ isDisabled: boolean }>`
   position: relative;
   padding-left: 10px;
   padding-right: 28px;
@@ -130,9 +136,6 @@ const SelectWrapper = styled.div`
   transition: all 0.2s;
   background: ${(props) => props.theme.fg};
   border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
   z-index: 0;
   display: flex;
   align-items: center;
@@ -140,8 +143,6 @@ const SelectWrapper = styled.div`
   font-size: 13px;
   overflow: hidden;
 
-  display: flex;
-  align-items: center;
   > img {
     width: 8px;
     position: absolute;
@@ -149,11 +150,25 @@ const SelectWrapper = styled.div`
     top: calc(50% - 3px);
     z-index: -1;
   }
+
+  ${(props) =>
+    !props.isDisabled ?
+    css`
+      :hover {
+        border: 1px solid #7a7b80;
+      }
+    ` : 
+    css`
+      color: #ffffff55;
+      > img {
+        opacity: 0.5;
+      }
+    `}
 `;
 
 const SelectLayer = styled.select<{
-  disabled?: boolean;
   hasError: boolean;
+  disabled?: boolean;
 }>`
   outline: none;
   position: absolute;
@@ -161,7 +176,7 @@ const SelectLayer = styled.select<{
   left: 0;
   width: 100%;
   height: 100%;
-  cursor: pointer;
+  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
   background: none;
   appearance: none;
   opacity: 0;

+ 1 - 1
dashboard/src/components/porter/StatusBar.tsx

@@ -76,7 +76,7 @@ const Icon = styled.img`
 
 const StyledProvisionerStatus = styled.div`
   border-radius: 5px;
-  background: #26292e;
+  background: ${(props) => props.theme.fg};
   border: 1px solid #494b4f;
   font-size: 13px;
   width: 100%;

+ 1 - 0
dashboard/src/components/porter/Tag.tsx

@@ -46,6 +46,7 @@ const StyledTag = styled.div<{
 }>`
   display: flex;
   justify-content: center;
+  font-size: 13px;
   padding: 3px 5px;
   border-radius: ${({ borderRadiusPixels }) => borderRadiusPixels}px;
   background: ${({ backgroundColor }) => backgroundColor};

+ 1 - 1
dashboard/src/components/porter/Text.tsx

@@ -25,7 +25,7 @@ const Text: React.FC<Props> = ({
   const getColor = () => {
     switch (color) {
       case "helper":
-        return "#aaaabb";
+        return "#9999aa";
       case "warner":
         return "#ff5a52";
       default:

+ 1074 - 5
dashboard/src/lib/clusters/constants.ts

@@ -1,29 +1,1098 @@
+import {
+  AksSkuTier,
+  Contract,
+  EnumCloudProvider,
+  EnumKubernetesKind,
+  GKENetwork,
+  LoadBalancerType,
+} from "@porter-dev/api-contracts";
+
 import aws from "assets/aws.png";
 import azure from "assets/azure.png";
 import infra from "assets/cluster.svg";
 import gcp from "assets/gcp.png";
 
-import { type CloudProvider } from "./types";
+import {
+  type ClientCloudProvider,
+  type ClientRegion,
+  type MachineType,
+  type PreflightCheck,
+  type PreflightCheckResolution,
+} from "./types";
+
+export const SUPPORTED_AWS_REGIONS: ClientRegion[] = [
+  { name: "us-east-1", displayName: "US East (N. Virginia) us-east-1" },
+  { name: "us-east-2", displayName: "US East (Ohio) us-east-2" },
+  { name: "us-west-1", displayName: "US West (N. California) us-west-1" },
+  { name: "us-west-2", displayName: "US West (Oregon) us-west-2" },
+  { name: "af-south-1", displayName: "Africa (Cape Town) af-south-1" },
+  { name: "ap-east-1", displayName: "Asia Pacific (Hong Kong) ap-east-1" },
+  { name: "ap-south-1", displayName: "Asia Pacific (Mumbai) ap-south-1" },
+  {
+    name: "ap-northeast-2",
+    displayName: "Asia Pacific (Seoul) ap-northeast-2",
+  },
+  {
+    name: "ap-southeast-1",
+    displayName: "Asia Pacific (Singapore) ap-southeast-1",
+  },
+  {
+    name: "ap-southeast-2",
+    displayName: "Asia Pacific (Sydney) ap-southeast-2",
+  },
+  {
+    name: "ap-northeast-1",
+    displayName: "Asia Pacific (Tokyo) ap-northeast-1",
+  },
+  { name: "ca-central-1", displayName: "Canada (Central) ca-central-1" },
+  { name: "eu-central-1", displayName: "Europe (Frankfurt) eu-central-1" },
+  { name: "eu-west-1", displayName: "Europe (Ireland) eu-west-1" },
+  { name: "eu-west-2", displayName: "Europe (London) eu-west-2" },
+  { name: "eu-south-1", displayName: "Europe (Milan) eu-south-1" },
+  { name: "eu-west-3", displayName: "Europe (Paris) eu-west-3" },
+  { name: "eu-north-1", displayName: "Europe (Stockholm) eu-north-1" },
+  { name: "me-south-1", displayName: "Middle East (Bahrain) me-south-1" },
+  { name: "sa-east-1", displayName: "South America (São Paulo) sa-east-1" },
+];
+
+const SUPPORTED_GCP_REGIONS: ClientRegion[] = [
+  { name: "us-east1", displayName: "us-east1 (South Carolina, USA)" },
+  { name: "us-east4", displayName: "us-east4 (Virginia, USA)" },
+  { name: "us-central1", displayName: "us-central1 (Iowa, USA)" },
+  { name: "europe-north1", displayName: "europe-north1 (Hamina, Finland)" },
+  { name: "europe-central2", displayName: "europe-central2 (Warsaw, Poland)" },
+  { name: "europe-west1", displayName: "europe-west1 (St. Ghislain, Belgium)" },
+  { name: "europe-west2", displayName: "europe-west2 (London, England)" },
+  { name: "europe-west6", displayName: "europe-west6 (Zurich, Switzerland)" },
+  { name: "asia-south1", displayName: "asia-south1 (Mumbia, India)" },
+  { name: "us-west1", displayName: "us-west1 (Oregon, USA)" },
+  { name: "us-west2", displayName: "us-west2 (Los Angeles, USA)" },
+  { name: "us-west3", displayName: "us-west3 (Salt Lake City, USA)" },
+  { name: "us-west4", displayName: "us-west4 (Las Vegas, USA)" },
+];
+
+const SUPPORTED_AZURE_REGIONS: ClientRegion[] = [
+  { name: "australiaeast", displayName: "Australia East" },
+  { name: "brazilsouth", displayName: "Brazil South" },
+  { name: "canadacentral", displayName: "Canada Central" },
+  { name: "centralindia", displayName: "Central India" },
+  { name: "centralus", displayName: "Central US" },
+  { name: "eastasia", displayName: "East Asia" },
+  { name: "eastus", displayName: "East US" },
+  { name: "eastus2", displayName: "East US 2" },
+  { name: "francecentral", displayName: "France Central" },
+  { name: "northeurope", displayName: "North Europe" },
+  { name: "norwayeast", displayName: "Norway East" },
+  { name: "southafricanorth", displayName: "South Africa North" },
+  { name: "southcentralus", displayName: "South Central US" },
+  { name: "swedencentral", displayName: "Sweden Central" },
+  { name: "switzerlandnorth", displayName: "Switzerland North" },
+  { name: "uaenorth", displayName: "UAE North" },
+  { name: "uksouth", displayName: "UK South" },
+  { name: "westeurope", displayName: "West Europe" },
+  { name: "westus2", displayName: "West US 2" },
+  { name: "westus3", displayName: "West US 3" },
+];
+
+const SUPPORTED_AWS_MACHINE_TYPES: MachineType[] = [
+  {
+    name: "t3.medium",
+    displayName: "t3.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3.large",
+    displayName: "t3.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3.xlarge",
+    displayName: "t3.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3.2xlarge",
+    displayName: "t3.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3a.medium",
+    displayName: "t3a.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3a.large",
+    displayName: "t3a.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3a.xlarge",
+    displayName: "t3a.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t3a.2xlarge",
+    displayName: "t3a.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t4g.medium",
+    displayName: "t4g.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t4g.large",
+    displayName: "t4g.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t4g.xlarge",
+    displayName: "t4g.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "t4g.2xlarge",
+    displayName: "t4g.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6i.large",
+    displayName: "c6i.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6i.xlarge",
+    displayName: "c6i.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6i.2xlarge",
+    displayName: "c6i.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6i.4xlarge",
+    displayName: "c6i.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6i.8xlarge",
+    displayName: "c6i.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6a.large",
+    displayName: "c6a.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6a.2xlarge",
+    displayName: "c6a.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6a.4xlarge",
+    displayName: "c6a.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c6a.8xlarge",
+    displayName: "c6a.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.large",
+    displayName: "r6i.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.xlarge",
+    displayName: "r6i.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.2xlarge",
+    displayName: "r6i.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.4xlarge",
+    displayName: "r6i.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.8xlarge",
+    displayName: "r6i.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.12xlarge",
+    displayName: "r6i.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.16xlarge",
+    displayName: "r6i.16xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.24xlarge",
+    displayName: "r6i.24xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "r6i.32xlarge",
+    displayName: "r6i.32xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m5n.large",
+    displayName: "m5n.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m5n.xlarge",
+    displayName: "m5n.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m5n.2xlarge",
+    displayName: "m5n.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.large",
+    displayName: "m6a.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.xlarge",
+    displayName: "m6a.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.2xlarge",
+    displayName: "m6a.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.4xlarge",
+    displayName: "m6a.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.8xlarge",
+    displayName: "m6a.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m6a.12xlarge",
+    displayName: "m6a.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.medium",
+    displayName: "m7a.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.large",
+    displayName: "m7a.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.xlarge",
+    displayName: "m7a.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.2xlarge",
+    displayName: "m7a.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.4xlarge",
+    displayName: "m7a.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.8xlarge",
+    displayName: "m7a.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.12xlarge",
+    displayName: "m7a.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.16xlarge",
+    displayName: "m7a.16xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7a.24xlarge",
+    displayName: "m7a.24xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.large",
+    displayName: "m7i.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.xlarge",
+    displayName: "m7i.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.2xlarge",
+    displayName: "m7i.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.4xlarge",
+    displayName: "m7i.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.8xlarge",
+    displayName: "m7i.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "m7i.12xlarge",
+    displayName: "m7i.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.medium",
+    displayName: "c7a.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.large",
+    displayName: "c7a.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.xlarge",
+    displayName: "c7a.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.2xlarge",
+    displayName: "c7a.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.4xlarge",
+    displayName: "c7a.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.8xlarge",
+    displayName: "c7a.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.12xlarge",
+    displayName: "c7a.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.16xlarge",
+    displayName: "c7a.16xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c7a.24xlarge",
+    displayName: "c7a.24xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+  },
+];
+
+const SUPPORTED_GCP_MACHINE_TYPES: MachineType[] = [
+  {
+    name: "e2-standard-2",
+    displayName: "e2-standard-2",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "e2-standard-4",
+    displayName: "e2-standard-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "e2-standard-8",
+    displayName: "e2-standard-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "e2-standard-16",
+    displayName: "e2-standard-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "e2-standard-32",
+    displayName: "e2-standard-32",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-standard-4",
+    displayName: "c3-standard-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-standard-8",
+    displayName: "c3-standard-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-standard-22",
+    displayName: "c3-standard-22",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-standard-44",
+    displayName: "c3-standard-44",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highcpu-4",
+    displayName: "c3-highcpu-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highcpu-8",
+    displayName: "c3-highcpu-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highcpu-22",
+    displayName: "c3-highcpu-22",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highcpu-44",
+    displayName: "c3-highcpu-44",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highmem-4",
+    displayName: "c3-highmem-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highmem-8",
+    displayName: "c3-highmem-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highmem-22",
+    displayName: "c3-highmem-22",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "c3-highmem-44",
+    displayName: "c3-highmem-44",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-1",
+    displayName: "n1-standard-1",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-2",
+    displayName: "n1-standard-2",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-4",
+    displayName: "n1-standard-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-8",
+    displayName: "n1-standard-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-16",
+    displayName: "n1-standard-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-standard-32",
+    displayName: "n1-standard-32",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highmem-2",
+    displayName: "n1-highmem-2",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highmem-4",
+    displayName: "n1-highmem-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highmem-8",
+    displayName: "n1-highmem-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highmem-16",
+    displayName: "n1-highmem-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highmem-32",
+    displayName: "n1-highmem-32",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highcpu-8",
+    displayName: "n1-highcpu-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highcpu-16",
+    displayName: "n1-highcpu-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+  {
+    name: "n1-highcpu-32",
+    displayName: "n1-highcpu-32",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+  },
+];
+
+const SUPPORTED_AZURE_MACHINE_TYPES: MachineType[] = [
+  {
+    name: "Standard_B2als_v2",
+    displayName: "Standard_B2als_v2",
+    supportedRegions: [
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ],
+  },
+  {
+    name: "Standard_B2as_v2",
+    displayName: "Standard_B2as_v2",
+    supportedRegions: [
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ],
+  },
+  {
+    name: "Standard_A2_v2",
+    displayName: "Standard_A2_v2",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ],
+  },
+  {
+    name: "Standard_A4_v2",
+    displayName: "Standard_A4_v2",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ],
+  },
+  {
+    name: "Standard_DS1_v2",
+    displayName: "Standard_DS1_v2",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ],
+  },
+  {
+    name: "Standard_DS2_v2",
+    displayName: "Standard_DS2_v2",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "swedencentral",
+      "switzerlandnorth",
+      "westus3",
+    ],
+  },
+  {
+    name: "Standard_D2ads_v5",
+    displayName: "Standard_D2ads_v5",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "uaenorth",
+      "uksouth",
+      "westus3",
+    ],
+  },
+  {
+    name: "Standard_B4als_v2",
+    displayName: "Standard_B4als_v2",
+    supportedRegions: [
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ],
+  },
+  {
+    name: "Standard_NC4as_T4_v3",
+    displayName: "Standard_NC4as_T4_v3",
+    supportedRegions: [
+      "australiaeast",
+      "centralindia",
+      "eastus",
+      "eastus2",
+      "northeurope",
+      "southcentralus",
+      "uksouth",
+      "westeurope",
+      "westus2",
+    ],
+  },
+  {
+    name: "Standard_NC8as_T4_v3",
+    displayName: "Standard_NC8as_T4_v3",
+    supportedRegions: [
+      "australiaeast",
+      "centralindia",
+      "eastus",
+      "eastus2",
+      "northeurope",
+      "southcentralus",
+      "uksouth",
+      "westeurope",
+      "westus2",
+    ],
+  },
+  {
+    name: "Standard_NC16as_T4_v3",
+    displayName: "Standard_NC16as_T4_v3",
+    supportedRegions: [
+      "australiaeast",
+      "centralindia",
+      "eastus",
+      "eastus2",
+      "northeurope",
+      "southcentralus",
+      "uksouth",
+      "westeurope",
+      "westus2",
+    ],
+  },
+  {
+    name: "Standard_NC64as_T4_v3",
+    displayName: "Standard_NC64as_T4_v3",
+    supportedRegions: [
+      "australiaeast",
+      "centralindia",
+      "eastus",
+      "eastus2",
+      "northeurope",
+      "southcentralus",
+      "uksouth",
+      "westeurope",
+      "westus2",
+    ],
+  },
+  {
+    name: "Standard_D8s_v3",
+    displayName: "Standard_D8s_v3",
+    supportedRegions: [
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ],
+  },
+];
+const SUPPORTED_AZURE_SKU_TIERS = [
+  {
+    name: "FREE",
+    displayName: "Free",
+  },
+  {
+    name: "STANDARD",
+    displayName: "Standard (for production workloads, +$73/month)",
+  },
+];
+
+const AWS_EIP_QUOTA_RESOLUTION: PreflightCheckResolution = {
+  title: "Requesting more EIP Addresses",
+  subtitle:
+    "You will need to either request more EIP addresses or delete existing ones in order to provision in the region specified. You can request more addresses by following these steps:",
+  steps: [
+    {
+      text: "Log into your AWS Account",
+      externalLink:
+        "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
+    },
+    {
+      text: "Navigate to the Amazon Elastic Compute Cloud (Amazon EC2) Service Quotas portal",
+      externalLink:
+        "https://us-east-1.console.aws.amazon.com/servicequotas/home/services/ec2/quotas",
+    },
+    {
+      text: 'Search for "EC2-VPC Elastic IPs" in the search box and click on the search result.',
+    },
+    {
+      text: 'Click on "Request quota increase". In order to provision with Porter, you will need to request at least 3 addresses above your current quota limit.',
+    },
+    {
+      text: "Once that request is approved, return to Porter and retry the provision.",
+    },
+  ],
+};
+const AWS_NAT_GATEWAY_QUOTA_RESOLUTION: PreflightCheckResolution = {
+  title: "Requesting more NAT Gateways",
+  subtitle:
+    "You will need to either request more NAT Gateways or delete existing ones in order to provision in the region specified. You can request more NAT Gateways by following these steps:",
+  steps: [
+    {
+      text: "Log into your AWS Account",
+      externalLink:
+        "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
+    },
+    {
+      text: "Navigate to the Amazon Virtual Private Cloud (Amazon VPC) Service Quotas portal",
+      externalLink:
+        "https://us-east-1.console.aws.amazon.com/servicequotas/home/services/vpc/quotas",
+    },
+    {
+      text: 'Search for "NAT gateways per Availability Zone" in the search box and click on the search result.',
+    },
+    {
+      text: 'Click on "Request quota increase". In order to provision with Porter, you will need to request at least 3 NAT Gateways above your current quota limit.',
+    },
+    {
+      text: "Once that request is approved, return to Porter and retry the provision.",
+    },
+  ],
+};
+const AWS_VPC_QUOTA_RESOLUTION: PreflightCheckResolution = {
+  title: "Requesting more VPCs",
+  subtitle:
+    "You will need to either request more VPCs or delete existing ones in order to provision in the region specified. You can request more VPCs by following these steps:",
+  steps: [
+    {
+      text: "Log into your AWS Account",
+      externalLink:
+        "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
+    },
+    {
+      text: "Navigate to the Amazon Virtual Private Cloud (Amazon VPC) Service Quotas portal",
+      externalLink:
+        "https://us-east-1.console.aws.amazon.com/servicequotas/home/services/vpc/quotas",
+    },
+    {
+      text: 'Search for "VPCs per Region" in the search box and click on the search result.',
+    },
+    {
+      text: 'Click on "Request quota increase". In order to provision with Porter, you will need to request at least 1 VPC above your current quota limit.',
+    },
+    {
+      text: "Once that request is approved, return to Porter and retry the provision.",
+    },
+  ],
+};
+const AWS_VCPUS_QUOTA_RESOLUTION: PreflightCheckResolution = {
+  title: "Requesting more vCPUs",
+  subtitle:
+    "You will need to either request more vCPUs or delete existing instances in order to provision in the region specified. You can request more vCPUs by following these steps:",
+  steps: [
+    {
+      text: "Log into your AWS Account",
+      externalLink:
+        "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
+    },
+    {
+      text: "Navigate to the Amazon Elastic Compute Cloud (Amazon EC2) Service Quotas portal",
+      externalLink:
+        "https://us-east-1.console.aws.amazon.com/servicequotas/home/services/ec2/quotas",
+    },
+    {
+      text: 'Search for "Running On-Demand Standard (A, C, D, H, I, M, R, T, Z) instances" in the search box and click on the search result.',
+    },
+    {
+      text: 'Click on "Request quota increase". In order to provision with Porter, you will need to request at least 10 vCPUs above your current quota limit.',
+    },
+    {
+      text: "Once that request is approved, return to Porter and retry the provision.",
+    },
+  ],
+};
+
+const SUPPORTED_AWS_PREFLIGHT_CHECKS: PreflightCheck[] = [
+  {
+    name: "eip",
+    displayName: "Elastic IP availability",
+    resolution: AWS_EIP_QUOTA_RESOLUTION,
+  },
+  {
+    name: "natGateway",
+    displayName: "NAT Gateway availability",
+    resolution: AWS_NAT_GATEWAY_QUOTA_RESOLUTION,
+  },
+  {
+    name: "vpc",
+    displayName: "VPC availability",
+    resolution: AWS_VPC_QUOTA_RESOLUTION,
+  },
+  {
+    name: "vcpu",
+    displayName: "vCPU availability",
+    resolution: AWS_VCPUS_QUOTA_RESOLUTION,
+  },
+];
+
+const SUPPORTED_GCP_PREFLIGHT_CHECKS: PreflightCheck[] = [
+  {
+    name: "apiEnabled",
+    displayName: "APIs enabled on service account",
+  },
+  {
+    name: "cidrAvailability",
+    displayName: "CIDR availability",
+  },
+  {
+    name: "iamPermissions",
+    displayName: "IAM permissions",
+  },
+];
+
+const DEFAULT_EKS_CONTRACT = new Contract({
+  cluster: {
+    kind: EnumKubernetesKind.EKS,
+    cloudProvider: EnumCloudProvider.AWS,
+    kindValues: {
+      case: "eksKind",
+      value: {
+        clusterVersion: "v1.27.0",
+        loadBalancer: {
+          loadBalancerType: LoadBalancerType.NLB,
+        },
+        network: {
+          serviceCidr: "172.20.0.0/16",
+          vpcCidr: "10.78.0.0/16",
+        },
+      },
+    },
+  },
+});
+
+const DEFAULT_AKS_CONTRACT = new Contract({
+  cluster: {
+    kind: EnumKubernetesKind.AKS,
+    cloudProvider: EnumCloudProvider.AZURE,
+    kindValues: {
+      case: "aksKind",
+      value: {
+        clusterVersion: "v1.27.3",
+        cidrRange: "10.78.0.0/16",
+        skuTier: AksSkuTier.FREE,
+      },
+    },
+  },
+});
+
+const DEFAULT_GKE_CONTRACT = new Contract({
+  cluster: {
+    kind: EnumKubernetesKind.GKE,
+    cloudProvider: EnumCloudProvider.GCP,
+    kindValues: {
+      case: "gkeKind",
+      value: {
+        clusterVersion: "v1.27.0",
+        network: new GKENetwork({
+          cidrRange: "10.78.0.0/16",
+          controlPlaneCidr: "10.77.0.0/28",
+          podCidr: "10.76.0.0/16",
+          serviceCidr: "10.75.0.0/16",
+        }),
+      },
+    },
+  },
+});
 
-export const CloudProviderAWS: CloudProvider = {
+export const CloudProviderAWS: ClientCloudProvider = {
   name: "AWS",
   displayName: "Amazon Web Services",
   icon: aws,
+  regions: SUPPORTED_AWS_REGIONS,
+  machineTypes: SUPPORTED_AWS_MACHINE_TYPES,
+  baseCost: 224.58,
+  newClusterDefaultContract: DEFAULT_EKS_CONTRACT,
+  preflightChecks: SUPPORTED_AWS_PREFLIGHT_CHECKS,
+  config: {
+    kind: "AWS",
+  },
 };
-export const CloudProviderGCP: CloudProvider = {
+export const CloudProviderGCP: ClientCloudProvider = {
   name: "GCP",
   displayName: "Google Cloud Platform",
   icon: gcp,
+  regions: SUPPORTED_GCP_REGIONS,
+  machineTypes: SUPPORTED_GCP_MACHINE_TYPES,
+  baseCost: 253.0,
+  newClusterDefaultContract: DEFAULT_GKE_CONTRACT,
+  preflightChecks: SUPPORTED_GCP_PREFLIGHT_CHECKS,
+  config: {
+    kind: "GCP",
+  },
 };
-export const CloudProviderAzure: CloudProvider = {
+export const CloudProviderAzure: ClientCloudProvider & {
+  config: { kind: "Azure" };
+} = {
   name: "Azure",
   displayName: "Microsoft Azure",
   icon: azure,
+  regions: SUPPORTED_AZURE_REGIONS,
+  machineTypes: SUPPORTED_AZURE_MACHINE_TYPES,
+  baseCost: 164.69,
+  newClusterDefaultContract: DEFAULT_AKS_CONTRACT,
+  preflightChecks: [],
+  config: {
+    kind: "Azure",
+    skuTiers: SUPPORTED_AZURE_SKU_TIERS,
+  },
 };
-export const CloudProviderLocal: CloudProvider = {
+export const CloudProviderLocal: ClientCloudProvider = {
   name: "Local",
   displayName: "Local",
   icon: infra,
+  regions: [],
+  machineTypes: [],
+  baseCost: 0,
+  newClusterDefaultContract: new Contract({}),
+  preflightChecks: [],
+  config: {
+    kind: "Local",
+  },
 };
 export const SUPPORTED_CLOUD_PROVIDERS = [
   CloudProviderAWS,

+ 269 - 0
dashboard/src/lib/clusters/index.ts

@@ -0,0 +1,269 @@
+import {
+  AKS,
+  AKSNodePool,
+  AksSkuTier,
+  AWSClusterNetwork,
+  Cluster,
+  EKS,
+  EKSNodeGroup,
+  EnumCloudProvider,
+  GKE,
+  GKENetwork,
+  GKENodePool,
+  GKENodePoolType,
+  NodeGroupType,
+  NodePoolType,
+  type Contract,
+} from "@porter-dev/api-contracts";
+import { match } from "ts-pattern";
+
+import {
+  type AKSClientClusterConfig,
+  type ClientClusterContract,
+  type EKSClientClusterConfig,
+  type GKEClientClusterConfig,
+} from "./types";
+
+// this method takes in an existing contract, applies all the changes from the client contract, and returns a new contract
+// all non-editable fields should be spread from the existing contract
+export function updateExistingClusterContract(
+  clientClusterContract: ClientClusterContract,
+  existingContract: Cluster
+): Cluster {
+  const cluster = new Cluster({
+    ...existingContract,
+    cloudProviderCredentialsId:
+      clientClusterContract.cluster.cloudProviderCredentialsId,
+    projectId: clientClusterContract.cluster.projectId,
+  });
+  match(clientClusterContract.cluster.config)
+    .with({ kind: "EKS" }, (config) => {
+      if (cluster.kindValues.case !== "eksKind") {
+        throw new Error("Invalid kind value for EKS");
+      }
+      cluster.kindValues.value = updateEKSKindValues(
+        config,
+        cluster.kindValues.value
+      );
+    })
+    .with({ kind: "GKE" }, (config) => {
+      if (cluster.kindValues.case !== "gkeKind") {
+        throw new Error("Invalid kind value for GKE");
+      }
+      cluster.kindValues.value = updateGKEKindValues(
+        config,
+        cluster.kindValues.value
+      );
+    })
+    .with({ kind: "AKS" }, (config) => {
+      if (cluster.kindValues.case !== "aksKind") {
+        throw new Error("Invalid kind value for AKS");
+      }
+      cluster.kindValues.value = updateAKSKindValues(
+        config,
+        cluster.kindValues.value
+      );
+    });
+
+  return cluster;
+}
+
+function updateEKSKindValues(
+  clientConfig: EKSClientClusterConfig,
+  existingConfig: EKS
+): EKS {
+  return new EKS({
+    ...existingConfig,
+    clusterName: clientConfig.clusterName,
+    region: clientConfig.region,
+    nodeGroups: clientConfig.nodeGroups.map((ng) => {
+      return new EKSNodeGroup({
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodeGroupType: match(ng.nodeGroupType)
+          .with("UNKNOWN", () => NodeGroupType.UNSPECIFIED)
+          .with("SYSTEM", () => NodeGroupType.SYSTEM)
+          .with("MONITORING", () => NodeGroupType.MONITORING)
+          .with("APPLICATION", () => NodeGroupType.APPLICATION)
+          .with("CUSTOM", () => NodeGroupType.CUSTOM)
+          .otherwise(() => NodeGroupType.UNSPECIFIED),
+      });
+    }),
+    cidrRange: clientConfig.cidrRange, // this should be removed once we no longer use the deprecated value
+    network: new AWSClusterNetwork({
+      ...(existingConfig?.network ?? {}),
+      vpcCidr: clientConfig.cidrRange,
+    }),
+  });
+}
+
+function updateGKEKindValues(
+  clientConfig: GKEClientClusterConfig,
+  existingConfig: GKE
+): GKE {
+  return new GKE({
+    ...existingConfig,
+    clusterName: clientConfig.clusterName,
+    region: clientConfig.region,
+    nodePools: clientConfig.nodeGroups.map((ng) => {
+      return new GKENodePool({
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodePoolType: match(ng.nodeGroupType)
+          .with("UNKNOWN", () => GKENodePoolType.GKE_NODE_POOL_TYPE_UNSPECIFIED)
+          .with("SYSTEM", () => GKENodePoolType.GKE_NODE_POOL_TYPE_SYSTEM)
+          .with(
+            "MONITORING",
+            () => GKENodePoolType.GKE_NODE_POOL_TYPE_MONITORING
+          )
+          .with(
+            "APPLICATION",
+            () => GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION
+          )
+          .with("CUSTOM", () => GKENodePoolType.GKE_NODE_POOL_TYPE_CUSTOM)
+          .otherwise(() => GKENodePoolType.GKE_NODE_POOL_TYPE_UNSPECIFIED),
+      });
+    }),
+    network: new GKENetwork({
+      ...(existingConfig?.network ?? {}),
+      cidrRange: clientConfig.cidrRange,
+    }),
+  });
+}
+
+function updateAKSKindValues(
+  clientConfig: AKSClientClusterConfig,
+  existingConfig: AKS
+): AKS {
+  return new AKS({
+    ...existingConfig,
+    clusterName: clientConfig.clusterName,
+    location: clientConfig.region,
+    nodePools: clientConfig.nodeGroups.map((ng) => {
+      return new AKSNodePool({
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodePoolType: match(ng.nodeGroupType)
+          .with("UNKNOWN", () => NodePoolType.UNSPECIFIED)
+          .with("SYSTEM", () => NodePoolType.SYSTEM)
+          .with("MONITORING", () => NodePoolType.MONITORING)
+          .with("APPLICATION", () => NodePoolType.APPLICATION)
+          .with("CUSTOM", () => NodePoolType.CUSTOM)
+          .otherwise(() => NodePoolType.UNSPECIFIED),
+      });
+    }),
+    skuTier: match(clientConfig.skuTier)
+      .with("FREE", () => AksSkuTier.FREE)
+      .with("STANDARD", () => AksSkuTier.STANDARD)
+      .otherwise(() => AksSkuTier.UNSPECIFIED),
+    cidrRange: clientConfig.cidrRange,
+  });
+}
+
+export function clientClusterContractFromProto(
+  contract: Contract
+): ClientClusterContract | undefined {
+  const contractCluster = contract.cluster;
+  if (!contractCluster?.kindValues?.case) {
+    return undefined;
+  }
+  return {
+    cluster: {
+      projectId: contractCluster.projectId,
+      clusterId: contractCluster.clusterId,
+      cloudProvider: match(contractCluster.cloudProvider)
+        .with(EnumCloudProvider.AWS, () => "AWS" as const)
+        .with(EnumCloudProvider.GCP, () => "GCP" as const)
+        .with(EnumCloudProvider.AZURE, () => "Azure" as const)
+        .otherwise(() => "Local" as const),
+      cloudProviderCredentialsId: contractCluster.cloudProviderCredentialsId,
+      config: match(contractCluster.kindValues)
+        .with({ case: "eksKind" }, ({ value }) => ({
+          kind: "EKS" as const,
+          clusterName: value.clusterName,
+          clusterVersion: value.clusterVersion,
+          region: value.region,
+          nodeGroups: value.nodeGroups.map((ng) => {
+            return {
+              instanceType: ng.instanceType,
+              minInstances: ng.minInstances,
+              maxInstances: ng.maxInstances,
+              nodeGroupType: match(ng.nodeGroupType)
+                .with(NodeGroupType.UNSPECIFIED, () => "UNKNOWN" as const)
+                .with(NodeGroupType.SYSTEM, () => "SYSTEM" as const)
+                .with(NodeGroupType.MONITORING, () => "MONITORING" as const)
+                .with(NodeGroupType.APPLICATION, () => "APPLICATION" as const)
+                .with(NodeGroupType.CUSTOM, () => "CUSTOM" as const)
+                .otherwise(() => "UNKNOWN" as const),
+            };
+          }),
+          cidrRange: value.network?.vpcCidr ?? value.cidrRange ?? "", // network will always be provided in one of those fields
+        }))
+        .with({ case: "gkeKind" }, ({ value }) => ({
+          kind: "GKE" as const,
+          clusterName: value.clusterName,
+          clusterVersion: value.clusterVersion,
+          region: value.region,
+          nodeGroups: value.nodePools.map((ng) => {
+            return {
+              instanceType: ng.instanceType,
+              minInstances: ng.minInstances,
+              maxInstances: ng.maxInstances,
+              nodeGroupType: match(ng.nodePoolType)
+                .with(
+                  GKENodePoolType.GKE_NODE_POOL_TYPE_UNSPECIFIED,
+                  () => "UNKNOWN" as const
+                )
+                .with(
+                  GKENodePoolType.GKE_NODE_POOL_TYPE_SYSTEM,
+                  () => "SYSTEM" as const
+                )
+                .with(
+                  GKENodePoolType.GKE_NODE_POOL_TYPE_MONITORING,
+                  () => "MONITORING" as const
+                )
+                .with(
+                  GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION,
+                  () => "APPLICATION" as const
+                )
+                .with(
+                  GKENodePoolType.GKE_NODE_POOL_TYPE_CUSTOM,
+                  () => "CUSTOM" as const
+                )
+                .otherwise(() => "UNKNOWN" as const),
+            };
+          }),
+          cidrRange: value.network?.cidrRange ?? "", // network will always be provided
+        }))
+        .with({ case: "aksKind" }, ({ value }) => ({
+          kind: "AKS" as const,
+          clusterName: value.clusterName,
+          clusterVersion: value.clusterVersion,
+          region: value.location,
+          nodeGroups: value.nodePools.map((ng) => {
+            return {
+              instanceType: ng.instanceType,
+              minInstances: ng.minInstances,
+              maxInstances: ng.maxInstances,
+              nodeGroupType: match(ng.nodePoolType)
+                .with(NodePoolType.UNSPECIFIED, () => "UNKNOWN" as const)
+                .with(NodePoolType.SYSTEM, () => "SYSTEM" as const)
+                .with(NodePoolType.MONITORING, () => "MONITORING" as const)
+                .with(NodePoolType.APPLICATION, () => "APPLICATION" as const)
+                .with(NodePoolType.CUSTOM, () => "CUSTOM" as const)
+                .otherwise(() => "UNKNOWN" as const),
+            };
+          }),
+          skuTier: match(value.skuTier)
+            .with(AksSkuTier.FREE, () => "FREE" as const)
+            .with(AksSkuTier.STANDARD, () => "STANDARD" as const)
+            .otherwise(() => "UNKNOWN" as const),
+          cidrRange: value.cidrRange,
+        }))
+        .exhaustive(),
+    },
+  };
+}

+ 424 - 19
dashboard/src/lib/clusters/types.ts

@@ -1,53 +1,296 @@
+import { type Contract } from "@porter-dev/api-contracts";
 import { z } from "zod";
 
 import { checkGroupValidator } from "main/home/compliance-dashboard/types";
 
 import { CloudProviderAWS } from "./constants";
 
-export type CloudProvider = {
-  name: SerializedCluster["cloud_provider"];
+// Cloud
+const cloudProviderValidator = z.enum(["AWS", "GCP", "Azure", "Local"]);
+export type CloudProvider = z.infer<typeof cloudProviderValidator>;
+export type ClientCloudProvider = {
+  name: CloudProvider;
   displayName: string;
   icon: string;
+  regions: ClientRegion[];
+  machineTypes: MachineType[];
+  baseCost: number;
+  newClusterDefaultContract: Contract; // this is where we include sensible defaults for new clusters
+  preflightChecks: PreflightCheck[];
+  // catch all for cloud-specific settings, may refactor this later
+  config:
+    | {
+        kind: "Azure";
+        skuTiers: AzureSKUTier[];
+      }
+    | {
+        kind: "AWS";
+      }
+    | {
+        kind: "GCP";
+      }
+    | {
+        kind: "Local";
+      };
+};
+const awsRegionValidator = z.enum([
+  "us-east-1",
+  "us-east-2",
+  "us-west-1",
+  "us-west-2",
+  "af-south-1",
+  "ap-east-1",
+  "ap-south-1",
+  "ap-northeast-2",
+  "ap-southeast-1",
+  "ap-southeast-2",
+  "ap-northeast-1",
+  "ca-central-1",
+  "eu-central-1",
+  "eu-west-1",
+  "eu-west-2",
+  "eu-south-1",
+  "eu-west-3",
+  "eu-north-1",
+  "me-south-1",
+  "sa-east-1",
+]);
+type AWSRegion = z.infer<typeof awsRegionValidator>;
+const gcpRegionValidator = z.enum([
+  "us-east1",
+  "us-east4",
+  "us-central1",
+  "europe-north1",
+  "europe-central2",
+  "europe-west1",
+  "europe-west2",
+  "europe-west6",
+  "asia-south1",
+  "us-west1",
+  "us-west2",
+  "us-west3",
+  "us-west4",
+]);
+type GCPRegion = z.infer<typeof gcpRegionValidator>;
+const azureRegionValidator = z.enum([
+  "australiaeast",
+  "brazilsouth",
+  "canadacentral",
+  "centralindia",
+  "centralus",
+  "eastasia",
+  "eastus",
+  "eastus2",
+  "francecentral",
+  "northeurope",
+  "norwayeast",
+  "southafricanorth",
+  "southcentralus",
+  "swedencentral",
+  "switzerlandnorth",
+  "uaenorth",
+  "uksouth",
+  "westeurope",
+  "westus2",
+  "westus3",
+]);
+type AzureRegion = z.infer<typeof azureRegionValidator>;
+export type ClientRegion = {
+  name: AWSRegion | GCPRegion | AzureRegion;
+  displayName: string;
+};
+const awsMachineTypeValidator = z.enum([
+  "t3.medium",
+  "t3.large",
+  "t3.xlarge",
+  "t3.2xlarge",
+  "t3a.medium",
+  "t3a.large",
+  "t3a.xlarge",
+  "t3a.2xlarge",
+  "t4g.medium",
+  "t4g.large",
+  "t4g.xlarge",
+  "t4g.2xlarge",
+  "c6i.large",
+  "c6i.xlarge",
+  "c6i.2xlarge",
+  "c6i.4xlarge",
+  "c6i.8xlarge",
+  "c6a.large",
+  "c6a.2xlarge",
+  "c6a.4xlarge",
+  "c6a.8xlarge",
+  "r6i.large",
+  "r6i.xlarge",
+  "r6i.2xlarge",
+  "r6i.4xlarge",
+  "r6i.8xlarge",
+  "r6i.12xlarge",
+  "r6i.16xlarge",
+  "r6i.24xlarge",
+  "r6i.32xlarge",
+  "m5n.large",
+  "m5n.xlarge",
+  "m5n.2xlarge",
+  "m6a.large",
+  "m6a.xlarge",
+  "m6a.2xlarge",
+  "m6a.4xlarge",
+  "m6a.8xlarge",
+  "m6a.12xlarge",
+  "m7a.medium",
+  "m7a.large",
+  "m7a.xlarge",
+  "m7a.2xlarge",
+  "m7a.4xlarge",
+  "m7a.8xlarge",
+  "m7a.12xlarge",
+  "m7a.16xlarge",
+  "m7a.24xlarge",
+  "m7i.large",
+  "m7i.xlarge",
+  "m7i.2xlarge",
+  "m7i.4xlarge",
+  "m7i.8xlarge",
+  "m7i.12xlarge",
+  "c7a.medium",
+  "c7a.large",
+  "c7a.xlarge",
+  "c7a.2xlarge",
+  "c7a.4xlarge",
+  "c7a.8xlarge",
+  "c7a.12xlarge",
+  "c7a.16xlarge",
+  "c7a.24xlarge",
+]);
+type AWSMachineType = z.infer<typeof awsMachineTypeValidator>;
+const gcpMachineTypeValidator = z.enum([
+  "e2-standard-2",
+  "e2-standard-4",
+  "e2-standard-8",
+  "e2-standard-16",
+  "e2-standard-32",
+  "c3-standard-4",
+  "c3-standard-8",
+  "c3-standard-22",
+  "c3-standard-44",
+  "c3-highcpu-4",
+  "c3-highcpu-8",
+  "c3-highcpu-22",
+  "c3-highcpu-44",
+  "c3-highmem-4",
+  "c3-highmem-8",
+  "c3-highmem-22",
+  "c3-highmem-44",
+  "n1-standard-1",
+  "n1-standard-2",
+  "n1-standard-4",
+  "n1-standard-8",
+  "n1-standard-16",
+  "n1-standard-32",
+  "n1-highmem-2",
+  "n1-highmem-4",
+  "n1-highmem-8",
+  "n1-highmem-16",
+  "n1-highmem-32",
+  "n1-highcpu-8",
+  "n1-highcpu-16",
+  "n1-highcpu-32",
+]);
+type GCPMachineType = z.infer<typeof gcpMachineTypeValidator>;
+const azureMachineTypeValidator = z.enum([
+  "Standard_B2als_v2",
+  "Standard_B2as_v2",
+  "Standard_A2_v2",
+  "Standard_A4_v2",
+  "Standard_DS1_v2",
+  "Standard_DS2_v2",
+  "Standard_D2ads_v5",
+  "Standard_B4als_v2",
+  "Standard_NC4as_T4_v3",
+  "Standard_NC8as_T4_v3",
+  "Standard_NC16as_T4_v3",
+  "Standard_NC64as_T4_v3",
+  "Standard_D8s_v3",
+]);
+type AzureMachineType = z.infer<typeof azureMachineTypeValidator>;
+type AzureSKUTier = {
+  name: string;
+  displayName: string;
+};
+export type MachineType = {
+  name: AWSMachineType | GCPMachineType | AzureMachineType;
+  displayName: string;
+  supportedRegions: Array<AWSRegion | GCPRegion | AzureRegion>;
+};
+type PreflightCheckResolutionStep = {
+  text: string;
+  externalLink?: string;
+};
+export type PreflightCheckResolution = {
+  title: string;
+  subtitle: string;
+  steps: PreflightCheckResolutionStep[];
+};
+export type PreflightCheck = {
+  name: PreflightCheckKey;
+  displayName: string;
+  resolution?: PreflightCheckResolution;
 };
 
+// Cluster
 export const clusterValidator = z.object({
   id: z.number(),
   name: z.string(),
   vanity_name: z.string(),
-  cloud_provider: z.enum(["AWS", "GCP", "Azure", "Local"]),
+  cloud_provider: cloudProviderValidator,
   cloud_provider_credential_identifier: z.string(),
   status: z.string(),
-  // created_at: z.string(),
-  // updated_at: z.string(),
 });
 export type SerializedCluster = z.infer<typeof clusterValidator>;
 export type ClientCluster = Omit<SerializedCluster, "cloud_provider"> & {
-  cloud_provider: CloudProvider;
+  cloud_provider: ClientCloudProvider;
+  contract: APIContract & {
+    config: ClientClusterContract;
+  };
+  state?: ClusterState;
 };
 export const isAWSCluster = (
   cluster: ClientCluster
 ): cluster is ClientCluster => {
   return cluster.cloud_provider === CloudProviderAWS;
 };
+export const clusterStateValidator = z.object({
+  phase: z.string(),
+  is_infrastructure_ready: z.boolean(),
+  is_control_plane_ready: z.boolean(),
+});
+export type ClusterState = z.infer<typeof clusterStateValidator>;
 
+// Contract
+const contractConditionValidator = z.enum([
+  "",
+  "QUOTA_REQUEST_FAILED",
+  "RETRYING_TOO_LONG",
+  "KUBE_APPLY_FAILED",
+  "FATAL_PROVISIONING_ERROR",
+  "ERROR_READING_MSG",
+  "MSG_CAUSED_PANIC",
+  "SUCCESS",
+  "DELETING",
+  "DELETED",
+  "COMPLIANCE_CHECK_FAILED",
+]);
+export type ContractCondition = z.infer<typeof contractConditionValidator>;
 export const contractValidator = z.object({
   id: z.string(),
   base64_contract: z.string(),
+  created_at: z.string(),
+  updated_at: z.string(),
   cluster_id: z.number(),
   project_id: z.number(),
-  condition: z.enum([
-    "",
-    "QUOTA_REQUEST_FAILED",
-    "RETRYING_TOO_LONG",
-    "KUBE_APPLY_FAILED",
-    "FATAL_PROVISIONING_ERROR",
-    "ERROR_READING_MSG",
-    "MSG_CAUSED_PANIC",
-    "SUCCESS",
-    "DELETING",
-    "DELETED",
-    "COMPLIANCE_CHECK_FAILED",
-  ]),
+  condition: contractConditionValidator,
   condition_metadata: z
     .discriminatedUnion("code", [
       z.object({
@@ -95,4 +338,166 @@ export const contractValidator = z.object({
       }))
     ),
 });
+// this is the type of the object that is returned from the getContract API, but only the base64_contract field is editable by the user
 export type APIContract = z.infer<typeof contractValidator>;
+const eksNodeGroupTypeValidator = z.enum([
+  "UNKNOWN",
+  "SYSTEM",
+  "MONITORING",
+  "APPLICATION",
+  "CUSTOM",
+]);
+const gkeNodeGroupTypeValidator = z.enum([
+  "UNKNOWN",
+  "SYSTEM",
+  "MONITORING",
+  "APPLICATION",
+  "CUSTOM",
+]);
+const eksNodeGroupValidator = z.object({
+  instanceType: z.string(),
+  minInstances: z.number(),
+  maxInstances: z.number(),
+  nodeGroupType: eksNodeGroupTypeValidator,
+});
+const gkeNodeGroupValidator = z.object({
+  instanceType: z.string(),
+  minInstances: z.number(),
+  maxInstances: z.number(),
+  nodeGroupType: gkeNodeGroupTypeValidator,
+});
+const aksNodeGroupTypeValidator = z.enum([
+  "UNKNOWN",
+  "SYSTEM",
+  "MONITORING",
+  "APPLICATION",
+  "CUSTOM",
+]);
+const aksNodeGroupValidator = z.object({
+  instanceType: z.string(),
+  minInstances: z.number(),
+  maxInstances: z.number(),
+  nodeGroupType: aksNodeGroupTypeValidator,
+});
+
+const cidrRangeValidator = z
+  .string()
+  .regex(
+    /^((1\d{2}|2[0-4]\d|25[0-4]|[1-9]\d|[1-9])\.(0|1\d{2}|2[0-4]\d|25[0-4]|[1-9]\d|\d)\.0\.0)\/16$/,
+    {
+      message: "CIDR range must be in the format (1-254).(0-254).0.0/16",
+    }
+  );
+
+const eksConfigValidator = z.object({
+  kind: z.literal("EKS"),
+  clusterName: z
+    .string()
+    .min(1, { message: "Name must be at least 1 character" })
+    .max(31, { message: "Name must be 31 characters or less" })
+    .regex(/^[a-z0-9-]{1,61}$/, {
+      message: 'Lowercase letters, numbers, and "-" only.',
+    }),
+  clusterVersion: z.string().optional().default(""),
+  region: awsRegionValidator,
+  nodeGroups: eksNodeGroupValidator.array(),
+  cidrRange: cidrRangeValidator,
+});
+const gkeConfigValidator = z.object({
+  kind: z.literal("GKE"),
+  clusterName: z
+    .string()
+    .min(1, { message: "Name must be at least 1 character" })
+    .max(31, { message: "Name must be 31 characters or less" })
+    .regex(/^[a-z0-9-]{1,61}$/, {
+      message: 'Lowercase letters, numbers, and "-" only.',
+    }),
+  clusterVersion: z.string().optional().default(""),
+  region: gcpRegionValidator,
+  nodeGroups: gkeNodeGroupValidator.array(),
+  cidrRange: cidrRangeValidator,
+});
+const aksConfigValidator = z.object({
+  kind: z.literal("AKS"),
+  clusterName: z
+    .string()
+    .min(1, { message: "Name must be at least 1 character" })
+    .max(31, { message: "Name must be 31 characters or less" })
+    .regex(/^[a-z0-9-]{1,61}$/, {
+      message: 'Lowercase letters, numbers, and "-" only.',
+    }),
+  clusterVersion: z.string().optional().default(""),
+  region: azureRegionValidator,
+  nodeGroups: aksNodeGroupValidator.array(),
+  skuTier: z.enum(["UNKNOWN", "FREE", "STANDARD"]),
+  cidrRange: cidrRangeValidator,
+});
+const clusterConfigValidator = z.discriminatedUnion("kind", [
+  eksConfigValidator,
+  gkeConfigValidator,
+  aksConfigValidator,
+]);
+
+const contractClusterValidator = z.object({
+  projectId: z.number(),
+  clusterId: z.number().optional(),
+  cloudProvider: cloudProviderValidator,
+  cloudProviderCredentialsId: z.string(),
+  config: clusterConfigValidator,
+});
+export type ClientClusterConfig = z.infer<typeof clusterConfigValidator>;
+export type EKSClientClusterConfig = z.infer<typeof eksConfigValidator>;
+export type GKEClientClusterConfig = z.infer<typeof gkeConfigValidator>;
+export type AKSClientClusterConfig = z.infer<typeof aksConfigValidator>;
+export const clusterContractValidator = z.object({
+  cluster: contractClusterValidator,
+});
+export type ClientClusterContract = z.infer<typeof clusterContractValidator>;
+
+const preflightCheckKeyValidator = z.enum([
+  "eip",
+  "vcpu",
+  "vpc",
+  "natGateway",
+  "apiEnabled",
+  "cidrAvailability",
+  "iamPermissions",
+]);
+type PreflightCheckKey = z.infer<typeof preflightCheckKeyValidator>;
+export const preflightCheckValidator = z.object({
+  errors: z
+    .object({
+      name: preflightCheckKeyValidator,
+      error: z.object({
+        message: z.string(),
+        metadata: z.record(z.string()).optional(),
+      }),
+    })
+    .array(),
+});
+export const createContractResponseValidator = z.object({
+  contract_revision: z.object({
+    project_id: z.number(),
+    cluster_id: z.number(),
+    revision_id: z.string(),
+  }),
+});
+export type ClientPreflightCheck = {
+  title: string;
+  status: "pending" | "success" | "failure";
+  error?: {
+    detail: string;
+    metadata: Record<string, string> | undefined;
+    resolution?: PreflightCheckResolution;
+  };
+};
+type CreateContractResponse = z.infer<typeof createContractResponseValidator>;
+export type UpdateClusterResponse =
+  | {
+      preflightChecks: ClientPreflightCheck[];
+      createContractResponse?: CreateContractResponse;
+    }
+  | {
+      preflightChecks?: ClientPreflightCheck[];
+      createContractResponse: CreateContractResponse;
+    };

+ 86 - 0
dashboard/src/lib/hooks/useCloudProvider.ts

@@ -0,0 +1,86 @@
+import { z } from "zod";
+
+import api from "shared/api";
+
+// TODO: refactor this to match "connectTo.." syntax
+export const isAWSArnAccessible = async ({
+  targetArn,
+  externalId,
+  projectId,
+}: {
+  targetArn: string;
+  externalId: string;
+  projectId: number;
+}): Promise<boolean> => {
+  try {
+    await api.createAWSIntegration(
+      "<token>",
+      {
+        aws_target_arn: targetArn,
+        aws_external_id: externalId,
+      },
+      { id: projectId }
+    );
+    return true;
+  } catch (err) {
+    return false;
+  }
+};
+
+export const connectToAzureAccount = async ({
+  subscriptionId,
+  clientId,
+  tenantId,
+  servicePrincipalKey,
+  projectId,
+}: {
+  subscriptionId: string;
+  clientId: string;
+  tenantId: string;
+  servicePrincipalKey: string;
+  projectId: number;
+}): Promise<string> => {
+  const res = await api.createAzureIntegration(
+    "<token",
+    {
+      azure_subscription_id: subscriptionId,
+      azure_client_id: clientId,
+      azure_tenant_id: tenantId,
+      service_principal_key: servicePrincipalKey,
+    },
+    { id: projectId }
+  );
+  const parsed = await z
+    .object({
+      cloud_provider_credentials_id: z.string(),
+    })
+    .parseAsync(res.data);
+  return parsed.cloud_provider_credentials_id;
+};
+
+export const connectToGCPAccount = async ({
+  projectId,
+  serviceAccountKey,
+  gcpProjectId,
+}: {
+  projectId: number;
+  serviceAccountKey: string;
+  gcpProjectId: string;
+}): Promise<string> => {
+  const res = await api.createGCPIntegration(
+    "<token",
+    {
+      gcp_key_data: serviceAccountKey,
+      gcp_project_id: gcpProjectId,
+    },
+    { project_id: projectId }
+  );
+
+  const parsed = await z
+    .object({
+      cloud_provider_credentials_id: z.string(),
+    })
+    .parseAsync(res.data);
+
+  return parsed.cloud_provider_credentials_id;
+};

+ 301 - 17
dashboard/src/lib/hooks/useCluster.ts

@@ -1,14 +1,32 @@
-import { useContext } from "react";
-import { Contract } from "@porter-dev/api-contracts";
+import { useContext, useState } from "react";
+import { Contract, PreflightCheckRequest } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import { match } from "ts-pattern";
 import { z } from "zod";
 
-import { SUPPORTED_CLOUD_PROVIDERS } from "lib/clusters/constants";
 import {
+  clientClusterContractFromProto,
+  updateExistingClusterContract,
+} from "lib/clusters";
+import {
+  CloudProviderAWS,
+  CloudProviderGCP,
+  SUPPORTED_CLOUD_PROVIDERS,
+} from "lib/clusters/constants";
+import {
+  clusterStateValidator,
   clusterValidator,
   contractValidator,
+  createContractResponseValidator,
+  preflightCheckValidator,
   type APIContract,
   type ClientCluster,
+  type ClientClusterContract,
+  type ClientPreflightCheck,
+  type ClusterState,
+  type ContractCondition,
+  type UpdateClusterResponse,
 } from "lib/clusters/types";
 
 import api from "shared/api";
@@ -34,7 +52,7 @@ export const useClusterList = (): TUseClusterList => {
         { id: currentProject.id }
       );
       const parsed = await z.array(clusterValidator).parseAsync(res.data);
-      return parsed
+      const filtered = parsed
         .map((c) => {
           const cloudProviderMatch = SUPPORTED_CLOUD_PROVIDERS.find(
             (s) => s.name === c.cloud_provider
@@ -44,6 +62,41 @@ export const useClusterList = (): TUseClusterList => {
             : null;
         })
         .filter(valueExists);
+      const latestContractsRes = await api.getContracts(
+        "<token>",
+        { latest: true },
+        { project_id: currentProject.id }
+      );
+      const latestContracts = await z
+        .array(contractValidator)
+        .parseAsync(latestContractsRes.data);
+      return filtered
+        .map((c) => {
+          const latestContract = latestContracts.find(
+            (contract) => contract.cluster_id === c.id
+          );
+          // if this cluster has no latest contract, don't include it
+          if (!latestContract) {
+            return undefined;
+          }
+          const latestClientContract = clientClusterContractFromProto(
+            Contract.fromJsonString(atob(latestContract.base64_contract), {
+              ignoreUnknownFields: true,
+            })
+          );
+          // if we can't parse the latest contract, don't include it
+          if (!latestClientContract) {
+            return undefined;
+          }
+          return {
+            ...c,
+            contract: {
+              ...latestContract,
+              config: latestClientContract,
+            },
+          };
+        })
+        .filter(valueExists);
     },
     {
       enabled: !!currentProject && currentProject.id !== -1,
@@ -63,8 +116,10 @@ type TUseCluster = {
 };
 export const useCluster = ({
   clusterId,
+  refetchInterval,
 }: {
   clusterId: number | undefined;
+  refetchInterval?: number;
 }): TUseCluster => {
   const { currentProject } = useContext(Context);
 
@@ -79,6 +134,8 @@ export const useCluster = ({
       ) {
         return;
       }
+
+      // get the cluster + match with what we know
       const res = await api.getCluster(
         "<token>",
         {},
@@ -91,7 +148,45 @@ export const useCluster = ({
       if (!cloudProviderMatch) {
         return;
       }
-      return { ...parsed, cloud_provider: cloudProviderMatch };
+
+      // get the latest contract
+      const latestContractsRes = await api.getContracts(
+        "<token>",
+        { latest: true, cluster_id: clusterId },
+        { project_id: currentProject.id }
+      );
+      const latestContracts = await z
+        .array(contractValidator)
+        .parseAsync(latestContractsRes.data);
+      if (latestContracts.length !== 1) {
+        return;
+      }
+      const latestClientContract = clientClusterContractFromProto(
+        Contract.fromJsonString(atob(latestContracts[0].base64_contract), {
+          ignoreUnknownFields: true,
+        })
+      );
+      if (!latestClientContract) {
+        return;
+      }
+
+      // get the latest state
+      const stateRes = await api.getClusterState(
+        "<token>",
+        {},
+        { project_id: currentProject.id, cluster_id: clusterId }
+      );
+      const state = await clusterStateValidator.parseAsync(stateRes.data);
+
+      return {
+        ...parsed,
+        cloud_provider: cloudProviderMatch,
+        state,
+        contract: {
+          ...latestContracts[0],
+          config: latestClientContract,
+        },
+      };
     },
     {
       enabled:
@@ -99,6 +194,7 @@ export const useCluster = ({
         currentProject.id !== -1 &&
         !!clusterId &&
         clusterId !== -1,
+      refetchInterval,
     }
   );
 
@@ -116,6 +212,8 @@ export const useLatestClusterContract = ({
 }): {
   contractDB: APIContract | undefined;
   contractProto: Contract | undefined;
+  clientContract: ClientClusterContract | undefined;
+  clusterCondition: ContractCondition | undefined;
   isLoading: boolean;
   isError: boolean;
 } => {
@@ -135,25 +233,27 @@ export const useLatestClusterContract = ({
 
       const res = await api.getContracts(
         "<token>",
-        {
-          latest: true,
-        },
+        { cluster_id: clusterId, latest: true },
         { project_id: currentProject.id }
       );
 
       const data = await z.array(contractValidator).parseAsync(res.data);
-      const filtered = data.filter(
-        (contract) => contract.cluster_id === clusterId
-      );
-      if (filtered.length === 0) {
+      if (data.length !== 1) {
         return;
       }
-      const match = filtered[0];
-      return {
-        contractDB: match,
-        contractProto: Contract.fromJsonString(atob(match.base64_contract), {
+      const contractDB = data[0];
+      const contractProto = Contract.fromJsonString(
+        atob(contractDB.base64_contract),
+        {
           ignoreUnknownFields: true,
-        }),
+        }
+      );
+      const clientContract = clientClusterContractFromProto(contractProto);
+      return {
+        contractDB,
+        contractProto,
+        clientContract,
+        clusterCondition: contractDB.condition,
       };
     },
     {
@@ -169,7 +269,191 @@ export const useLatestClusterContract = ({
   return {
     contractDB: latestClusterContractReq.data?.contractDB,
     contractProto: latestClusterContractReq.data?.contractProto,
+    clientContract: latestClusterContractReq.data?.clientContract,
+    clusterCondition: latestClusterContractReq.data?.clusterCondition,
     isLoading: latestClusterContractReq.isLoading,
     isError: latestClusterContractReq.isError,
   };
 };
+
+type TUseClusterState = {
+  state: ClusterState | undefined;
+  isLoading: boolean;
+  isError: boolean;
+};
+export const useClusterState = ({
+  clusterId,
+}: {
+  clusterId: number | undefined;
+}): TUseClusterState => {
+  const { currentProject } = useContext(Context);
+
+  const clusterStateReq = useQuery(
+    ["getClusterState", currentProject?.id, clusterId],
+    async () => {
+      if (
+        !currentProject?.id ||
+        currentProject.id === -1 ||
+        !clusterId ||
+        clusterId === -1
+      ) {
+        return;
+      }
+      const res = await api.getClusterState(
+        "<token>",
+        {},
+        { project_id: currentProject.id, cluster_id: clusterId }
+      );
+      const parsed = await clusterStateValidator.parseAsync(res.data);
+      return parsed;
+    },
+    {
+      enabled:
+        !!currentProject &&
+        currentProject.id !== -1 &&
+        !!clusterId &&
+        clusterId !== -1,
+      refetchInterval: 5000,
+    }
+  );
+
+  return {
+    state: clusterStateReq.data,
+    isLoading: clusterStateReq.isLoading,
+    isError: clusterStateReq.isError,
+  };
+};
+
+type TUseUpdateCluster = {
+  updateCluster: (
+    clientContract: ClientClusterContract,
+    baseContract: Contract
+  ) => Promise<UpdateClusterResponse>;
+  isHandlingPreflightChecks: boolean;
+  isCreatingContract: boolean;
+};
+export const useUpdateCluster = ({
+  projectId,
+}: {
+  projectId: number | undefined;
+}): TUseUpdateCluster => {
+  const [isHandlingPreflightChecks, setIsHandlingPreflightChecks] =
+    useState<boolean>(false);
+  const [isCreatingContract, setIsCreatingContract] = useState<boolean>(false);
+
+  const updateCluster = async (
+    clientContract: ClientClusterContract,
+    baseContract: Contract
+  ): Promise<UpdateClusterResponse> => {
+    if (!projectId) {
+      throw new Error("Project ID is missing");
+    }
+    if (!baseContract.cluster) {
+      throw new Error("Cluster is missing");
+    }
+    const newContract = new Contract({
+      ...baseContract,
+      cluster: updateExistingClusterContract(
+        clientContract,
+        baseContract.cluster
+      ),
+    });
+
+    setIsHandlingPreflightChecks(true);
+    try {
+      const preflightCheckResp = await api.preflightCheck(
+        "<token>",
+        new PreflightCheckRequest({
+          contract: newContract,
+        }),
+        {
+          id: projectId,
+        }
+      );
+      const parsed = await preflightCheckValidator.parseAsync(
+        preflightCheckResp.data
+      );
+
+      if (parsed.errors.length > 0) {
+        const cloudProviderSpecificChecks = match(
+          clientContract.cluster.cloudProvider
+        )
+          .with("AWS", () => CloudProviderAWS.preflightChecks)
+          .with("GCP", () => CloudProviderGCP.preflightChecks)
+          .otherwise(() => []);
+
+        const clientPreflightChecks: ClientPreflightCheck[] = parsed.errors
+          .map((e) => {
+            const preflightCheckMatch = cloudProviderSpecificChecks.find(
+              (cloudProviderCheck) => e.name === cloudProviderCheck.name
+            );
+            if (!preflightCheckMatch) {
+              return undefined;
+            }
+            return {
+              title: preflightCheckMatch.displayName,
+              status: "failure" as const,
+              error: {
+                detail: e.error.message,
+                metadata: e.error.metadata,
+                resolution: preflightCheckMatch.resolution,
+              },
+            };
+          })
+          .filter(valueExists);
+        return {
+          preflightChecks: clientPreflightChecks,
+        };
+      }
+      // otherwise, continue to create the contract
+    } catch (err) {
+      throw new Error(
+        getErrorMessageFromNetworkCall(err, "Cluster preflight checks")
+      );
+    } finally {
+      setIsHandlingPreflightChecks(false);
+    }
+
+    setIsCreatingContract(true);
+    try {
+      const createContractResp = await api.createContract(
+        "<token>",
+        newContract,
+        {
+          project_id: projectId,
+        }
+      );
+      const parsed = await createContractResponseValidator.parseAsync(
+        createContractResp.data
+      );
+      return {
+        createContractResponse: parsed,
+      };
+    } catch (err) {
+      throw new Error(getErrorMessageFromNetworkCall(err, "Cluster creation"));
+    } finally {
+      setIsCreatingContract(false);
+    }
+  };
+
+  return {
+    updateCluster,
+    isHandlingPreflightChecks,
+    isCreatingContract,
+  };
+};
+
+const getErrorMessageFromNetworkCall = (
+  err: unknown,
+  networkCallDescription: string
+): string => {
+  if (axios.isAxiosError(err)) {
+    const parsed = z
+      .object({ error: z.string() })
+      .safeParse(err.response?.data);
+    if (parsed.success) {
+      return `${networkCallDescription} failed: ${parsed.data.error}`;
+    }
+  }
+  return `${networkCallDescription} failed: please try again or contact support@porter.run if the error persists.`;
+};

+ 1 - 1
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -332,7 +332,7 @@ export const useClusterResourceLimits = ({
   }, [getClusterNodes]);
 
   const getCluster = useQuery(
-    ["getCluster", projectId, clusterId],
+    ["getClusterIngressIp", projectId, clusterId],
     async () => {
       if (!projectId || !clusterId || clusterId === -1) {
         return await Promise.resolve({ ingress_ip: "" });

+ 19 - 29
dashboard/src/main/home/Home.tsx

@@ -30,7 +30,6 @@ import {
   type ProjectListType,
   type ProjectType,
 } from "shared/types";
-import { overrideInfraTabEnabled } from "utils/infrastructure";
 
 import discordLogo from "../../assets/discord.svg";
 import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
@@ -38,7 +37,6 @@ import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
 import AppView from "./app-dashboard/app-view/AppView";
 import AppDashboard from "./app-dashboard/AppDashboard";
 import Apps from "./app-dashboard/apps/Apps";
-import CreateEnvGroup from "./env-dashboard/CreateEnvGroup";
 import CreateApp from "./app-dashboard/create-app/CreateApp";
 import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
@@ -50,7 +48,12 @@ import Dashboard from "./dashboard/Dashboard";
 import CreateDatabase from "./database-dashboard/CreateDatabase";
 import DatabaseDashboard from "./database-dashboard/DatabaseDashboard";
 import DatabaseView from "./database-dashboard/DatabaseView";
-import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
+import CreateEnvGroup from "./env-dashboard/CreateEnvGroup";
+import EnvDashboard from "./env-dashboard/EnvDashboard";
+import ExpandedEnv from "./env-dashboard/ExpandedEnv";
+import ClusterDashboard from "./infrastructure-dashboard/ClusterDashboard";
+import ClusterView from "./infrastructure-dashboard/ClusterView";
+import CreateClusterForm from "./infrastructure-dashboard/forms/CreateClusterForm";
 import Integrations from "./integrations/Integrations";
 import LaunchWrapper from "./launch/LaunchWrapper";
 import ModalHandler from "./ModalHandler";
@@ -59,8 +62,6 @@ import { NewProjectFC } from "./new-project/NewProject";
 import Onboarding from "./onboarding/Onboarding";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
-import ExpandedEnv from "./env-dashboard/ExpandedEnv";
-import EnvDashboard from "./env-dashboard/EnvDashboard";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -421,7 +422,7 @@ const Home: React.FC<Props> = (props) => {
                 document.body
               )}
             {/* Render sidebar when there's at least one project */}
-            {projects?.length > 0 && baseRoute !== "new-project" ? (
+            {projects?.length > 0 && baseRoute !== "new-project" && (
               <Sidebar
                 key="sidebar"
                 forceSidebar={forceSidebar}
@@ -430,14 +431,6 @@ const Home: React.FC<Props> = (props) => {
                 forceRefreshClusters={forceRefreshClusters}
                 setRefreshClusters={setForceRefreshClusters}
               />
-            ) : (
-              <DiscordButton
-                href="https://discord.gg/34n7NN7FJ7"
-                target="_blank"
-              >
-                <Icon src={discordLogo} />
-                Join Our Discord
-              </DiscordButton>
             )}
             <ViewWrapper id="HomeViewWrapper">
               <Navbar
@@ -526,21 +519,18 @@ const Home: React.FC<Props> = (props) => {
                     return <Onboarding />;
                   }}
                 />
-                {(user?.isPorterUser ||
-                  overrideInfraTabEnabled({
-                    projectID: currentProject?.id,
-                  })) && (
-                  <Route
-                    path="/infrastructure"
-                    render={() => {
-                      return (
-                        <DashboardWrapper>
-                          <InfrastructureRouter />
-                        </DashboardWrapper>
-                      );
-                    }}
-                  />
-                )}
+                <Route path="/infrastructure/new">
+                  <CreateClusterForm />
+                </Route>
+                <Route path="/infrastructure/:clusterId/:tab">
+                  <ClusterView />
+                </Route>
+                <Route path="/infrastructure/:clusterId">
+                  <ClusterView />
+                </Route>
+                <Route path="/infrastructure">
+                  <ClusterDashboard />
+                </Route>
                 <Route
                   path="/dashboard"
                   render={() => {

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

@@ -39,7 +39,7 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
 
         {this.props.description && (
           <>
-            <Spacer height="35px" />
+            <Spacer y={1} />
             <InfoSection>
               <TopRow>
                 <Tooltip content="TestInfo" position="bottom" hidden={true}>

+ 0 - 8
dashboard/src/main/home/cluster-dashboard/DashboardRouter.tsx

@@ -23,14 +23,6 @@ import JobDashboard from "./jobs/JobDashboard";
 import ExpandedEnvGroupDashboard from "./env-groups/ExpandedEnvGroupDashboard";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 
-const LazyDatabasesRoutes = loadable(
-  // @ts-ignore
-  () => import("./databases/routes.tsx"),
-  {
-    fallback: <Loading />,
-  }
-);
-
 const LazyPreviewEnvironmentsRoutes = loadable(
   // @ts-ignore
   () => import("./preview-environments/routes.tsx"),

+ 0 - 75
dashboard/src/main/home/cluster-dashboard/databases/DatabasesHome.tsx

@@ -1,75 +0,0 @@
-import React, { useContext, useEffect, useState } from "react";
-import TabSelector from "components/TabSelector";
-import DashboardHeader from "../DashboardHeader";
-import DatabasesList from "./DatabasesList";
-import { StatusPage } from "main/home/onboarding/steps/ProvisionResources/forms/StatusPage";
-import { Context } from "shared/Context";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
-import { getQueryParam, useRouting } from "shared/routing";
-
-const AvailableTabs = ["databases-list", "provisioner-status"] as const;
-
-type AvailableTabsType = typeof AvailableTabs[number];
-
-const DatabasesHome = () => {
-  const { currentProject } = useContext(Context);
-  const [currentTab, setCurrentTab] = useState<AvailableTabsType>(
-    "databases-list"
-  );
-  const { pushQueryParams } = useRouting();
-  const location = useLocation();
-  const history = useHistory();
-
-  useEffect(() => {
-    const current_tab = getQueryParam(
-      { location },
-      "current_tab"
-    ) as AvailableTabsType;
-
-    if (!AvailableTabs.includes(current_tab)) {
-      return;
-    }
-
-    if (current_tab !== currentTab) {
-      setCurrentTab(current_tab);
-    }
-  }, [location.search, history]);
-
-  return (
-    <div>
-      <DashboardHeader
-        image="storage"
-        title="Databases"
-        description="List of databases created and linked to this cluster."
-        materialIconClass="material-icons-outlined"
-        disableLineBreak
-      />
-      <TabSelector
-        currentTab={currentTab}
-        options={[
-          {
-            label: "Databases",
-            value: "databases-list",
-            component: <DatabasesList />,
-          },
-          {
-            label: "Provisioner status",
-            value: "provisioner-status",
-            component: (
-              <StatusPage
-                project_id={currentProject.id}
-                filter={["rds"]}
-                setInfraStatus={() => {}}
-              />
-            ),
-          },
-        ]}
-        setCurrentTab={(newTab: AvailableTabsType) => {
-          pushQueryParams({ current_tab: newTab });
-        }}
-      />
-    </div>
-  );
-};
-
-export default DatabasesHome;

+ 0 - 364
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -1,364 +0,0 @@
-import CopyToClipboard from "components/CopyToClipboard";
-import Table from "components/OldTable";
-import React, { useContext, useEffect, useMemo, useState } from "react";
-import { useRouteMatch } from "react-router";
-import { Link } from "react-router-dom";
-import { Column, Row } from "react-table";
-import api from "shared/api";
-import useAuth from "shared/auth/useAuth";
-import { Context } from "shared/Context";
-import { useRouting } from "shared/routing";
-import styled from "styled-components";
-import { mock_database_list } from "./mock_data";
-
-export type DatabaseObject = {
-  cluster_id: number;
-  project_id: number;
-  infra_id: number;
-  instance_id: string;
-  instance_name: string;
-  status: string;
-  instance_endpoint: string;
-};
-
-const DatabasesList = () => {
-  const {
-    currentCluster,
-    currentProject,
-    setCurrentError,
-    setCurrentModal,
-    setCurrentOverlay,
-    user,
-  } = useContext(Context);
-  const { url } = useRouteMatch();
-  const [isLoading, setIsLoading] = useState(true);
-  const [databases, setDatabases] = useState<DatabaseObject[]>([]);
-  const [isAuth] = useAuth();
-  const { pushQueryParams } = useRouting();
-
-  useEffect(() => {
-    let isSubscribed = true;
-    api
-      .getDatabases(
-        "<token>",
-        {},
-        { project_id: currentProject.id, cluster_id: currentCluster.id }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setDatabases(res.data);
-          setIsLoading(false);
-        }
-      })
-      .catch((error) => {
-        console.error(error);
-        setCurrentError(error);
-      });
-
-    // if (isSubscribed) {
-    //   setDatabases(mock_database_list);
-    //   setIsLoading(false);
-    // }
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentCluster, currentProject]);
-
-  const handleDeleteDatabase = async (project_id: number, infra_id: number) => {
-    try {
-      await api.destroyInfra(
-        "<token>",
-        {},
-        {
-          project_id,
-          infra_id,
-        }
-      );
-
-      // call an endpoint for updating the database status
-      await api.updateDatabaseStatus(
-        "<token>",
-        {
-          status: "destroying",
-        },
-        {
-          project_id,
-          infra_id,
-        }
-      );
-
-      setCurrentOverlay(null);
-      pushQueryParams({ current_tab: "provisioner-status" });
-    } catch (error) {
-      console.error(error);
-      setCurrentError("We couldn't delete the infra, please try again.");
-    }
-  };
-
-  const columns = useMemo<Column<DatabaseObject>[]>(() => {
-    let columns: Column<DatabaseObject>[] = [
-      {
-        Header: "Instance id",
-        accessor: "instance_id",
-      },
-      {
-        Header: "Name",
-        accessor: "instance_name",
-      },
-      {
-        Header: "Status",
-        accessor: "status",
-        Cell: ({ cell }) => {
-          const status: "running" | "destroying" = cell.value as any;
-          return <Status status={status}>{status}</Status>;
-        },
-      },
-      {
-        Header: "Endpoint",
-        accessor: "instance_endpoint",
-        Cell: ({ row }) => {
-          return (
-            <>
-              <CopyToClipboard as={Url} text={row.original.instance_endpoint}>
-                <span>{row.original.instance_endpoint}</span>
-                <i className="material-icons-outlined">content_copy</i>
-              </CopyToClipboard>
-            </>
-          );
-        },
-      },
-      {
-        id: "connect_button",
-        Cell: ({ row }: any) => {
-          return (
-            <>
-              <ConnectButton
-                onClick={() =>
-                  setCurrentModal("ConnectToDatabaseInstructionsModal", {
-                    endpoint: row.original.instance_endpoint,
-                    name: row.original.instance_name,
-                  })
-                }
-              >
-                Connect
-              </ConnectButton>
-            </>
-          );
-        },
-        width: 50,
-      },
-    ];
-
-    if (isAuth("cluster", "", ["get", "delete"])) {
-      columns.push({
-        id: "delete_button",
-        Cell: ({ row }: { row: Row<DatabaseObject> }) => {
-          return (
-            <>
-              <DeleteButton
-                onClick={() =>
-                  setCurrentOverlay({
-                    message: `Are you sure you want to delete ${row.original.instance_name}?`,
-                    onYes: () =>
-                      handleDeleteDatabase(
-                        row.original.project_id,
-                        row.original.infra_id
-                      ),
-                    onNo: () => setCurrentOverlay(null),
-                  })
-                }
-              >
-                <i className="material-icons">delete</i>
-              </DeleteButton>
-            </>
-          );
-        },
-        width: 50,
-      });
-    } else {
-      columns = columns.filter((col) => col.id !== "delete_button");
-    }
-
-    return columns;
-  }, [user]);
-
-  const data = useMemo<Array<DatabaseObject>>(() => {
-    return databases;
-  }, [databases]);
-
-  return (
-    <DatabasesListWrapper>
-      <ControlRow>
-        <Button
-          to={`/infrastructure/provision/RDS?origin=${encodeURIComponent(
-            "/databases"
-          )}`}
-        >
-          <i className="material-icons">add</i>
-          Create database
-        </Button>
-      </ControlRow>
-      <StyledTableWrapper>
-        <Table columns={columns} data={data} isLoading={isLoading} />
-      </StyledTableWrapper>
-    </DatabasesListWrapper>
-  );
-};
-
-export default DatabasesList;
-
-const Status = styled.div<{ status: "running" | "destroying" }>`
-  padding: 5px 10px;
-  margin-right: 12px;
-  background: ${(props) => {
-    if (props.status === "running") return "#38a88a";
-    if (props.status === "destroying") return "#cc3d42";
-  }};
-  font-size: 13px;
-  border-radius: 3px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  max-height: 25px;
-  max-width: 80px;
-  text-transform: capitalize;
-  font-weight: 400;
-  user-select: none;
-`;
-
-const DeleteButton = styled.div`
-  display: flex;
-  visibility: ${(props: { invis?: boolean }) =>
-    props.invis ? "hidden" : "visible"};
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  float: right;
-  height: 30px;
-  :hover {
-    background: #ffffff11;
-    border-radius: 20px;
-    cursor: pointer;
-  }
-
-  > i {
-    font-size: 20px;
-    color: #ffffff44;
-    border-radius: 20px;
-  }
-`;
-
-const DatabasesListWrapper = styled.div`
-  margin-top: 35px;
-`;
-
-const StyledTableWrapper = styled.div`
-  padding: 14px;
-  position: relative;
-  border-radius: 8px;
-  background: #26292e;
-  border: 1px solid #494b4f;
-  width: 100%;
-  height: 100%;
-  :not(:last-child) {
-    margin-bottom: 25px;
-  }
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const Url = styled.a`
-  max-width: 300px;
-  font-size: 13px;
-  user-select: text;
-  font-weight: 400;
-  display: flex;
-  align-items: center;
-  > i {
-    margin-left: 10px;
-    font-size: 15px;
-  }
-
-  > span {
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-  }
-
-  :hover {
-    cursor: pointer;
-  }
-`;
-
-const Button = styled(Link)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  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 ConnectButton = styled.button<{}>`
-  height: 25px;
-  font-size: 13px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-  display: flex;
-  align-items: center;
-  padding: 6px 20px 7px 20px;
-  text-align: left;
-  border: 0;
-  border-radius: 5px;
-  background: #5561c0;
-  cursor: pointer;
-  user-select: none;
-  :focus {
-    outline: 0;
-  }
-  :hover {
-    filter: brightness(120%);
-  }
-`;

+ 0 - 31
dashboard/src/main/home/cluster-dashboard/databases/mock_data.ts

@@ -1,31 +0,0 @@
-import { DatabaseObject } from "./DatabasesList";
-
-export const mock_database_list: DatabaseObject[] = [
-  {
-    cluster_id: 1,
-    instance_endpoint: "some/some",
-    instance_id: "my-id",
-    instance_name: "instance-name",
-    project_id: 3,
-    infra_id: 1,
-    status: "running",
-  },
-  {
-    cluster_id: 1,
-    instance_endpoint: "some/some",
-    instance_id: "my-id",
-    instance_name: "instance-name",
-    project_id: 3,
-    infra_id: 2,
-    status: "running",
-  },
-  {
-    cluster_id: 1,
-    instance_endpoint: "some/some",
-    instance_id: "my-id",
-    instance_name: "instance-name",
-    project_id: 3,
-    infra_id: 3,
-    status: "running",
-  },
-];

+ 0 - 33
dashboard/src/main/home/cluster-dashboard/databases/routes.tsx

@@ -1,33 +0,0 @@
-import React, { useContext, useEffect, useLayoutEffect } from "react";
-import { Route, Switch, useRouteMatch } from "react-router";
-import { Context } from "shared/Context";
-import { useRouting } from "shared/routing";
-import DatabasesHome from "./DatabasesHome";
-
-const DatabasesRoutes = () => {
-  const { url } = useRouteMatch();
-  const { currentCluster, currentProject } = useContext(Context);
-  const { pushFiltered } = useRouting();
-
-  useLayoutEffect(() => {
-    if (
-      currentCluster.service !== "eks" ||
-      currentCluster.infra_id <= 0 ||
-      !currentProject.enable_rds_databases
-    ) {
-      pushFiltered("/cluster-dashboard", []);
-    }
-  }, [currentCluster]);
-
-  return (
-    <>
-      <Switch>
-        <Route path={`${url}/`}>
-          <DatabasesHome />
-        </Route>
-      </Switch>
-    </>
-  );
-};
-
-export default DatabasesRoutes;

+ 0 - 114
dashboard/src/main/home/cluster-dashboard/databases/static_data.ts

@@ -1,114 +0,0 @@
-export const POSTGRES_ENGINE_VERSIONS: {
-  [key: string]: { label: string; value: string }[];
-} = {
-  postgres9: [
-    { label: "v9.6.1", value: "9.6.1" },
-    { label: "v9.6.2", value: "9.6.2" },
-    { label: "v9.6.3", value: "9.6.3" },
-    { label: "v9.6.4", value: "9.6.4" },
-    { label: "v9.6.5", value: "9.6.5" },
-    { label: "v9.6.6", value: "9.6.6" },
-    { label: "v9.6.7", value: "9.6.7" },
-    { label: "v9.6.8", value: "9.6.8" },
-    { label: "v9.6.9", value: "9.6.9" },
-    { label: "v9.6.10", value: "9.6.10" },
-    { label: "v9.6.11", value: "9.6.11" },
-    { label: "v9.6.12", value: "9.6.12" },
-    { label: "v9.6.13", value: "9.6.13" },
-    { label: "v9.6.14", value: "9.6.14" },
-    { label: "v9.6.15", value: "9.6.15" },
-    { label: "v9.6.16", value: "9.6.16" },
-    { label: "v9.6.17", value: "9.6.17" },
-    { label: "v9.6.18", value: "9.6.18" },
-    { label: "v9.6.19", value: "9.6.19" },
-    { label: "v9.6.20", value: "9.6.20" },
-    { label: "v9.6.21", value: "9.6.21" },
-    { label: "v9.6.22", value: "9.6.22" },
-    { label: "v9.6.23", value: "9.6.23" },
-  ],
-  postgres10: [
-    { label: "v10.1", value: "10.1" },
-    { label: "v10.2", value: "10.2" },
-    { label: "v10.3", value: "10.3" },
-    { label: "v10.4", value: "10.4" },
-    { label: "v10.5", value: "10.5" },
-    { label: "v10.6", value: "10.6" },
-    { label: "v10.7", value: "10.7" },
-    { label: "v10.8", value: "10.8" },
-    { label: "v10.9", value: "10.9" },
-    { label: "v10.10", value: "10.10" },
-    { label: "v10.11", value: "10.11" },
-    { label: "v10.12", value: "10.12" },
-    { label: "v10.13", value: "10.13" },
-    { label: "v10.14", value: "10.14" },
-    { label: "v10.15", value: "10.15" },
-    { label: "v10.16", value: "10.16" },
-    { label: "v10.17", value: "10.17" },
-    { label: "v10.18", value: "10.18" },
-  ],
-  postgres11: [
-    { label: "v11.1", value: "11.1" },
-    { label: "v11.2", value: "11.2" },
-    { label: "v11.3", value: "11.3" },
-    { label: "v11.4", value: "11.4" },
-    { label: "v11.5", value: "11.5" },
-    { label: "v11.6", value: "11.6" },
-    { label: "v11.7", value: "11.7" },
-    { label: "v11.8", value: "11.8" },
-    { label: "v11.9", value: "11.9" },
-    { label: "v11.10", value: "11.10" },
-    { label: "v11.11", value: "11.11" },
-    { label: "v11.12", value: "11.12" },
-    { label: "v11.13", value: "11.13" },
-  ],
-  postgres12: [
-    { label: "v12.2", value: "12.2" },
-    { label: "v12.3", value: "12.3" },
-    { label: "v12.4", value: "12.4" },
-    { label: "v12.5", value: "12.5" },
-    { label: "v12.6", value: "12.6" },
-    { label: "v12.7", value: "12.7" },
-    { label: "v12.8", value: "12.8" },
-  ],
-  postgres13: [
-    { label: "v13.1", value: "13.1" },
-    { label: "v13.2", value: "13.2" },
-    { label: "v13.3", value: "13.3" },
-    { label: "v13.4", value: "13.4" },
-  ],
-};
-
-export const POSTGRES_DB_FAMILIES = (() => {
-  const dbFamilies = Object.keys(POSTGRES_ENGINE_VERSIONS);
-  return dbFamilies.map((family) => {
-    return {
-      label: family,
-      value: family,
-    };
-  });
-})();
-
-export const DEFAULT_DB_FAMILY =
-  POSTGRES_DB_FAMILIES[POSTGRES_DB_FAMILIES.length - 1].value;
-
-export const LAST_POSTGRES_ENGINE_VERSION =
-  POSTGRES_ENGINE_VERSIONS.postgres13[
-    POSTGRES_ENGINE_VERSIONS.postgres13.length - 1
-  ].value;
-
-export const FORM_DEFAULT_VALUES = {
-  db_allocated_storage: "10",
-  db_max_allocated_storage: "20",
-  db_storage_encrypted: false,
-};
-
-export const DATABASE_INSTANCE_TYPES = [
-  { value: "db.t2.medium", label: "db.t2.medium" },
-  { value: "db.t2.xlarge", label: "db.t2.xlarge" },
-  { value: "db.t2.2xlarge", label: "db.t2.2xlarge" },
-  { value: "db.t3.medium", label: "db.t3.medium" },
-  { value: "db.t3.xlarge", label: "db.t3.xlarge" },
-  { value: "db.t3.2xlarge", label: "db.t3.2xlarge" },
-];
-
-export const DEFAULT_DATABASE_INSTANCE_TYPE = DATABASE_INSTANCE_TYPES[0].value;

+ 193 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterContextProvider.tsx

@@ -0,0 +1,193 @@
+import React, { createContext, useCallback, useContext, useMemo } from "react";
+import { Contract } from "@porter-dev/api-contracts";
+import { useQueryClient } from "@tanstack/react-query";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { updateExistingClusterContract } from "lib/clusters";
+import {
+  type ClientCluster,
+  type ClientClusterContract,
+} from "lib/clusters/types";
+import { useCluster } from "lib/hooks/useCluster";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import notFound from "assets/not-found.png";
+
+type ClusterContextType = {
+  cluster: ClientCluster;
+  projectId: number;
+  isClusterUpdating: boolean;
+  updateClusterVanityName: (name: string) => void;
+  updateCluster: (clientContract: ClientClusterContract) => Promise<void>;
+  deleteCluster: () => Promise<void>;
+};
+
+const ClusterContext = createContext<ClusterContextType | null>(null);
+
+export const useClusterContext = (): ClusterContextType => {
+  const ctx = React.useContext(ClusterContext);
+  if (!ctx) {
+    throw new Error(
+      "useClusterContext must be used within a ClusterContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type ClusterContextProviderProps = {
+  clusterId?: number;
+  children: JSX.Element;
+};
+
+const ClusterContextProvider: React.FC<ClusterContextProviderProps> = ({
+  clusterId,
+  children,
+}) => {
+  const { currentProject } = useContext(Context);
+  const { cluster, isLoading, isError } = useCluster({
+    clusterId,
+    refetchInterval: 3000,
+  });
+
+  const paramsExist =
+    !!clusterId && !!currentProject && currentProject.id !== -1;
+
+  const queryClient = useQueryClient();
+  const updateClusterVanityName = useCallback(
+    async (name: string) => {
+      if (!paramsExist) {
+        return;
+      }
+      await api.renameCluster(
+        "<token",
+        { name },
+        {
+          project_id: currentProject.id,
+          cluster_id: clusterId,
+        }
+      );
+
+      await queryClient.invalidateQueries(["getCluster"]);
+    },
+    [paramsExist, clusterId]
+  );
+  const updateCluster = useCallback(
+    async (clientContract: ClientClusterContract) => {
+      if (!paramsExist || !cluster?.contract) {
+        return;
+      }
+      const latestContract = Contract.fromJsonString(
+        atob(cluster.contract.base64_contract),
+        {
+          ignoreUnknownFields: true,
+        }
+      );
+      if (!latestContract.cluster) {
+        return;
+      }
+      const updatedContract = new Contract({
+        ...latestContract,
+        cluster: updateExistingClusterContract(
+          clientContract,
+          latestContract.cluster
+        ),
+      });
+
+      await api.createContract("<token>", updatedContract, {
+        project_id: currentProject.id,
+      });
+
+      await queryClient.invalidateQueries(["getCluster"]);
+    },
+    [paramsExist, clusterId, currentProject?.id, cluster?.contract]
+  );
+  const deleteCluster = useCallback(async () => {
+    if (!paramsExist) {
+      return;
+    }
+    await api.deleteCluster(
+      "<token",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: clusterId,
+      }
+    );
+    await queryClient.invalidateQueries(["getClusters"]);
+  }, [paramsExist, clusterId, currentProject?.id]);
+  const isClusterUpdating = useMemo(() => {
+    return cluster?.contract?.condition === "" ?? false;
+  }, [cluster?.contract.condition]);
+
+  if (isLoading || !paramsExist) {
+    return <Loading />;
+  }
+
+  if (isError) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            Unable to load configuration for the provided cluster.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/infrastructure">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  if (!cluster) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No cluster matching the provided ID was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/infrastructure">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <ClusterContext.Provider
+      value={{
+        cluster,
+        projectId: currentProject.id,
+        isClusterUpdating,
+        updateClusterVanityName,
+        updateCluster,
+        deleteCluster,
+      }}
+    >
+      {children}
+    </ClusterContext.Provider>
+  );
+};
+
+export default ClusterContextProvider;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 342 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterDashboard.tsx

@@ -0,0 +1,342 @@
+import React, { useMemo, useState } from "react";
+import _ from "lodash";
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
+import Icon from "components/porter/Icon";
+import Image from "components/porter/Image";
+import PorterLink from "components/porter/Link";
+import SearchBar from "components/porter/SearchBar";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import StatusDot from "components/porter/StatusDot";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import Toggle from "components/porter/Toggle";
+import {
+  CloudProviderAWS,
+  CloudProviderAzure,
+  CloudProviderGCP,
+} from "lib/clusters/constants";
+import { type ClientCluster } from "lib/clusters/types";
+import { useClusterList } from "lib/hooks/useCluster";
+
+import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
+import infra from "assets/cluster.svg";
+import globe from "assets/globe.svg";
+import grid from "assets/grid.png";
+import infraGrad from "assets/infra-grad.svg";
+import list from "assets/list.png";
+import notFound from "assets/not-found.png";
+import time from "assets/time.png";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+
+const ClusterDashboard: React.FC = () => {
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState<"grid" | "list">("grid");
+  const [providerFilter, setProviderFilter] = useState<
+    "all" | "AWS" | "GCP" | "Azure"
+  >("all");
+
+  const { clusters, isLoading } = useClusterList();
+
+  const filteredClusters = useMemo(() => {
+    const filteredBySearch = search(clusters, searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
+
+    const sortedFilteredBySearch = _.sortBy(filteredBySearch, ["name"]);
+
+    const filteredByProvider = sortedFilteredBySearch.filter(
+      (cluster: ClientCluster) => {
+        if (providerFilter === "all") {
+          return true;
+        }
+        return cluster.cloud_provider.name === providerFilter;
+      }
+    );
+
+    return filteredByProvider;
+  }, [clusters, searchValue, providerFilter]);
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={infraGrad}
+        title="Infrastructure"
+        description="Clusters for running applications on this project."
+        disableLineBreak
+      />
+      <Container row spaced>
+        <Select
+          options={[
+            { value: "all", label: "All" },
+            {
+              value: "AWS",
+              label: "AWS",
+              icon: CloudProviderAWS.icon,
+            },
+            {
+              value: "GCP",
+              label: "GCP",
+              icon: CloudProviderGCP.icon,
+            },
+            {
+              value: "Azure",
+              label: "Azure",
+              icon: CloudProviderAzure.icon,
+            },
+          ]}
+          value={providerFilter}
+          setValue={(value) => {
+            if (
+              value === "all" ||
+              value === "GCP" ||
+              value === "AWS" ||
+              value === "Azure"
+            ) {
+              setProviderFilter(value);
+            }
+          }}
+          prefix={
+            <Container row>
+              <Image src={infra} size={15} opacity={0.6} />
+              <Spacer inline width="20px" />
+              Cloud
+            </Container>
+          }
+        />
+        <Spacer inline x={1} />
+        <SearchBar
+          value={searchValue}
+          setValue={(x) => {
+            setSearchValue(x);
+          }}
+          placeholder="Search clusters . . ."
+          width="100%"
+        />
+        <Spacer inline x={1} />
+
+        <Toggle
+          items={[
+            { label: <ToggleIcon src={grid} />, value: "grid" },
+            { label: <ToggleIcon src={list} />, value: "list" },
+          ]}
+          active={view}
+          setActive={(x) => {
+            if (x === "grid") {
+              setView("grid");
+            } else {
+              setView("list");
+            }
+          }}
+        />
+
+        <Spacer inline x={1} />
+        <PorterLink to="/infrastructure/new">
+          <Button onClick={() => ({})} height="30px" width="130px">
+            <I className="material-icons">add</I> New cluster
+          </Button>
+        </PorterLink>
+      </Container>
+      <Spacer y={1} />
+      {isLoading ? (
+        <Loading />
+      ) : filteredClusters.length === 0 ? (
+        <Fieldset>
+          <Container row>
+            <PlaceholderIcon src={notFound} />
+            <Text color="helper">No matching clusters were found.</Text>
+          </Container>
+        </Fieldset>
+      ) : view === "grid" ? (
+        <GridList>
+          {filteredClusters.map((cluster: ClientCluster, i: number) => {
+            return (
+              <Link to={`/infrastructure/${cluster.id}`} key={i}>
+                <Block>
+                  <Container row>
+                    <Icon src={cluster.cloud_provider.icon} height="18px" />
+                    <Spacer inline width="11px" />
+                    <Text size={14}>{cluster.vanity_name}</Text>
+                  </Container>
+                  <Container row>
+                    <Tag hoverable={false}>
+                      <Container row>
+                        <Icon src={globe} height="13px" />
+                        <Spacer inline x={0.5} />
+                        {cluster.contract.config.cluster.config?.region}
+                      </Container>
+                    </Tag>
+                  </Container>
+                  <Container row>
+                    <StatusDot
+                      status={
+                        cluster.status === "READY" ? "available" : "pending"
+                      }
+                      heightPixels={8}
+                    />
+                    <Spacer inline x={0.5} />
+                    <Text color="helper">
+                      {cluster.status === "READY" ? "Running" : "Updating"}
+                    </Text>
+                    <Spacer inline x={1} />
+                    <SmallIcon opacity="0.3" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(cluster.contract.updated_at)}
+                    </Text>
+                  </Container>
+                </Block>
+              </Link>
+            );
+          })}
+        </GridList>
+      ) : (
+        <List>
+          {filteredClusters.map((cluster: ClientCluster, i: number) => {
+            return (
+              <Row to={`/infrastructure/${cluster.id}`} key={i}>
+                <Container row spaced>
+                  <Container row>
+                    <MidIcon src={cluster.cloud_provider.icon} />
+                    <Text size={14}>{cluster.vanity_name}</Text>
+                  </Container>
+                  <Container row>
+                    <StatusDot
+                      status={
+                        cluster.status === "READY" ? "available" : "pending"
+                      }
+                      heightPixels={8}
+                    />
+                    <Spacer inline x={0.5} />
+                    <Text color="helper">
+                      {cluster.status === "READY" ? "Running" : "Updating"}
+                    </Text>
+                  </Container>
+                </Container>
+                <Spacer y={0.5} />
+                <Container row>
+                  <Container row>
+                    <SmallIcon opacity="0.3" src={globe} />
+                    <Text size={13} color="#ffffff44">
+                      {cluster.contract.config.cluster.config.region}
+                    </Text>
+                    <Spacer inline x={1} />
+                    <SmallIcon opacity="0.3" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(cluster.contract.updated_at)}
+                    </Text>
+                  </Container>
+                </Container>
+              </Row>
+            );
+          })}
+        </List>
+      )}
+
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default ClusterDashboard;
+
+const MidIcon = styled.img<{ height?: string }>`
+  height: ${(props) => props.height || "18px"};
+  margin-right: 11px;
+`;
+
+const Row = styled(Link)<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  display: block;
+  padding: 15px;
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const Block = styled.div`
+  height: 150px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+`;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+  min-width: 300px;
+  height: fit-content;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  margin-left: 2px;
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
+  margin-right: 10px;
+`;

+ 181 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx

@@ -0,0 +1,181 @@
+import React, { createContext, useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { type Contract } from "@porter-dev/api-contracts";
+import { useQueryClient } from "@tanstack/react-query";
+import { FormProvider, useForm } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import { Error as ErrorComponent } from "components/porter/Error";
+import {
+  clusterContractValidator,
+  type ClientClusterContract,
+  type UpdateClusterResponse,
+} from "lib/clusters/types";
+import { useUpdateCluster } from "lib/hooks/useCluster";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import PreflightChecksModal from "./modals/PreflightChecksModal";
+
+// todo(ianedwards): refactor button to use more predictable state
+export type UpdateClusterButtonProps = {
+  status: "" | "loading" | JSX.Element | "success";
+  isDisabled: boolean;
+  loadingText: string;
+};
+
+type ClusterFormContextType = {
+  setCurrentContract: (contract: Contract) => void;
+  showFailedPreflightChecksModal: boolean;
+  updateClusterButtonProps: UpdateClusterButtonProps;
+};
+
+const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
+
+export const useClusterFormContext = (): ClusterFormContextType => {
+  const ctx = React.useContext(ClusterFormContext);
+  if (!ctx) {
+    throw new Error(
+      "useClusterFormContext must be used within a ClusterFormContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type ClusterFormContextProviderProps = {
+  projectId?: number;
+  redirectOnSubmit?: boolean;
+  children: JSX.Element;
+};
+
+const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
+  projectId,
+  redirectOnSubmit,
+  children,
+}) => {
+  const history = useHistory();
+  const [currentContract, setCurrentContract] = useState<Contract | undefined>(
+    undefined
+  );
+  const [updateClusterResponse, setUpdateClusterResponse] = useState<
+    UpdateClusterResponse | undefined
+  >(undefined);
+  const [updateClusterError, setUpdateClusterError] = useState<string>("");
+  const [showFailedPreflightChecksModal, setShowFailedPreflightChecksModal] =
+    useState<boolean>(false);
+
+  const { updateCluster, isHandlingPreflightChecks, isCreatingContract } =
+    useUpdateCluster({ projectId });
+
+  const { showIntercomWithMessage } = useIntercom();
+
+  const clusterForm = useForm<ClientClusterContract>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(clusterContractValidator),
+  });
+  const {
+    handleSubmit,
+    formState: { isSubmitting },
+  } = clusterForm;
+
+  const queryClient = useQueryClient();
+
+  const updateClusterButtonProps = useMemo(() => {
+    const props: UpdateClusterButtonProps = {
+      status: "",
+      isDisabled: false,
+      loadingText: "",
+    };
+    if (isSubmitting) {
+      props.status = "loading";
+      props.isDisabled = true;
+    }
+
+    if (updateClusterError) {
+      props.status = (
+        <ErrorComponent message={updateClusterError} maxWidth="600px" />
+      );
+    }
+    if (isHandlingPreflightChecks) {
+      props.loadingText = "Running preflight checks...";
+    }
+    if (isCreatingContract) {
+      props.loadingText = "Provisioning cluster...";
+    }
+    if (updateClusterResponse?.createContractResponse) {
+      props.status = "success";
+    }
+
+    return props;
+  }, [
+    isSubmitting,
+    updateClusterResponse,
+    updateClusterError,
+    isHandlingPreflightChecks,
+    isCreatingContract,
+  ]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setUpdateClusterResponse(undefined);
+    setUpdateClusterError("");
+    if (!currentContract?.cluster) {
+      return;
+    }
+    try {
+      const response = await updateCluster(data, currentContract);
+      setUpdateClusterResponse(response);
+      if (response.preflightChecks) {
+        setShowFailedPreflightChecksModal(true);
+      }
+      if (response.createContractResponse) {
+        await queryClient.invalidateQueries(["getCluster"]);
+
+        if (redirectOnSubmit) {
+          history.push(
+            `/infrastructure/${response.createContractResponse.contract_revision.cluster_id}`
+          );
+        }
+      }
+    } catch (err) {
+      if (err instanceof Error) {
+        setUpdateClusterError(err.message);
+        showIntercomWithMessage({
+          message: "I am running into an issue updating my cluster.",
+        });
+      }
+    }
+  });
+
+  return (
+    <ClusterFormContext.Provider
+      value={{
+        setCurrentContract,
+        showFailedPreflightChecksModal,
+        updateClusterButtonProps,
+      }}
+    >
+      <Wrapper>
+        <FormProvider {...clusterForm}>
+          <form onSubmit={onSubmit}>{children}</form>
+        </FormProvider>
+        {showFailedPreflightChecksModal &&
+          updateClusterResponse?.preflightChecks && (
+            <PreflightChecksModal
+              onClose={() => {
+                setShowFailedPreflightChecksModal(false);
+              }}
+              preflightChecks={updateClusterResponse.preflightChecks}
+            />
+          )}
+      </Wrapper>
+    </ClusterFormContext.Provider>
+  );
+};
+
+export default ClusterFormContextProvider;
+
+const Wrapper = styled.div`
+  height: fit-content;
+  margin-bottom: 10px;
+  width: 100%;
+`;

+ 54 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterHeader.tsx

@@ -0,0 +1,54 @@
+import React from "react";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import StatusDot from "components/porter/StatusDot";
+import Text from "components/porter/Text";
+
+import { readableDate } from "shared/string_utils";
+
+import { useClusterContext } from "./ClusterContextProvider";
+
+const ClusterHeader: React.FC = () => {
+  const { cluster } = useClusterContext();
+
+  return (
+    <>
+      <Container row>
+        <Icon src={cluster.cloud_provider.icon} height="22px" />
+        <Spacer inline x={1} />
+        <Text size={21}>{cluster.vanity_name}</Text>
+      </Container>
+      <Spacer y={0.5} />
+      <CreatedAtContainer>
+        <Container row>
+          <Spacer inline x={0.2} />
+          <StatusDot
+            status={cluster.status === "READY" ? "available" : "pending"}
+            heightPixels={8}
+          />
+          <Spacer inline x={0.7} />
+          <Text color="helper">
+            {cluster.status === "READY" ? "Running" : "Updating"}
+          </Text>
+          <Spacer inline x={1} />
+        </Container>
+        <div style={{ flexShrink: 0 }}>
+          <Text color="#aaaabb66">
+            Updated {readableDate(cluster.contract.updated_at)}
+          </Text>
+        </div>
+        <Spacer y={0.5} />
+      </CreatedAtContainer>
+    </>
+  );
+};
+
+export default ClusterHeader;
+
+const CreatedAtContainer = styled.div`
+  display: inline-flex;
+  column-gap: 6px;
+`;

+ 36 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterProvisioningIndicator.tsx

@@ -0,0 +1,36 @@
+import React, { useMemo } from "react";
+
+import StatusBar from "components/porter/StatusBar";
+
+import { useClusterContext } from "./ClusterContextProvider";
+
+const ClusterProvisioningIndicator: React.FC = () => {
+  const { cluster } = useClusterContext();
+
+  const { percentCompleted, title } = useMemo(() => {
+    let stepsCompleted = 1;
+    if (cluster.state?.is_control_plane_ready) {
+      stepsCompleted += 1;
+    }
+    if (cluster.state?.is_infrastructure_ready) {
+      stepsCompleted += 1;
+    }
+    if (cluster.state?.phase === "Provisioned") {
+      stepsCompleted += 1;
+    }
+    const percentCompleted = (stepsCompleted / 5) * 100.0;
+    const title = `${cluster.cloud_provider.name} provisioning status`;
+    return { percentCompleted, title };
+  }, [cluster]);
+
+  return (
+    <StatusBar
+      icon={cluster.cloud_provider.icon}
+      title={title}
+      subtitle={`Setup can take up to 20 minutes. You can close this window and come back later.`}
+      percentCompleted={percentCompleted}
+    />
+  );
+};
+
+export default ClusterProvisioningIndicator;

+ 36 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterSaveButton.tsx

@@ -0,0 +1,36 @@
+import React from "react";
+
+import Button from "components/porter/Button";
+
+import { useClusterContext } from "./ClusterContextProvider";
+import { useClusterFormContext } from "./ClusterFormContextProvider";
+
+type Props = {
+  height?: string;
+  disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
+};
+const ClusterSaveButton: React.FC<Props> = ({
+  height,
+  disabledTooltipPosition,
+}) => {
+  const { updateClusterButtonProps } = useClusterFormContext();
+  const { isClusterUpdating } = useClusterContext();
+
+  return (
+    <Button
+      type="submit"
+      status={updateClusterButtonProps.status}
+      loadingText={updateClusterButtonProps.loadingText}
+      disabled={updateClusterButtonProps.isDisabled || isClusterUpdating}
+      disabledTooltipMessage={
+        "Please wait for the current update to complete before updating again."
+      }
+      height={height}
+      disabledTooltipPosition={disabledTooltipPosition}
+    >
+      Update
+    </Button>
+  );
+};
+
+export default ClusterSaveButton;

+ 124 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterTabs.tsx

@@ -0,0 +1,124 @@
+import React, { useContext, useEffect, useMemo } from "react";
+import { Contract } from "@porter-dev/api-contracts";
+import AnimateHeight from "react-animate-height";
+import { useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { Context } from "shared/Context";
+
+import { useClusterContext } from "./ClusterContextProvider";
+import { useClusterFormContext } from "./ClusterFormContextProvider";
+import ClusterProvisioningIndicator from "./ClusterProvisioningIndicator";
+import ClusterSaveButton from "./ClusterSaveButton";
+import ClusterOverview from "./tabs/overview/ClusterOverview";
+import Settings from "./tabs/Settings";
+
+const validTabs = ["overview", "settings", "advanced"] as const;
+const DEFAULT_TAB = "overview" as const;
+type ValidTab = (typeof validTabs)[number];
+const tabs = [
+  { label: "Overview", value: "overview" },
+  { label: "Settings", value: "settings" },
+];
+
+type Props = {
+  tabParam?: string;
+};
+const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
+  const { currentProject } = useContext(Context);
+  const history = useHistory();
+
+  const { cluster, isClusterUpdating } = useClusterContext();
+
+  const {
+    reset,
+    formState: { isDirty },
+  } = useFormContext<ClientClusterContract>();
+
+  const { setCurrentContract } = useClusterFormContext();
+
+  useEffect(() => {
+    reset(cluster.contract.config);
+    setCurrentContract(
+      Contract.fromJsonString(atob(cluster.contract.base64_contract), {
+        ignoreUnknownFields: true,
+      })
+    );
+  }, [cluster]);
+
+  useEffect(() => {
+    if (
+      currentProject?.advanced_infra_enabled &&
+      !tabs.some((x) => x.value === "advanced")
+    ) {
+      tabs.splice(1, 0, {
+        label: "Advanced",
+        value: "advanced",
+      });
+    }
+  }, [currentProject]);
+
+  const currentTab = useMemo(() => {
+    if (tabParam && validTabs.includes(tabParam as ValidTab)) {
+      return tabParam as ValidTab;
+    }
+
+    return DEFAULT_TAB;
+  }, [tabParam]);
+
+  return (
+    <DashboardWrapper>
+      {isClusterUpdating && (
+        <>
+          <ClusterProvisioningIndicator />
+          <Spacer y={1} />
+        </>
+      )}
+      <AnimateHeight height={isDirty ? "auto" : 0}>
+        <Banner
+          type="warning"
+          suffix={
+            <>
+              <ClusterSaveButton
+                height={"10px"}
+                disabledTooltipPosition={"bottom"}
+              />
+            </>
+          }
+        >
+          Changes you are currently previewing have not been saved.
+          <Spacer inline width="5px" />
+        </Banner>
+        <Spacer y={1} />
+      </AnimateHeight>
+      <TabSelector
+        options={tabs}
+        currentTab={currentTab}
+        setCurrentTab={(tab) => {
+          history.push(`/infrastructure/${cluster.id}/${tab}`);
+        }}
+      />
+      <Spacer y={1} />
+      {match(currentTab)
+        .with("overview", () => <ClusterOverview />)
+        .with("settings", () => <Settings />)
+        .with("advanced", () => <div>Advanced settings</div>)
+        .otherwise(() => null)}
+    </DashboardWrapper>
+  );
+};
+
+export default ClusterTabs;
+
+const DashboardWrapper = styled.div`
+  width: 100%;
+  min-width: 300px;
+  height: fit-content;
+`;

+ 69 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterView.tsx

@@ -0,0 +1,69 @@
+import React, { useContext, useMemo } from "react";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
+import { z } from "zod";
+
+import Back from "components/porter/Back";
+import Spacer from "components/porter/Spacer";
+
+import { Context } from "shared/Context";
+
+import ClusterContextProvider from "./ClusterContextProvider";
+import ClusterFormContextProvider from "./ClusterFormContextProvider";
+import ClusterHeader from "./ClusterHeader";
+import ClusterTabs from "./ClusterTabs";
+
+type Props = RouteComponentProps;
+
+const ClusterView: React.FC<Props> = ({ match }) => {
+  const { currentProject } = useContext(Context);
+  const params = useMemo(() => {
+    const { params } = match;
+    const validParams = z
+      .object({
+        tab: z.string().optional(),
+        clusterId: z.string().optional(),
+      })
+      .safeParse(params);
+
+    if (!validParams.success || !validParams.data.clusterId) {
+      return {
+        tab: undefined,
+      };
+    }
+    const clusterId = parseInt(validParams.data.clusterId);
+    return {
+      tab: validParams.data.tab,
+      clusterId,
+    };
+  }, [match]);
+  return (
+    <ClusterContextProvider clusterId={params.clusterId}>
+      <ClusterFormContextProvider projectId={currentProject?.id}>
+        <StyledExpandedCluster>
+          <Back to="/infrastructure" />
+          <ClusterHeader />
+          <Spacer y={1} />
+          <ClusterTabs tabParam={params.tab} />
+        </StyledExpandedCluster>
+      </ClusterFormContextProvider>
+    </ClusterContextProvider>
+  );
+};
+
+export default withRouter(ClusterView);
+
+const StyledExpandedCluster = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 160 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/CloudProviderSelect.tsx

@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import Banner from "components/porter/Banner";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import {
+  CloudProviderAWS,
+  CloudProviderAzure,
+  CloudProviderGCP,
+} from "lib/clusters/constants";
+import { type ClientCloudProvider } from "lib/clusters/types";
+
+import bolt from "assets/bolt.svg";
+
+import CostConsentModal from "../modals/cost-consent/CostConsentModal";
+
+type Props = {
+  onComplete: (provider: ClientCloudProvider) => void;
+};
+const CloudProviderSelect: React.FC<Props> = ({ onComplete }) => {
+  const [cloudProvider, setCloudProvider] = useState<
+    ClientCloudProvider | undefined
+  >(undefined);
+
+  return (
+    <div>
+      <DashboardHeader
+        image={bolt}
+        title="Getting started"
+        description="Select your existing cloud provider to get started with Porter."
+        disableLineBreak
+        capitalize={false}
+      />
+      <Banner>
+        Don't want to link your own cloud account? Immediately deploy your apps
+        on the <Spacer inline width="5px" />
+        <Link to="https://sandbox.porter.run" hasunderline>
+          Porter sandbox
+        </Link>
+        .
+      </Banner>
+      <Spacer y={1} />
+      <StyledProvisionerFlow>
+        <BlockList>
+          {[CloudProviderAWS, CloudProviderGCP, CloudProviderAzure].map(
+            (provider: ClientCloudProvider, i: number) => {
+              return (
+                <Block
+                  key={i}
+                  onClick={() => {
+                    setCloudProvider(provider);
+                  }}
+                >
+                  <Icon src={provider.icon} />
+                  <BlockTitle>{provider.name}</BlockTitle>
+                  <BlockDescription>Hosted in your own cloud</BlockDescription>
+                </Block>
+              );
+            }
+          )}
+        </BlockList>
+      </StyledProvisionerFlow>
+      {cloudProvider !== undefined && (
+        <CostConsentModal
+          cloudProvider={cloudProvider}
+          onClose={() => {
+            setCloudProvider(undefined);
+          }}
+          onComplete={() => {
+            onComplete(cloudProvider);
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default CloudProviderSelect;
+
+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: 30px;
+  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: ${({ theme }) => theme.clickable.bg};
+  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;
+`;

+ 144 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx

@@ -0,0 +1,144 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Loading from "components/Loading";
+import RequestToEnable from "components/porter/RequestToEnable";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { type ClientCloudProvider } from "lib/clusters/types";
+import { useClusterList } from "lib/hooks/useCluster";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import infraGrad from "assets/infra-grad.svg";
+
+import ClusterFormContextProvider from "../ClusterFormContextProvider";
+import CreateEKSClusterForm from "./aws/CreateEKSClusterForm";
+import CreateAKSClusterForm from "./azure/CreateAKSClusterForm";
+import CloudProviderSelect from "./CloudProviderSelect";
+import CreateGKEClusterForm from "./gcp/CreateGKEClusterForm";
+
+const CreateClusterForm: React.FC = () => {
+  const { currentProject } = useContext(Context);
+  const { clusters } = useClusterList();
+  const [selectedCloudProvider, setSelectedCloudProvider] = useState<
+    ClientCloudProvider | undefined
+  >(undefined);
+
+  if (!currentProject || currentProject.id === -1) {
+    return <Loading />;
+  }
+  if (!currentProject?.multi_cluster && clusters.length > 0) {
+    return (
+      <Wrapper>
+        <DashboardHeader
+          image={infraGrad}
+          title="Infrastructure"
+          description="Clusters for running applications on this project."
+          disableLineBreak
+        />
+        <RequestToEnable
+          title={"Multi-cluster is not enabled for this project"}
+          subtitle={
+            "Reach out to the Porter team to enable multi-cluster on your project."
+          }
+          intercomText={"I would like to enable multi-cluster for my project."}
+        />
+      </Wrapper>
+    );
+  }
+
+  return (
+    <ClusterFormContextProvider
+      projectId={currentProject?.id}
+      redirectOnSubmit={true}
+    >
+      <CreateClusterFormContainer>
+        {match(selectedCloudProvider)
+          .with({ name: "AWS" }, () => (
+            <CreateEKSClusterForm
+              goBack={() => {
+                setSelectedCloudProvider(undefined);
+              }}
+              projectId={currentProject.id}
+              projectName={currentProject.name}
+            />
+          ))
+          .with({ name: "GCP" }, () => (
+            <CreateGKEClusterForm
+              goBack={() => {
+                setSelectedCloudProvider(undefined);
+              }}
+              projectId={currentProject.id}
+              projectName={currentProject.name}
+            />
+          ))
+          .with({ name: "Azure" }, () => (
+            <CreateAKSClusterForm
+              goBack={() => {
+                setSelectedCloudProvider(undefined);
+              }}
+              projectId={currentProject.id}
+              projectName={currentProject.name}
+            />
+          ))
+          .otherwise(() => (
+            <CloudProviderSelect
+              onComplete={(provider: ClientCloudProvider) => {
+                setSelectedCloudProvider(provider);
+                if (currentProject?.id) {
+                  void api.inviteAdmin(
+                    "<token>",
+                    {},
+                    { project_id: currentProject.id }
+                  );
+                }
+              }}
+            />
+          ))}
+      </CreateClusterFormContainer>
+    </ClusterFormContextProvider>
+  );
+};
+
+export default CreateClusterForm;
+
+const Wrapper = styled.div`
+  width: 100%;
+`;
+
+const CreateClusterFormContainer = styled.div`
+  width: 100%;
+`;
+
+export const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+export 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;
+  }
+`;

+ 120 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/aws/ConfigureEKSCluster.tsx

@@ -0,0 +1,120 @@
+import React, { useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Image from "components/porter/Image";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderAWS } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import NodeGroups from "../../shared/NodeGroups";
+
+type Props = {
+  goBack: () => void;
+};
+
+const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
+  const [currentStep, _setCurrentStep] = useState<number>(4);
+
+  const {
+    control,
+    register,
+    formState: { errors },
+  } = useFormContext<ClientClusterContract>();
+
+  const { updateClusterButtonProps } = useClusterFormContext();
+
+  return (
+    <div>
+      <Back onClick={goBack} />
+      <Container row>
+        <Image src={CloudProviderAWS.icon} size={22} />
+        <Spacer inline x={1} />
+        <Text size={21}>Configure AWS settings</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text color="helper">Specify settings for your AWS infrastructure.</Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        currentStep={currentStep}
+        steps={[
+          <>
+            <Text size={16}>Cluster name</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Lowercase letters, numbers, and &quot;-&quot; only.
+            </Text>
+            <Spacer y={0.7} />
+            <ControlledInput
+              placeholder="ex: my-cluster"
+              type="text"
+              width="300px"
+              error={errors.cluster?.config?.clusterName?.message}
+              {...register("cluster.config.clusterName")}
+            />
+          </>,
+          <>
+            <Text size={16}>Region</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Select the region where you want to run your cluster.
+            </Text>
+            <Spacer y={0.7} />
+            <Controller
+              name={`cluster.config.region`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <Container style={{ width: "300px" }}>
+                  <Select
+                    options={CloudProviderAWS.regions.map((region) => ({
+                      value: region.name,
+                      label: region.displayName,
+                    }))}
+                    setValue={(selected: string) => {
+                      onChange(selected);
+                    }}
+                    value={value}
+                  />
+                </Container>
+              )}
+            />
+          </>,
+          <>
+            <Text size={16}>Application node group</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Configure your application infrastructure.{" "}
+              <a
+                href="https://docs.porter.run/other/kubernetes-101"
+                target="_blank"
+                rel="noreferrer"
+              >
+                &nbsp;(?)
+              </a>
+            </Text>
+            <Spacer y={1} />
+            <NodeGroups availableMachineTypes={CloudProviderAWS.machineTypes} />
+          </>,
+          <Button
+            key={3}
+            type="submit"
+            status={updateClusterButtonProps.status}
+            disabled={updateClusterButtonProps.isDisabled}
+            loadingText={updateClusterButtonProps.loadingText}
+          >
+            Create resources
+          </Button>,
+        ]}
+      />
+    </div>
+  );
+};
+
+export default ConfigureEKSCluster;

+ 93 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/aws/CreateEKSClusterForm.tsx

@@ -0,0 +1,93 @@
+import React, { useEffect, useState } from "react";
+import { useFormContext } from "react-hook-form";
+import { match } from "ts-pattern";
+
+import { CloudProviderAWS } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ConfigureEKSCluster from "./ConfigureEKSCluster";
+import GrantAWSPermissions from "./GrantAWSPermissions";
+
+type Props = {
+  goBack: () => void;
+  projectId: number;
+  projectName: string;
+};
+const CreateEKSClusterForm: React.FC<Props> = ({
+  goBack,
+  projectId,
+  projectName,
+}) => {
+  const [step, setStep] = useState<"permissions" | "cluster">("permissions");
+  const { setValue, reset } = useFormContext<ClientClusterContract>();
+  const { setCurrentContract } = useClusterFormContext();
+
+  useEffect(() => {
+    reset({
+      cluster: {
+        projectId,
+        cloudProvider: "AWS" as const,
+        config: {
+          kind: "EKS" as const,
+          clusterName: `${projectName}-cluster-${Math.random()
+            .toString(36)
+            .substring(2, 8)}`,
+          region: "us-east-1",
+          nodeGroups: [
+            {
+              nodeGroupType: "APPLICATION" as const,
+              instanceType: "t3.medium",
+              minInstances: 1,
+              maxInstances: 10,
+            },
+            {
+              nodeGroupType: "SYSTEM" as const,
+              instanceType: "t3.medium",
+              minInstances: 1,
+              maxInstances: 3,
+            },
+            {
+              nodeGroupType: "MONITORING" as const,
+              instanceType: "t3.large",
+              minInstances: 1,
+              maxInstances: 1,
+            },
+          ],
+          cidrRange: "10.78.0.0/16",
+        },
+      },
+    });
+    setCurrentContract(CloudProviderAWS.newClusterDefaultContract);
+  }, []);
+
+  return match(step)
+    .with("permissions", () => (
+      <GrantAWSPermissions
+        goBack={goBack}
+        proceed={({
+          cloudProviderCredentialIdentifier,
+        }: {
+          cloudProviderCredentialIdentifier: string;
+        }) => {
+          setValue(
+            "cluster.cloudProviderCredentialsId",
+            cloudProviderCredentialIdentifier
+          );
+          setStep("cluster");
+        }}
+        projectId={projectId}
+      />
+    ))
+    .with("cluster", () => (
+      <ConfigureEKSCluster
+        goBack={() => {
+          setStep("permissions");
+          setValue("cluster.cloudProviderCredentialsId", "");
+        }}
+      />
+    ))
+    .exhaustive();
+};
+
+export default CreateEKSClusterForm;

+ 407 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/aws/GrantAWSPermissions.tsx

@@ -0,0 +1,407 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import styled from "styled-components";
+import { v4 as uuidv4 } from "uuid";
+
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Input from "components/porter/Input";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderAWS } from "lib/clusters/constants";
+import { isAWSArnAccessible } from "lib/hooks/useCloudProvider";
+
+import api from "shared/api";
+
+import GrantAWSPermissionsHelpModal from "../../modals/help/permissions/GrantAWSPermissionsHelpModal";
+import { CheckItem } from "../../modals/PreflightChecksModal";
+
+type Props = {
+  goBack: () => void;
+  proceed: ({
+    cloudProviderCredentialIdentifier,
+  }: {
+    cloudProviderCredentialIdentifier: string;
+  }) => void;
+  projectId: number;
+};
+
+const GrantAWSPermissions: React.FC<Props> = ({
+  goBack,
+  proceed,
+  projectId,
+}) => {
+  const [AWSAccountID, setAWSAccountID] = useState("");
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
+  const [accountIdContinueButtonStatus, setAccountIdContinueButtonStatus] =
+    useState<string>("");
+  const [isAccountAccessible, setIsAccountAccessible] = useState(false);
+  const reportToAnalytics = useCallback(
+    async ({
+      step,
+      awsAccountId = "",
+      cloudFormationUrl = "",
+      errorMessage = "",
+      loginUrl = "",
+      externalId = "",
+    }: {
+      step: string;
+      awsAccountId?: string;
+      cloudFormationUrl?: string;
+      errorMessage?: string;
+      loginUrl?: string;
+      externalId?: string;
+    }) => {
+      void api
+        .updateOnboardingStep(
+          "<token>",
+          {
+            step,
+            account_id: awsAccountId,
+            cloudformation_url: cloudFormationUrl,
+            error_message: errorMessage,
+            login_url: loginUrl,
+            external_id: externalId,
+          },
+          {
+            project_id: projectId,
+          }
+        )
+        .catch(() => ({})); // do not care about error here, so just catch it
+    },
+    [projectId]
+  );
+  const awsAccountIdInputError = useMemo(() => {
+    const regex = /^\d{12}$/;
+    if (AWSAccountID.trim().length === 0) {
+      return undefined;
+    } else if (!regex.test(AWSAccountID)) {
+      return "A valid AWS Account ID must be a 12-digit number.";
+    }
+    return undefined;
+  }, [AWSAccountID]);
+  const externalId = useMemo(() => {
+    if (!AWSAccountID || awsAccountIdInputError) {
+      return "";
+    }
+    let externalId = localStorage.getItem(AWSAccountID);
+    if (!externalId) {
+      externalId = uuidv4();
+      localStorage.setItem(AWSAccountID, externalId);
+    }
+
+    return externalId;
+  }, [AWSAccountID, awsAccountIdInputError]);
+  const data = useQuery(
+    [
+      "cloudFormationStackCreated",
+      AWSAccountID,
+      projectId,
+      isAccountAccessible,
+      externalId,
+    ],
+    async () => {
+      return await isAWSArnAccessible({
+        targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+        externalId,
+        projectId,
+      });
+    },
+    {
+      enabled: currentStep === 3 && !isAccountAccessible, // no need to check if it's already accessible
+      refetchInterval: 5000,
+      refetchIntervalInBackground: true,
+    }
+  );
+  useEffect(() => {
+    if (data.isSuccess) {
+      setIsAccountAccessible(data.data);
+    }
+  }, [data]);
+  const handleAWSAccountIDChange = (accountId: string): void => {
+    setAWSAccountID(accountId);
+    setIsAccountAccessible(false); // any time they change the account ID, we need to re-check if it's accessible
+  };
+  const checkIfAlreadyAccessible = async (): Promise<void> => {
+    setAccountIdContinueButtonStatus("loading");
+    const isAlreadyAccessible = await isAWSArnAccessible({
+      targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+      externalId,
+      projectId,
+    });
+    if (isAlreadyAccessible) {
+      setCurrentStep(3);
+      setIsAccountAccessible(true);
+    } else {
+      setCurrentStep(2);
+    }
+    void reportToAnalytics({
+      step: "aws-account-id-complete",
+      awsAccountId: AWSAccountID,
+    });
+    setAccountIdContinueButtonStatus("");
+  };
+  const directToAWSLogin = (): void => {
+    const loginUrl = `https://signin.aws.amazon.com/console`;
+    void reportToAnalytics({
+      step: "aws-login-redirect-success",
+      loginUrl,
+    });
+    window.open(loginUrl, "_blank");
+  };
+  const directToCloudFormation = useCallback(async () => {
+    const trustArn = process.env.TRUST_ARN
+      ? process.env.TRUST_ARN
+      : "arn:aws:iam::108458755588:role/CAPIManagement";
+    const cloudFormationUrl = `https://console.aws.amazon.com/cloudformation/home?#/stacks/create/review?templateURL=https://porter-role.s3.us-east-2.amazonaws.com/cloudformation-access-policy.json&stackName=PorterRole&param_TrustArnParameter=${trustArn}`;
+    void reportToAnalytics({
+      step: "aws-cloudformation-redirect-success",
+      awsAccountId: AWSAccountID,
+      cloudFormationUrl,
+      externalId,
+    });
+    setCurrentStep(3);
+    window.open(cloudFormationUrl, "_blank");
+  }, [AWSAccountID, externalId]);
+  const handleGrantPermissionsComplete = (): void => {
+    void reportToAnalytics({
+      step: "aws-create-integration-success",
+      awsAccountId: AWSAccountID,
+    });
+    proceed({
+      cloudProviderCredentialIdentifier: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+    });
+  };
+
+  return (
+    <>
+      <Back onClick={goBack} />
+      <Container row>
+        <Image src={CloudProviderAWS.icon} size={22} />
+        <Spacer inline x={1} />
+        <Text size={21}>Grant AWS permissions</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text color="helper">
+        Grant Porter permissions to create infrastructure in your AWS account by
+        following 4 simple steps.
+      </Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        onlyShowCurrentStep={true}
+        currentStep={currentStep}
+        steps={[
+          <>
+            <Text size={16}>Log in to your AWS account</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Return to Porter after successful login.</Text>
+            <Spacer y={0.5} />
+            <AWSButtonContainer>
+              <ButtonImg src={CloudProviderAWS.icon} />
+              <Button
+                width={"170px"}
+                onClick={directToAWSLogin}
+                color="linear-gradient(180deg, #26292e, #24272c)"
+                withBorder
+              >
+                Log in
+              </Button>
+            </AWSButtonContainer>
+            <Spacer y={1} />
+            <Button
+              onClick={() => {
+                setCurrentStep(1);
+              }}
+            >
+              Continue
+            </Button>
+          </>,
+          <>
+            <Text size={16}>Enter your AWS account ID</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Make sure this is the ID of the account you are currently logged
+              into and would like to provision resources in.
+            </Text>
+            <Spacer y={0.5} />
+            <Input
+              label={
+                <Flex>
+                  👤 AWS account ID
+                  <i
+                    className="material-icons"
+                    onClick={() => {
+                      window.open(
+                        "https://us-east-1.console.aws.amazon.com/billing/home?region=us-east-1#/account",
+                        "_blank"
+                      );
+                    }}
+                  >
+                    help_outline
+                  </i>
+                </Flex>
+              }
+              value={AWSAccountID}
+              setValue={handleAWSAccountIDChange}
+              placeholder="ex: 915037676314"
+              error={awsAccountIdInputError}
+            />
+            <Spacer y={1} />
+            <StepChangeButtonsContainer>
+              <Button
+                onClick={checkIfAlreadyAccessible}
+                disabled={
+                  awsAccountIdInputError != null ||
+                  AWSAccountID.length === 0 ||
+                  accountIdContinueButtonStatus === "loading"
+                }
+              >
+                Continue
+              </Button>
+              <Spacer inline x={0.5} />
+              <Button
+                onClick={() => {
+                  setCurrentStep(0);
+                }}
+                color="#222222"
+                status={accountIdContinueButtonStatus}
+                loadingText={`Checking if Porter can already access this account`}
+              >
+                Back
+              </Button>
+            </StepChangeButtonsContainer>
+          </>,
+          <>
+            <Text size={16}>Create an AWS CloudFormation stack</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              This grants Porter permissions to create infrastructure in your
+              account.
+            </Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Clicking the button below will take you to the AWS CloudFormation
+              console. Return to Porter after clicking &apos;Create stack&apos;
+              in the bottom right corner.
+            </Text>
+            <Spacer y={0.5} />
+            <AWSButtonContainer>
+              <ButtonImg src={CloudProviderAWS.icon} />
+              <Button
+                width={"170px"}
+                onClick={directToCloudFormation}
+                color="linear-gradient(180deg, #26292e, #24272c)"
+                withBorder
+                disabled={isAccountAccessible}
+                disabledTooltipMessage={
+                  "Porter can already access your account!"
+                }
+              >
+                Grant permissions
+              </Button>
+            </AWSButtonContainer>
+            <Spacer y={1} />
+            <StepChangeButtonsContainer>
+              <Button
+                onClick={() => {
+                  setCurrentStep(3);
+                }}
+              >
+                Continue
+              </Button>
+              <Spacer inline x={0.5} />
+              <Button
+                onClick={() => {
+                  setCurrentStep(1);
+                }}
+                color="#222222"
+              >
+                Back
+              </Button>
+            </StepChangeButtonsContainer>
+          </>,
+          <>
+            <Text size={16}>Check permissions</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Checking if Porter can access AWS account with ID {AWSAccountID}
+              . This can take up to a minute.
+              <Spacer inline width="10px" />
+              <Link
+                hasunderline
+                onClick={() => {
+                  setShowNeedHelpModal(true);
+                }}
+              >
+                Need help?
+              </Link>
+            </Text>
+            <Spacer y={1} />
+            <CheckItem
+              preflightCheck={{
+                title: "AWS account accessible",
+                status: isAccountAccessible ? "success" : "pending",
+              }}
+            />
+            <Spacer y={1} />
+            <Container row>
+              <Button
+                onClick={handleGrantPermissionsComplete}
+                disabled={!isAccountAccessible}
+              >
+                Continue
+              </Button>
+              <Spacer inline x={0.5} />
+              <Button
+                onClick={() => {
+                  setCurrentStep(2);
+                }}
+                color="#222222"
+              >
+                Back
+              </Button>
+            </Container>
+          </>,
+        ]}
+      />
+      {showNeedHelpModal && (
+        <GrantAWSPermissionsHelpModal
+          onClose={() => {
+            setShowNeedHelpModal(false);
+          }}
+        />
+      )}
+    </>
+  );
+};
+
+export default GrantAWSPermissions;
+
+const StepChangeButtonsContainer = styled.div`
+  display: flex;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  ailgn-items: center;
+  > i {
+    margin-left: 10px;
+    font-size: 16px;
+    cursor: pointer;
+  }
+`;
+
+const ButtonImg = styled.img`
+  height: 14px;
+  margin-right: 12px;
+`;
+
+const AWSButtonContainer = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 156 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx

@@ -0,0 +1,156 @@
+import React, { useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderAzure } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import NodeGroups from "../../shared/NodeGroups";
+import { BackButton, Img } from "../CreateClusterForm";
+
+type Props = {
+  goBack: () => void;
+};
+
+const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
+  const [currentStep, _setCurrentStep] = useState<number>(100);
+
+  const {
+    control,
+    register,
+    formState: { errors },
+    watch,
+  } = useFormContext<ClientClusterContract>();
+
+  const region = watch("cluster.config.region");
+
+  const { updateClusterButtonProps } = useClusterFormContext();
+
+  return (
+    <div>
+      <Container row>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer x={1} inline />
+        <Img src={CloudProviderAzure.icon} />
+        <Text size={16}>Configure AKS Cluster</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text>Specify settings for your AKS cluster.</Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        currentStep={currentStep}
+        steps={[
+          <>
+            <Text size={16}>Cluster name</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: my-cluster"
+              type="text"
+              width="300px"
+              error={errors.cluster?.config?.clusterName?.message}
+              {...register("cluster.config.clusterName")}
+            />
+          </>,
+          <>
+            <Text size={16}>Cluster region</Text>
+            <Spacer y={0.5} />
+            <Controller
+              name={`cluster.config.region`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <Container style={{ width: "300px" }}>
+                  <Select
+                    options={CloudProviderAzure.regions.map((region) => ({
+                      value: region.name,
+                      label: region.displayName,
+                    }))}
+                    setValue={(selected: string) => {
+                      onChange(selected);
+                    }}
+                    value={value}
+                    label="📍 Azure region"
+                  />
+                </Container>
+              )}
+            />
+          </>,
+          <>
+            <Container style={{ width: "300px" }}>
+              <Text size={16}>Azure tier</Text>
+              <Spacer y={0.5} />
+              <Controller
+                name={`cluster.config.skuTier`}
+                control={control}
+                render={({ field: { value, onChange } }) => (
+                  <Select
+                    options={CloudProviderAzure.config.skuTiers.map((tier) => ({
+                      value: tier.name,
+                      label: tier.displayName,
+                    }))}
+                    value={value}
+                    setValue={(newSkuTier: string) => {
+                      onChange(newSkuTier);
+                    }}
+                  />
+                )}
+              />
+            </Container>
+          </>,
+          <>
+            <Text size={16}>CIDR Range</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: 10.78.0.0/16"
+              type="text"
+              width="300px"
+              error={errors.cluster?.config?.cidrRange?.message}
+              {...register("cluster.config.cidrRange")}
+            />
+          </>,
+          <>
+            <Text size={16}>
+              Application node group{" "}
+              <a
+                href="https://docs.porter.run/other/kubernetes-101"
+                target="_blank"
+                rel="noreferrer"
+              >
+                &nbsp;(?)
+              </a>
+            </Text>
+            <Spacer y={0.5} />
+            <NodeGroups
+              availableMachineTypes={CloudProviderAzure.machineTypes.filter(
+                (mt) => mt.supportedRegions.includes(region)
+              )}
+            />
+          </>,
+          <>
+            <Text size={16}>Provision cluster</Text>
+            <Spacer y={0.5} />
+            <Button
+              type="submit"
+              status={updateClusterButtonProps.status}
+              disabled={updateClusterButtonProps.isDisabled}
+              loadingText={updateClusterButtonProps.loadingText}
+            >
+              Submit
+            </Button>
+          </>,
+        ]}
+      />
+    </div>
+  );
+};
+
+export default ConfigureAKSCluster;

+ 95 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/azure/CreateAKSClusterForm.tsx

@@ -0,0 +1,95 @@
+import React, { useEffect, useState } from "react";
+import { useFormContext } from "react-hook-form";
+import { match } from "ts-pattern";
+
+import { CloudProviderAzure } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ConfigureAKSCluster from "./ConfigureAKSCluster";
+import GrantAzurePermissions from "./GrantAzurePermissions";
+
+type Props = {
+  goBack: () => void;
+  projectId: number;
+  projectName: string;
+};
+const CreateAKSClusterForm: React.FC<Props> = ({
+  goBack,
+  projectId,
+  projectName,
+}) => {
+  const [step, setStep] = useState<"permissions" | "cluster">("permissions");
+
+  const { setValue, reset } = useFormContext<ClientClusterContract>();
+  const { setCurrentContract } = useClusterFormContext();
+
+  useEffect(() => {
+    reset({
+      cluster: {
+        projectId,
+        cloudProvider: "Azure" as const,
+        config: {
+          kind: "AKS" as const,
+          clusterName: `${projectName}-cluster-${Math.random()
+            .toString(36)
+            .substring(2, 8)}`,
+          region: "eastus",
+          nodeGroups: [
+            {
+              nodeGroupType: "APPLICATION" as const,
+              instanceType: "Standard_B2als_v2",
+              minInstances: 1,
+              maxInstances: 10,
+            },
+            {
+              nodeGroupType: "SYSTEM" as const,
+              instanceType: "Standard_B2als_v2",
+              minInstances: 1,
+              maxInstances: 3,
+            },
+            {
+              nodeGroupType: "MONITORING" as const,
+              instanceType: "Standard_B2als_v2",
+              minInstances: 1,
+              maxInstances: 3,
+            },
+          ],
+          cidrRange: "10.78.0.0/16",
+          skuTier: "FREE" as const,
+        },
+      },
+    });
+    setCurrentContract(CloudProviderAzure.newClusterDefaultContract);
+  }, []);
+
+  return match(step)
+    .with("permissions", () => (
+      <GrantAzurePermissions
+        goBack={goBack}
+        proceed={({
+          cloudProviderCredentialIdentifier,
+        }: {
+          cloudProviderCredentialIdentifier: string;
+        }) => {
+          setValue(
+            "cluster.cloudProviderCredentialsId",
+            cloudProviderCredentialIdentifier
+          );
+          setStep("cluster");
+        }}
+        projectId={projectId}
+      />
+    ))
+    .with("cluster", () => (
+      <ConfigureAKSCluster
+        goBack={() => {
+          setStep("permissions");
+          setValue("cluster.cloudProviderCredentialsId", "");
+        }}
+      />
+    ))
+    .exhaustive();
+};
+
+export default CreateAKSClusterForm;

+ 218 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/azure/GrantAzurePermissions.tsx

@@ -0,0 +1,218 @@
+import React, { useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderAzure } from "lib/clusters/constants";
+import { connectToAzureAccount } from "lib/hooks/useCloudProvider";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import { BackButton, Img } from "../CreateClusterForm";
+
+type Props = {
+  goBack: () => void;
+  proceed: ({
+    cloudProviderCredentialIdentifier,
+  }: {
+    cloudProviderCredentialIdentifier: string;
+  }) => void;
+  projectId: number;
+};
+
+const azurePermissionsFormValidator = z.object({
+  subscriptionId: z.string().min(1, { message: "Required" }),
+  clientId: z.string().min(1, { message: "Required" }),
+  servicePrincipalKey: z.string().min(1, { message: "Required" }),
+  tenantId: z.string().min(1, { message: "Required" }),
+});
+type AzurePermissionsForm = z.infer<typeof azurePermissionsFormValidator>;
+
+const GrantAzurePermissions: React.FC<Props> = ({
+  goBack,
+  proceed,
+  projectId,
+}) => {
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [errorMessage, setErrorMessage] = useState<string>("");
+  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+  const { showIntercomWithMessage } = useIntercom();
+
+  const azurePermissionsForm = useForm<AzurePermissionsForm>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(azurePermissionsFormValidator),
+  });
+  const {
+    register,
+    formState: { errors },
+    handleSubmit,
+  } = azurePermissionsForm;
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+    if (errorMessage) {
+      return <ErrorComponent message={errorMessage} maxWidth="600px" />;
+    }
+
+    return "";
+  }, [isSubmitting, errorMessage]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setIsSubmitting(true);
+    try {
+      const cloudProviderCredentialIdentifier = await connectToAzureAccount({
+        subscriptionId: data.subscriptionId,
+        clientId: data.clientId,
+        servicePrincipalKey: data.servicePrincipalKey,
+        tenantId: data.tenantId,
+        projectId,
+      });
+      proceed({ cloudProviderCredentialIdentifier });
+    } catch (err) {
+      showIntercomWithMessage({
+        message: "I am running into an issue setting up Azure permissions.",
+      });
+      let message =
+        "Permission setup failed: please try again or contact support@porter.run if the error persists.";
+      if (axios.isAxiosError(err)) {
+        const parsed = z
+          .object({ error: z.string() })
+          .safeParse(err.response?.data);
+        if (parsed.success) {
+          message = `Permission setup failed: ${parsed.data.error}`;
+        }
+      }
+      setErrorMessage(message);
+    } finally {
+      setIsSubmitting(false);
+    }
+  });
+
+  return (
+    <div>
+      <Container row>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer x={1} inline />
+        <Img src={CloudProviderAzure.icon} />
+        <Text size={16}>Grant Azure permissions</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text color="helper">
+        Grant Porter permissions to create infrastructure in your Azure
+        subscription.
+      </Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        onlyShowCurrentStep={true}
+        currentStep={currentStep}
+        steps={[
+          <>
+            <Text size={16}>Set up your Azure subscription</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Follow our{" "}
+              <Link
+                to="https://docs.porter.run/provision/provisioning-on-azure"
+                target="_blank"
+              >
+                documentation
+              </Link>{" "}
+              to create your service principal and prepare your subscription for
+              use with Porter.
+            </Text>
+            <Spacer y={1} />
+            <Button
+              onClick={() => {
+                setCurrentStep(1);
+              }}
+            >
+              Continue
+            </Button>
+          </>,
+          <>
+            <Text size={16}>Input Azure service principal credentials</Text>
+            <Spacer height="15px" />
+            <Text color="helper">
+              Provide the credentials for an Azure Service Principal authorized
+              on your Azure subscription.
+            </Text>
+            <Spacer y={1} />
+            <Text size={16}>Subscription ID</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: 12345678-abcd-1234-abcd-12345678abcd"
+              type="text"
+              width="300px"
+              error={errors.subscriptionId?.message}
+              {...register("subscriptionId")}
+            />
+            <Spacer y={1} />
+            <Text size={16}>App ID</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: 12345678-abcd-1234-abcd-12345678abcd"
+              type="text"
+              width="300px"
+              error={errors.clientId?.message}
+              {...register("clientId")}
+            />
+            <Spacer y={1} />
+            <Text size={16}>Password</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+              type="password"
+              width="300px"
+              error={errors.servicePrincipalKey?.message}
+              {...register("servicePrincipalKey")}
+            />
+            <Spacer y={1} />
+            <Text size={16}>Tenant ID</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: 12345678-abcd-1234-abcd-12345678abcd"
+              type="text"
+              width="300px"
+              error={errors.tenantId?.message}
+              {...register("tenantId")}
+            />
+            <Spacer y={1} />
+            <Container row>
+              <Button
+                onClick={() => {
+                  setCurrentStep(0);
+                }}
+                color="#222222"
+              >
+                Back
+              </Button>
+              <Spacer inline x={0.5} />
+              <Button
+                status={buttonStatus}
+                onClick={onSubmit}
+                loadingText={"Checking permissions..."}
+              >
+                Continue
+              </Button>
+            </Container>
+          </>,
+        ]}
+      />
+    </div>
+  );
+};
+
+export default GrantAzurePermissions;

+ 129 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/ConfigureGKECluster.tsx

@@ -0,0 +1,129 @@
+import React, { useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderGCP } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import NodeGroups from "../../shared/NodeGroups";
+import { BackButton, Img } from "../CreateClusterForm";
+
+type Props = {
+  goBack: () => void;
+};
+
+const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
+  const [currentStep, _setCurrentStep] = useState<number>(4);
+
+  const {
+    control,
+    register,
+    formState: { errors },
+  } = useFormContext<ClientClusterContract>();
+
+  const { updateClusterButtonProps } = useClusterFormContext();
+
+  return (
+    <div>
+      <Container row>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer x={1} inline />
+        <Img src={CloudProviderGCP.icon} />
+        <Text size={16}>Configure EKS Cluster</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text>Specify settings for your EKS cluster.</Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        currentStep={currentStep}
+        steps={[
+          <>
+            <Text size={16}>Cluster name</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: my-cluster"
+              type="text"
+              width="300px"
+              error={errors.cluster?.config?.clusterName?.message}
+              {...register("cluster.config.clusterName")}
+            />
+          </>,
+          <>
+            <Text size={16}>Cluster region</Text>
+            <Spacer y={0.5} />
+            <Controller
+              name={`cluster.config.region`}
+              control={control}
+              render={({ field: { value, onChange } }) => {
+                return (
+                  <Container style={{ width: "300px" }}>
+                    <Select
+                      options={CloudProviderGCP.regions.map((region) => ({
+                        value: region.name,
+                        label: region.displayName,
+                      }))}
+                      setValue={(selected: string) => {
+                        onChange(selected);
+                      }}
+                      value={value}
+                      label="📍 GCP location"
+                    />
+                  </Container>
+                );
+              }}
+            />
+          </>,
+          <>
+            <Text size={16}>CIDR Range</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              placeholder="ex: 10.78.0.0/16"
+              type="text"
+              width="300px"
+              error={errors.cluster?.config?.cidrRange?.message}
+              {...register("cluster.config.cidrRange")}
+            />
+          </>,
+          <>
+            <Text size={16}>
+              Application node group{" "}
+              <a
+                href="https://docs.porter.run/other/kubernetes-101"
+                target="_blank"
+                rel="noreferrer"
+              >
+                &nbsp;(?)
+              </a>
+            </Text>
+            <Spacer y={0.5} />
+            <NodeGroups availableMachineTypes={CloudProviderGCP.machineTypes} />
+          </>,
+          <>
+            <Text size={16}>Provision cluster</Text>
+            <Spacer y={0.5} />
+            <Button
+              type="submit"
+              status={updateClusterButtonProps.status}
+              disabled={updateClusterButtonProps.isDisabled}
+              loadingText={updateClusterButtonProps.loadingText}
+            >
+              Submit
+            </Button>
+          </>,
+        ]}
+      />
+    </div>
+  );
+};
+
+export default ConfigureGKECluster;

+ 95 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/CreateGKEClusterForm.tsx

@@ -0,0 +1,95 @@
+import React, { useEffect, useState } from "react";
+import { useFormContext } from "react-hook-form";
+import { match } from "ts-pattern";
+
+import { CloudProviderGCP } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ConfigureGKECluster from "./ConfigureGKECluster";
+import GrantGCPPermissions from "./GrantGCPPermissions";
+
+type Props = {
+  goBack: () => void;
+  projectId: number;
+  projectName: string;
+};
+
+const CreateGKEClusterForm: React.FC<Props> = ({
+  goBack,
+  projectId,
+  projectName,
+}) => {
+  const [step, setStep] = useState<"permissions" | "cluster">("permissions");
+
+  const { setValue, reset } = useFormContext<ClientClusterContract>();
+  const { setCurrentContract } = useClusterFormContext();
+
+  useEffect(() => {
+    reset({
+      cluster: {
+        projectId,
+        cloudProvider: "GCP" as const,
+        config: {
+          kind: "GKE" as const,
+          clusterName: `${projectName}-cluster-${Math.random()
+            .toString(36)
+            .substring(2, 8)}`,
+          region: "us-east1",
+          nodeGroups: [
+            {
+              nodeGroupType: "APPLICATION" as const,
+              instanceType: "e2-standard-2",
+              minInstances: 1,
+              maxInstances: 10,
+            },
+            {
+              nodeGroupType: "SYSTEM" as const,
+              instanceType: "custom-2-4096",
+              minInstances: 1,
+              maxInstances: 2,
+            },
+            {
+              nodeGroupType: "MONITORING" as const,
+              instanceType: "custom-2-4096",
+              minInstances: 1,
+              maxInstances: 1,
+            },
+          ],
+          cidrRange: "10.78.0.0/16",
+        },
+      },
+    });
+    setCurrentContract(CloudProviderGCP.newClusterDefaultContract);
+  }, []);
+
+  return match(step)
+    .with("permissions", () => (
+      <GrantGCPPermissions
+        goBack={goBack}
+        proceed={({
+          cloudProviderCredentialIdentifier,
+        }: {
+          cloudProviderCredentialIdentifier: string;
+        }) => {
+          setValue(
+            "cluster.cloudProviderCredentialsId",
+            cloudProviderCredentialIdentifier
+          );
+          setStep("cluster");
+        }}
+        projectId={projectId}
+      />
+    ))
+    .with("cluster", () => (
+      <ConfigureGKECluster
+        goBack={() => {
+          setStep("permissions");
+          setValue("cluster.cloudProviderCredentialsId", "");
+        }}
+      />
+    ))
+    .exhaustive();
+};
+
+export default CreateGKEClusterForm;

+ 242 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/GrantGCPPermissions.tsx

@@ -0,0 +1,242 @@
+import React, { useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
+import { useForm } from "react-hook-form";
+import styled from "styled-components";
+import { z } from "zod";
+
+import UploadArea from "components/form-components/UploadArea";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { CloudProviderGCP } from "lib/clusters/constants";
+import { connectToGCPAccount } from "lib/hooks/useCloudProvider";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import { BackButton, Img } from "../CreateClusterForm";
+
+type Props = {
+  goBack: () => void;
+  proceed: ({
+    cloudProviderCredentialIdentifier,
+  }: {
+    cloudProviderCredentialIdentifier: string;
+  }) => void;
+  projectId: number;
+};
+
+const gcpPermissionsFormValidator = z.object({
+  serviceAccountKey: z.string().min(1, { message: "Required" }),
+  gcpProjectId: z.string().min(1, { message: "Required" }),
+});
+type GCPPermissionsForm = z.infer<typeof gcpPermissionsFormValidator>;
+
+const GrantGCPPermissions: React.FC<Props> = ({
+  goBack,
+  proceed,
+  projectId,
+}) => {
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+  const [errorMessage, setErrorMessage] = useState<string>("");
+  const [uploadFileError, setUploadFileError] = useState<string>("");
+  const { showIntercomWithMessage } = useIntercom();
+  const gcpPermissionsForm = useForm<GCPPermissionsForm>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(gcpPermissionsFormValidator),
+  });
+  const { handleSubmit, setValue, watch } = gcpPermissionsForm;
+  const gcpProjectId = watch("gcpProjectId");
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+    if (errorMessage) {
+      return <ErrorComponent message={errorMessage} maxWidth="600px" />;
+    }
+
+    return "";
+  }, [isSubmitting, errorMessage]);
+
+  const handleLoadJSON = (serviceAccountJSONFile: string): void => {
+    setUploadFileError("");
+    setValue("gcpProjectId", "");
+    setValue("serviceAccountKey", "");
+    try {
+      JSON.parse(serviceAccountJSONFile);
+    } catch (e) {
+      setUploadFileError(
+        "Uploaded file is not a valid JSON file. Please upload a new file."
+      );
+      return;
+    }
+    const serviceAccountCredentials = z
+      .object({
+        project_id: z.string(),
+      })
+      .safeParse(JSON.parse(serviceAccountJSONFile));
+
+    if (!serviceAccountCredentials.success) {
+      setUploadFileError(
+        `Invalid GCP service account credentials. No project ID detected in uploaded file. Please try again.`
+      );
+    } else {
+      setValue("gcpProjectId", serviceAccountCredentials.data.project_id);
+      setValue("serviceAccountKey", serviceAccountJSONFile);
+    }
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    setIsSubmitting(true);
+    try {
+      const cloudProviderCredentialIdentifier = await connectToGCPAccount({
+        projectId,
+        gcpProjectId: data.gcpProjectId,
+        serviceAccountKey: data.serviceAccountKey,
+      });
+      proceed({ cloudProviderCredentialIdentifier });
+    } catch (err) {
+      showIntercomWithMessage({
+        message: "I am running into an issue setting up GCP permissions.",
+      });
+      let message =
+        "Permission setup failed: please try again or contact support@porter.run if the error persists.";
+      if (axios.isAxiosError(err)) {
+        const parsed = z
+          .object({ error: z.string() })
+          .safeParse(err.response?.data);
+        if (parsed.success) {
+          message = `Permission setup failed: ${parsed.data.error}`;
+        }
+      }
+      setErrorMessage(message);
+    }
+  });
+
+  return (
+    <div>
+      <Container row>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer x={1} inline />
+        <Img src={CloudProviderGCP.icon} />
+        <Text size={16}>Grant GCP permissions</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text color="helper">
+        Grant Porter permissions to create infrastructure in your Google
+        project.
+      </Text>
+      <Spacer y={1} />
+      <VerticalSteps
+        currentStep={currentStep}
+        onlyShowCurrentStep={true}
+        steps={[
+          <>
+            <Text size={16}> Create the service account </Text>
+            <Spacer y={0.5} />
+            <Link
+              to="https://docs.porter.run/standard/getting-started/provisioning-on-gcp"
+              target="_blank"
+            >
+              Follow the steps in the Porter docs to generate your service
+              account credentials
+            </Link>
+            <Spacer y={0.5} />
+            <Button
+              onClick={() => {
+                setCurrentStep(1);
+              }}
+              height={"15px"}
+            >
+              Continue
+            </Button>
+          </>,
+          <>
+            <Text size={16}>Upload service account credentials</Text>
+            <Spacer y={1} />
+            <UploadArea
+              setValue={(x: string) => {
+                handleLoadJSON(x);
+              }}
+              label="🔒 GCP Key Data (JSON)"
+              placeholder="Drag a GCP Service Account JSON here, or click to browse."
+              width="100%"
+              height="100%"
+              isRequired={true}
+            />
+            {uploadFileError && (
+              <>
+                <AppearingDiv color={"#fcba03"}>
+                  <ErrorComponent message={uploadFileError} maxWidth="600px" />
+                </AppearingDiv>
+              </>
+            )}
+            {gcpProjectId && (
+              <>
+                <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
+                  <I className="material-icons">check</I>
+                  <Text color="#8590ff">
+                    Your cluster will be provisioned in Google Project:{" "}
+                    {gcpProjectId}
+                  </Text>
+                </AppearingDiv>
+              </>
+            )}
+            <Spacer y={1} />
+            <Container row>
+              <Button
+                onClick={() => {
+                  setCurrentStep(0);
+                }}
+                color="#222222"
+              >
+                Back
+              </Button>
+              <Button
+                disabled={!!uploadFileError || isSubmitting}
+                onClick={onSubmit}
+                status={buttonStatus}
+              >
+                Continue
+              </Button>
+            </Container>
+          </>,
+        ]}
+      />
+    </div>
+  );
+};
+
+export default GrantGCPPermissions;
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  align-items: center;
+  color: ${(props) => props.color || "#ffffff44"};
+  margin-left: 10px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  margin-right: 5px;
+`;

+ 168 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx

@@ -0,0 +1,168 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Loading from "components/Loading";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import StatusDot from "components/porter/StatusDot";
+import Text from "components/porter/Text";
+import { type ClientPreflightCheck } from "lib/clusters/types";
+
+import ResolutionStepsModalContents from "./help/preflight/ResolutionStepsModalContents";
+
+type ItemProps = {
+  preflightCheck: ClientPreflightCheck;
+};
+export const CheckItem: React.FC<ItemProps> = ({ preflightCheck }) => {
+  const [isExpanded, setIsExpanded] = useState(true);
+
+  return (
+    <CheckItemContainer>
+      <CheckItemTop
+        onClick={() => {
+          setIsExpanded(!isExpanded);
+        }}
+      >
+        {match(preflightCheck.status)
+          .with("pending", () => (
+            <Loading offset="0px" width="20px" height="20px" />
+          ))
+          .otherwise((status) =>
+            match(status)
+              .with("success", () => <StatusDot status="available" />)
+              .with("failure", () => <StatusDot status="failing" />)
+              .exhaustive()
+          )}
+        <Spacer inline x={1} />
+        <Text style={{ flex: 1 }}>{preflightCheck.title}</Text>
+        {preflightCheck.error && (
+          <ExpandIcon className="material-icons" isExpanded={isExpanded}>
+            arrow_drop_down
+          </ExpandIcon>
+        )}
+      </CheckItemTop>
+      {isExpanded && preflightCheck.error && (
+        <div>
+          <ErrorComponent
+            message={preflightCheck.error.detail}
+            ctaText={
+              preflightCheck.error.resolution
+                ? "Troubleshooting steps"
+                : undefined
+            }
+            errorModalContents={
+              preflightCheck.error.resolution ? (
+                <ResolutionStepsModalContents
+                  resolution={preflightCheck.error.resolution}
+                />
+              ) : undefined
+            }
+          />
+          <Spacer y={0.5} />
+          {preflightCheck.error.metadata &&
+            Object.entries(preflightCheck.error.metadata).map(
+              ([key, value]) => (
+                <>
+                  <div key={key}>
+                    <ErrorMessageLabel>{key}:</ErrorMessageLabel>
+                    <ErrorMessageContent>{value}</ErrorMessageContent>
+                  </div>
+                </>
+              )
+            )}
+        </div>
+      )}
+    </CheckItemContainer>
+  );
+};
+
+type Props = {
+  onClose: () => void;
+  preflightChecks: ClientPreflightCheck[];
+};
+const PreflightChecksModal: React.FC<Props> = ({
+  onClose,
+  preflightChecks,
+}) => {
+  return (
+    <Modal width="600px" closeModal={onClose}>
+      <AppearingDiv>
+        <Text size={16}>Cluster provision check</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Your cloud provider account does not have enough resources to
+          provision this cluster. Please visit your cloud provider or change
+          your cluster configuration, then re-submit.
+        </Text>
+        <Spacer y={1} />
+        {preflightChecks.map((pfc) => (
+          <CheckItem preflightCheck={pfc} key={pfc.title} />
+        ))}
+      </AppearingDiv>
+    </Modal>
+  );
+};
+
+export default PreflightChecksModal;
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  flex-direction: column;
+  color: ${(props) => props.color || "#ffffff44"};
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const CheckItemContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  border: 1px solid ${(props) => props.theme.border};
+  border-radius: 5px;
+  font-size: 13px;
+  width: 100%;
+  margin-bottom: 10px;
+  padding-left: 10px;
+  cursor: pointer;
+  background: ${(props) => props.theme.clickable.bg};
+`;
+
+const CheckItemTop = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background: ${(props) => props.theme.clickable.bg};
+`;
+
+const ExpandIcon = styled.i<{ isExpanded: boolean }>`
+  margin-left: 8px;
+  color: #ffffff66;
+  font-size: 20px;
+  cursor: pointer;
+  border-radius: 20px;
+  transform: ${(props) => (props.isExpanded ? "" : "rotate(-90deg)")};
+`;
+const ErrorMessageLabel = styled.span`
+  font-weight: bold;
+  margin-left: 10px;
+`;
+const ErrorMessageContent = styled.div`
+  font-family: "Courier New", Courier, monospace;
+  padding: 5px 10px;
+  border-radius: 4px;
+  margin-left: 10px;
+  user-select: text;
+  cursor: text;
+`;

+ 88 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/AWSCostConsentModalContents.tsx

@@ -0,0 +1,88 @@
+import React from "react";
+import styled from "styled-components";
+
+import ExpandableSection from "components/porter/ExpandableSection";
+import Fieldset from "components/porter/Fieldset";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type Props = {
+  baseCost: number;
+};
+const AWSCostConsentModalContents: React.FC<Props> = ({ baseCost }) => {
+  return (
+    <div>
+      <Text size={16}>Base AWS cost consent</Text>
+      <Spacer height="15px" />
+      <Text color="helper">
+        Porter will create the underlying infrastructure in your own AWS
+        account. You will be separately charged by AWS for this infrastructure.
+        The cost for this base infrastructure is as follows:
+      </Text>
+      <Spacer y={1} />
+      <ExpandableSection
+        noWrapper
+        expandText="[+] Show details"
+        collapseText="[-] Hide details"
+        Header={
+          <Text size={20} weight={600}>
+            ${baseCost} / mo
+          </Text>
+        }
+        ExpandedSection={
+          <>
+            <Spacer height="15px" />
+            <Fieldset background="#1b1d2688">
+              • Amazon Elastic Kubernetes Service (EKS) = $73/mo
+              <Spacer height="15px" />
+              • Amazon EC2:
+              <Spacer height="15px" />
+              <Tab />+ System workloads: t3.medium instance (2) = $60.74/mo
+              <Spacer height="15px" />
+              <Tab />+ Monitoring workloads: t3.large instance (1) = $60.74/mo
+              <Spacer height="15px" />
+              <Tab />+ Application workloads: t3.medium instance (1) = $30.1/mo
+            </Fieldset>
+          </>
+        }
+      />
+      <Spacer y={1} />
+      <Text color="helper">
+        The base AWS infrastructure covers up to 2 vCPU and 4GB of RAM. Separate
+        from the AWS cost, Porter charges based on your resource usage.
+      </Text>
+      <Spacer inline width="5px" />
+      <Spacer y={0.5} />
+      <Link hasunderline to="https://porter.run/pricing" target="_blank">
+        Learn more about our pricing.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        You can use your AWS credits to pay for the underlying infrastructure,
+        and if you are a startup with less than 5M in funding, you may qualify
+        for our startup program that gives you $10k in credits.
+      </Text>
+      <Spacer y={0.5} />
+      <Link
+        hasunderline
+        to="https://gcpjnf9adme.typeform.com/to/vUg9SDWf"
+        target="_blank"
+      >
+        You can apply here.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        All AWS resources will be automatically deleted when you delete your
+        Porter project. Please enter the AWS base cost (&quot;{baseCost}&quot;)
+        below to proceed:
+      </Text>
+    </div>
+  );
+};
+
+export default AWSCostConsentModalContents;
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;

+ 92 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/AzureCostConsentModalContents.tsx

@@ -0,0 +1,92 @@
+import React from "react";
+import styled from "styled-components";
+
+import ExpandableSection from "components/porter/ExpandableSection";
+import Fieldset from "components/porter/Fieldset";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type Props = {
+  baseCost: number;
+};
+const AzureCostConsentModalContents: React.FC<Props> = ({ baseCost }) => {
+  return (
+    <div>
+      <Text size={16}>Base Azure cost consent</Text>
+      <Spacer height="15px" />
+      <Text color="helper">
+        Porter will create the underlying infrastructure in your own Azure
+        account. You will be separately charged by Azure for this
+        infrastructure. The cost for this base infrastructure is as follows:
+      </Text>
+      <Spacer y={1} />
+      <ExpandableSection
+        noWrapper
+        expandText="[+] Show details"
+        collapseText="[-] Hide details"
+        Header={<Cost>${baseCost} / mo</Cost>}
+        ExpandedSection={
+          <>
+            <Spacer height="15px" />
+            <Fieldset background="#1b1d2688">
+              • Azure virtual machines:
+              <Spacer height="15px" />
+              <Tab />+ System workloads: Standard_B2als_v2 instance (3) =
+              $82.34/mo
+              <Spacer height="15px" />
+              <Tab />+ Monitoring workloads: Standard_B2as_v2 instance (1) =
+              $54.90/mo
+              <Spacer height="15px" />
+              <Tab />+ Application workloads: Standard_B2als_v2 instance (1) =
+              $27.45/mo
+            </Fieldset>
+          </>
+        }
+      />
+      <Spacer y={1} />
+      <Text color="helper">
+        The base Azure infrastructure covers up to 2 vCPU and 4GB of RAM for
+        application workloads. Separate from the Azure cost, Porter charges
+        based on your resource usage.
+      </Text>
+      <Spacer inline width="5px" />
+      <Spacer y={0.5} />
+      <Link hasunderline to="https://porter.run/pricing" target="_blank">
+        Learn more about our pricing.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        You can use your Azure credits to pay for the underlying infrastructure,
+        and if you are a startup with less than 5M in funding, you may qualify
+        for our startup program that gives you $10k in credits.
+      </Text>
+      <Spacer y={0.5} />
+      <Link
+        hasunderline
+        to="https://gcpjnf9adme.typeform.com/to/vUg9SDWf"
+        target="_blank"
+      >
+        You can apply here.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        All Azure resources will be automatically deleted when you delete your
+        Porter project. Please enter the Azure base cost (&quot;{baseCost}
+        &quot;) below to proceed:
+      </Text>
+    </div>
+  );
+};
+
+export default AzureCostConsentModalContents;
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;
+
+const Cost = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+`;

+ 61 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/CostConsentModal.tsx

@@ -0,0 +1,61 @@
+import React, { useState } from "react";
+import { match } from "ts-pattern";
+
+import Button from "components/porter/Button";
+import Input from "components/porter/Input";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import { type ClientCloudProvider } from "lib/clusters/types";
+
+import AWSCostConsentModalContents from "./AWSCostConsentModalContents";
+import AzureCostConsentModalContents from "./AzureCostConsentModalContents";
+import GCPCostConsentModalContents from "./GCPCostConsentModalContents";
+
+type Props = {
+  onClose: () => void;
+  onComplete: () => void;
+  cloudProvider: ClientCloudProvider;
+};
+
+const AWSCostConsentModal: React.FC<Props> = ({
+  onClose,
+  onComplete,
+  cloudProvider,
+}) => {
+  const [confirmCost, setConfirmCost] = useState<string>("");
+
+  return (
+    <>
+      <Modal closeModal={onClose}>
+        {match(cloudProvider)
+          .with({ name: "AWS" }, () => (
+            <AWSCostConsentModalContents baseCost={cloudProvider.baseCost} />
+          ))
+          .with({ name: "GCP" }, () => (
+            <GCPCostConsentModalContents baseCost={cloudProvider.baseCost} />
+          ))
+          .with({ name: "Azure" }, () => (
+            <AzureCostConsentModalContents baseCost={cloudProvider.baseCost} />
+          ))
+          .otherwise(() => null)}
+        <Spacer y={1} />
+        <Input
+          placeholder={cloudProvider.baseCost.toString()}
+          value={confirmCost}
+          setValue={setConfirmCost}
+          width="100%"
+          height="40px"
+        />
+        <Spacer y={1} />
+        <Button
+          disabled={confirmCost !== cloudProvider.baseCost.toString()}
+          onClick={onComplete}
+        >
+          Continue
+        </Button>
+      </Modal>
+    </>
+  );
+};
+
+export default AWSCostConsentModal;

+ 89 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/cost-consent/GCPCostConsentModalContents.tsx

@@ -0,0 +1,89 @@
+import React from "react";
+import styled from "styled-components";
+
+import ExpandableSection from "components/porter/ExpandableSection";
+import Fieldset from "components/porter/Fieldset";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type Props = {
+  baseCost: number;
+};
+const GCPCostConsentModalContents: React.FC<Props> = ({ baseCost }) => {
+  return (
+    <div>
+      <Text size={16}>Base GCP cost consent</Text>
+      <Spacer height="15px" />
+      <Text color="helper">
+        Porter will create the underlying infrastructure in your own GCP
+        project. You will be separately charged by GCP for this infrastructure.
+        The cost for this base infrastructure is as follows:
+      </Text>
+      <Spacer y={1} />
+      <ExpandableSection
+        noWrapper
+        expandText="[+] Show details"
+        collapseText="[-] Hide details"
+        Header={<Cost>${baseCost} / mo</Cost>}
+        ExpandedSection={
+          <>
+            <Spacer height="15px" />
+            <Fieldset background="#1b1d2688">
+              • Google Kubernetes Engine Management (GKE) = $73/mo
+              <Spacer height="15px" />
+              • GKE Compute:
+              <Spacer height="15px" />
+              <Tab />+ System workloads (2) = $90/mo
+              <Spacer height="15px" />
+              <Tab />+ Monitoring workloads (1) = $45/mo
+              <Spacer height="15px" />
+              <Tab />+ Application workloads (1) = $45/mo
+            </Fieldset>
+          </>
+        }
+      />
+      <Spacer y={1} />
+      <Text color="helper">
+        The base GCP infrastructure covers up to 2 vCPU and 4GB of RAM. Separate
+        from the GCP cost, Porter charges based on your resource usage.
+      </Text>
+      <Spacer inline width="5px" />
+      <Spacer y={0.5} />
+      <Link hasunderline to="https://porter.run/pricing" target="_blank">
+        Learn more about our pricing.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        You can use your GCP credits to pay for the underlying infrastructure,
+        and if you are a startup with less than 5M in funding, you may qualify
+        for our startup program that gives you $10k in credits.
+      </Text>
+      <Spacer y={0.5} />
+      <Link
+        hasunderline
+        to="https://gcpjnf9adme.typeform.com/to/vUg9SDWf"
+        target="_blank"
+      >
+        You can apply here.
+      </Link>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        All GCP resources will be automatically deleted when you delete your
+        Porter project. Please enter the GCP base cost (&quot;{baseCost}&quot;)
+        below to proceed:
+      </Text>
+    </div>
+  );
+};
+
+export default GCPCostConsentModalContents;
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;
+
+const Cost = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+`;

+ 84 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/help/permissions/GrantAWSPermissionsHelpModal.tsx

@@ -0,0 +1,84 @@
+import React from "react";
+import styled from "styled-components";
+
+import Link from "components/porter/Link";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Step from "components/porter/Step";
+import Text from "components/porter/Text";
+
+import cloudformationStatus from "assets/cloud-formation-stack-complete.png";
+
+type Props = {
+  onClose: () => void;
+};
+
+const GrantAWSPermissionsHelpModal: React.FC<Props> = ({ onClose }) => {
+  return (
+    <Modal closeModal={onClose} width={"800px"}>
+      <Text size={16}>Granting Porter access to AWS</Text>
+      <Spacer y={1} />
+      <Text color="helper">
+        Porter needs access to your AWS account in order to create
+        infrastructure. You can grant Porter access to AWS by following these
+        steps:
+      </Text>
+      <Spacer y={1} />
+      <Step number={1}>
+        <Link
+          to="https://aws.amazon.com/resources/create-account/"
+          target="_blank"
+        >
+          Create an AWS account
+        </Link>
+        <Spacer inline width="5px" />
+        if you do not already have one.
+      </Step>
+      <Spacer y={1} />
+      <Step number={2}>
+        Once you are logged in to your AWS account,
+        <Spacer inline width="5px" />
+        <Link
+          to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+          target="_blank"
+        >
+          copy your account ID
+        </Link>
+        .
+      </Step>
+      <Spacer y={1} />
+      <Step number={3}>
+        Fill in your account ID on Porter and select &quot;Grant
+        permissions&quot;.
+      </Step>
+      <Spacer y={1} />
+      <Step number={4}>
+        After being redirected to AWS CloudFormation, select &quot;Create
+        stack&quot; on the bottom right.
+      </Step>
+      <Spacer y={1} />
+      <Step number={5}>
+        The stack will start to create. Refresh until the stack status has
+        changed from &quot;CREATE_IN_PROGRESS&quot; to
+        &quot;CREATE_COMPLETE&quot;:
+      </Step>
+      <Spacer y={1} />
+      <ImageDiv>
+        <img src={cloudformationStatus} height="250px" />
+      </ImageDiv>
+      <Spacer y={1} />
+      <Step number={6}>Return to Porter and select &quot;Continue&quot;.</Step>
+      <Spacer y={1} />
+      <Step number={7}>
+        If you continue to see issues,{" "}
+        <a href="mailto:support@porter.run">email support.</a>
+      </Step>
+    </Modal>
+  );
+};
+
+export default GrantAWSPermissionsHelpModal;
+
+const ImageDiv = styled.div`
+  text-align: center;
+`;

+ 45 - 0
dashboard/src/main/home/infrastructure-dashboard/modals/help/preflight/ResolutionStepsModalContents.tsx

@@ -0,0 +1,45 @@
+import React from "react";
+import styled from "styled-components";
+
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Step from "components/porter/Step";
+import Text from "components/porter/Text";
+import { type PreflightCheckResolution } from "lib/clusters/types";
+
+type Props = {
+  resolution: PreflightCheckResolution;
+};
+const ElasticIPQuotaModalContents: React.FC<Props> = ({ resolution }) => {
+  return (
+    <div>
+      <Text size={16} weight={500}>
+        {resolution.title}
+      </Text>
+      <Spacer y={1} />
+      <Text color="helper">{resolution.subtitle}</Text>
+      <Spacer y={1} />
+      <StepContainer>
+        {resolution.steps.map((step, i) => (
+          <Step number={i + 1} key={i}>
+            {step.externalLink ? (
+              <Link to={step.externalLink} target="_blank">
+                {step.text}
+              </Link>
+            ) : (
+              step.text
+            )}
+          </Step>
+        ))}
+      </StepContainer>
+    </div>
+  );
+};
+
+export default ElasticIPQuotaModalContents;
+
+const StepContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+`;

+ 121 - 0
dashboard/src/main/home/infrastructure-dashboard/shared/NodeGroups.tsx

@@ -0,0 +1,121 @@
+import React, { useMemo } from "react";
+import { Controller, useFieldArray, useFormContext } from "react-hook-form";
+
+import Container from "components/porter/Container";
+import Expandable from "components/porter/Expandable";
+import Image from "components/porter/Image";
+import Input from "components/porter/Input";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  type ClientClusterContract,
+  type MachineType,
+} from "lib/clusters/types";
+
+import world from "assets/world.svg";
+
+type Props = {
+  availableMachineTypes: MachineType[];
+};
+const NodeGroups: React.FC<Props> = ({ availableMachineTypes }) => {
+  const { control } = useFormContext<ClientClusterContract>();
+  const { fields: nodeGroups } = useFieldArray({
+    control,
+    name: "cluster.config.nodeGroups",
+  });
+  const displayableNodeGroups = useMemo(() => {
+    const dng = nodeGroups.map((ng, idx) => {
+      return {
+        nodeGroup: ng,
+        idx,
+        isIncluded:
+          ng.nodeGroupType === "APPLICATION" || ng.nodeGroupType === "CUSTOM",
+      };
+    });
+    return dng;
+  }, [nodeGroups]);
+
+  return (
+    <>
+      {displayableNodeGroups.map((ng) => {
+        return ng.isIncluded ? (
+          <Expandable
+            preExpanded={true}
+            key={ng.nodeGroup.id}
+            header={
+              <Container row>
+                <Image src={world} />
+                <Spacer inline x={1} />
+                {ng.nodeGroup.nodeGroupType === "APPLICATION" &&
+                  "Default node group"}
+              </Container>
+            }
+          >
+            <Controller
+              name={`cluster.config.nodeGroups.${ng.idx}`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <>
+                  <Select
+                    width="300px"
+                    options={availableMachineTypes.map((t) => ({
+                      value: t.name,
+                      label: t.displayName,
+                    }))}
+                    value={value.instanceType}
+                    setValue={(newInstanceType: string) => {
+                      onChange({
+                        ...value,
+                        instanceType: newInstanceType,
+                      });
+                    }}
+                    label="Machine type"
+                  />
+                  <Spacer y={1} />
+                  <Text color="helper">
+                    Minimum number of application nodes
+                  </Text>
+                  <Spacer y={0.5} />
+                  <Input
+                    width="75px"
+                    type="number"
+                    disabled={false}
+                    value={value.minInstances.toString()}
+                    setValue={(newMinInstances: string) => {
+                      onChange({
+                        ...value,
+                        minInstances: parseInt(newMinInstances),
+                      });
+                    }}
+                    placeholder="ex: 1"
+                  />
+                  <Spacer y={1} />
+                  <Text color="helper">
+                    Maximum number of application nodes
+                  </Text>
+                  <Spacer y={0.5} />
+                  <Input
+                    width="75px"
+                    type="number"
+                    disabled={false}
+                    value={value.maxInstances.toString()}
+                    setValue={(newMaxInstances: string) => {
+                      onChange({
+                        ...value,
+                        maxInstances: parseInt(newMaxInstances),
+                      });
+                    }}
+                    placeholder="ex: 10"
+                  />
+                </>
+              )}
+            />
+          </Expandable>
+        ) : null;
+      })}
+    </>
+  );
+};
+
+export default NodeGroups;

+ 156 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/Settings.tsx

@@ -0,0 +1,156 @@
+import React, { useCallback, useContext, useMemo, useState } from "react";
+import axios from "axios";
+import { useHistory } from "react-router";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Icon from "components/porter/Icon";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import { Context } from "shared/Context";
+import trash from "assets/trash.png";
+
+import { useClusterContext } from "../ClusterContextProvider";
+import { useClusterFormContext } from "../ClusterFormContextProvider";
+
+const Settings: React.FC = () => {
+  const { cluster, deleteCluster, updateClusterVanityName, isClusterUpdating } =
+    useClusterContext();
+  const [clusterName, setClusterName] = useState(cluster.vanity_name);
+  const history = useHistory();
+  const { setCurrentOverlay = () => ({}) } = useContext(Context);
+  const { showIntercomWithMessage } = useIntercom();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [errorMessage, setErrorMessage] = useState("");
+  const [status, setStatus] = useState("");
+  const { updateClusterButtonProps } = useClusterFormContext();
+
+  const renameCluster = useCallback(async (): Promise<void> => {
+    setStatus("loading");
+    try {
+      updateClusterVanityName(clusterName);
+      setStatus("success");
+    } catch (err) {
+      setStatus("error");
+    }
+  }, [clusterName, updateClusterVanityName]);
+
+  const handleDeletionSubmit = async (): Promise<void> => {
+    try {
+      setIsSubmitting(true);
+      await deleteCluster();
+      setCurrentOverlay(null);
+      history.push("/infrastructure");
+    } catch (err) {
+      showIntercomWithMessage({
+        message: "I am running into an issue deleting my cluster.",
+      });
+
+      let message =
+        "Cluster deletion failed: please try again or contact support@porter.run if the error persists.";
+
+      if (axios.isAxiosError(err)) {
+        const parsed = z
+          .object({ error: z.string() })
+          .safeParse(err.response?.data);
+        if (parsed.success) {
+          message = `Cluster deletion failed: ${parsed.data.error}`;
+        }
+      }
+      setErrorMessage(message);
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleDeletionClick = async (): Promise<void> => {
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${cluster.name}?`,
+      onYes: handleDeletionSubmit,
+      onNo: () => {
+        setCurrentOverlay(null);
+      },
+    });
+  };
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+    if (errorMessage) {
+      return <ErrorComponent message={errorMessage} maxWidth="600px" />;
+    }
+
+    return "";
+  }, [isSubmitting, errorMessage]);
+
+  return (
+    <Container>
+      <Text size={16}>Cluster name</Text>
+      <Spacer y={0.5} />
+      <Text color={"helper"}>
+        The vanity name for your cluster will not change your cluster&apos;s
+        name in your cloud provider.
+      </Text>
+      <Spacer y={0.7} />
+      <Input
+        placeholder="ex: my-cluster"
+        width="300px"
+        value={clusterName}
+        setValue={setClusterName}
+      />
+      <Spacer y={1} />
+      <Button
+        status={status}
+        onClick={renameCluster}
+        disabled={clusterName === ""}
+      >
+        Update
+      </Button>
+      <Spacer y={1} />
+      <Text size={16}>Delete &quot;{cluster.vanity_name}&quot;</Text>
+      <Spacer y={0.5} />
+      <Text color={"helper"}>
+        Delete this cluster and underlying infrastructure. To ensure that
+        everything has been properly destroyed, please visit the console of your
+        cloud provider. Instructions to properly delete all resources can be
+        found
+        <a
+          target="none"
+          href="https://docs.porter.run/other/deleting-dangling-resources"
+        >
+          {" "}
+          here
+        </a>
+        . Contact support@porter.run if you need guidance.
+      </Text>
+      <Spacer y={1} />
+      <Button
+        color="#b91133"
+        onClick={handleDeletionClick}
+        status={buttonStatus}
+        disabled={
+          isSubmitting ||
+          updateClusterButtonProps.isDisabled ||
+          isClusterUpdating
+        }
+        disabledTooltipMessage={
+          isSubmitting
+            ? "Deleting..."
+            : "Unable to delete while the cluster is updating."
+        }
+      >
+        <Icon src={trash} height={"15px"} />
+        <Spacer inline x={0.5} />
+        Delete
+      </Button>
+    </Container>
+  );
+};
+
+export default Settings;

+ 83 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/AKSClusterOverview.tsx

@@ -0,0 +1,83 @@
+import React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Container from "components/porter/Container";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { CloudProviderAzure } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import NodeGroups from "../../shared/NodeGroups";
+
+const AKSClusterOverview: React.FC = () => {
+  const { control, watch } = useFormContext<ClientClusterContract>();
+
+  const region = watch("cluster.config.region");
+  const cidrRange = watch("cluster.config.cidrRange");
+
+  return (
+    <>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>Cluster region</Text>
+        <Spacer y={0.5} />
+        <Select
+          options={[{ value: region, label: region }]}
+          disabled={true}
+          value={region}
+          label="📍 Azure region"
+        />
+        <Spacer y={1} />
+      </Container>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>Cluster CIDR range</Text>
+        <Spacer y={0.5} />
+        <Select
+          options={[{ value: cidrRange, label: cidrRange }]}
+          disabled={true}
+          value={cidrRange}
+        />
+        <Spacer y={1} />
+      </Container>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>Azure tier</Text>
+        <Spacer y={0.5} />
+        <Controller
+          name={`cluster.config.skuTier`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Select
+              options={CloudProviderAzure.config.skuTiers.map((tier) => ({
+                value: tier.name,
+                label: tier.displayName,
+              }))}
+              value={value}
+              setValue={(newSkuTier: string) => {
+                onChange(newSkuTier);
+              }}
+            />
+          )}
+        />
+        <Spacer y={1} />
+      </Container>
+      <Text size={16}>
+        Application node group{" "}
+        <a
+          href="https://docs.porter.run/other/kubernetes-101"
+          target="_blank"
+          rel="noreferrer"
+        >
+          &nbsp;(?)
+        </a>
+      </Text>
+      <Spacer y={0.5} />
+      <NodeGroups
+        availableMachineTypes={CloudProviderAzure.machineTypes.filter((mt) =>
+          mt.supportedRegions.includes(region)
+        )}
+      />
+    </>
+  );
+};
+
+export default AKSClusterOverview;

+ 35 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/ClusterOverview.tsx

@@ -0,0 +1,35 @@
+import React from "react";
+import { match } from "ts-pattern";
+
+import Spacer from "components/porter/Spacer";
+
+import { useClusterContext } from "../../ClusterContextProvider";
+import ClusterSaveButton from "../../ClusterSaveButton";
+import AKSClusterOverview from "./AKSClusterOverview";
+import EKSClusterOverview from "./EKSClusterOverview";
+import GKEClusterOverview from "./GKEClusterOverview";
+
+const ClusterOverview: React.FC = () => {
+  const { cluster } = useClusterContext();
+
+  return (
+    <>
+      {match(cluster.contract.config.cluster.config)
+        .with({ kind: "EKS" }, () => {
+          return <EKSClusterOverview />;
+        })
+        .with({ kind: "GKE" }, () => {
+          return <GKEClusterOverview />;
+        })
+        .with({ kind: "AKS" }, () => {
+          return <AKSClusterOverview />;
+        })
+        .exhaustive()}
+      <Spacer y={1} />
+      <ClusterSaveButton />
+      <Spacer y={1} />
+    </>
+  );
+};
+
+export default ClusterOverview;

+ 58 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/EKSClusterOverview.tsx

@@ -0,0 +1,58 @@
+import React, { useMemo } from "react";
+import { useFormContext } from "react-hook-form";
+
+import Container from "components/porter/Container";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  CloudProviderAWS,
+  SUPPORTED_AWS_REGIONS,
+} from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import NodeGroups from "../../shared/NodeGroups";
+
+const EKSClusterOverview: React.FC = () => {
+  const { watch } = useFormContext<ClientClusterContract>();
+  const region = watch("cluster.config.region");
+
+  const label = useMemo(() => {
+    return SUPPORTED_AWS_REGIONS.find((x) => x.name === region)?.displayName;
+  }, [region]);
+
+  return (
+    <>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>AWS region</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Your cluster is running in the following region.
+        </Text>
+        <Spacer y={0.7} />
+        <Select
+          options={[{ value: region, label: label || "" }]}
+          disabled={true}
+          value={region}
+        />
+      </Container>
+      <Spacer y={1} />
+      <Text size={16}>Node groups</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Configure node groups to support custom workloads.{" "}
+        <a
+          href="https://docs.porter.run/other/kubernetes-101"
+          target="_blank"
+          rel="noreferrer"
+        >
+          &nbsp;(?)
+        </a>
+      </Text>
+      <Spacer y={1} />
+      <NodeGroups availableMachineTypes={CloudProviderAWS.machineTypes} />
+    </>
+  );
+};
+
+export default EKSClusterOverview;

+ 57 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/GKEClusterOverview.tsx

@@ -0,0 +1,57 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+
+import Container from "components/porter/Container";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { CloudProviderGCP } from "lib/clusters/constants";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import NodeGroups from "../../shared/NodeGroups";
+
+const GKEClusterOverview: React.FC = () => {
+  const { watch } = useFormContext<ClientClusterContract>();
+  const region = watch("cluster.config.region");
+  const cidrRange = watch("cluster.config.cidrRange");
+
+  return (
+    <>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>Cluster region</Text>
+        <Spacer y={0.5} />
+        <Select
+          options={[{ value: region, label: region }]}
+          disabled={true}
+          value={region}
+          label="📍 GCP location"
+        />
+        <Spacer y={1} />
+      </Container>
+      <Container style={{ width: "300px" }}>
+        <Text size={16}>Cluster CIDR range</Text>
+        <Spacer y={0.5} />
+        <Select
+          options={[{ value: cidrRange, label: cidrRange }]}
+          disabled={true}
+          value={cidrRange}
+        />
+        <Spacer y={1} />
+      </Container>
+      <Text size={16}>
+        Application node group{" "}
+        <a
+          href="https://docs.porter.run/other/kubernetes-101"
+          target="_blank"
+          rel="noreferrer"
+        >
+          &nbsp;(?)
+        </a>
+      </Text>
+      <Spacer y={0.5} />
+      <NodeGroups availableMachineTypes={CloudProviderGCP.machineTypes} />
+    </>
+  );
+};
+
+export default GKEClusterOverview;

+ 12 - 9
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -8,11 +8,12 @@ import { devtools } from "valtio/utils";
 import Routes from "./Routes";
 import { OFState } from "./state";
 import { useSteps } from "./state/StepHandler";
-import { Onboarding as OnboardingSaveType } from "./types";
+import { type Onboarding as OnboardingSaveType } from "./types";
 
-import lightning from "assets/lightning.png";
+import bolt from "assets/bolt.svg";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import CreateClusterForm from "../infrastructure-dashboard/forms/CreateClusterForm";
 
 const Onboarding = () => {
   const context = useContext(Context);
@@ -20,7 +21,7 @@ const Onboarding = () => {
   useSteps(isLoading);
 
   useEffect(() => {
-    let unsub = devtools(OFState, { name: "Onboarding flow state" });
+    const unsub = devtools(OFState, { name: "Onboarding flow state" });
     return () => {
       if (typeof unsub === "function") {
         unsub();
@@ -42,7 +43,7 @@ const Onboarding = () => {
       const response = await api.getOnboardingState(
         "<token>",
         {},
-        { project_id: project_id }
+        { project_id }
       );
 
       if (response.data) {
@@ -62,11 +63,11 @@ const Onboarding = () => {
           "<token>",
           {},
           {
-            project_id: project_id,
+            project_id,
             registry_connection_id: odata.registry_connection_id,
           }
         );
-        //console.log(response);
+        // console.log(response);
         if (response.data) {
           registry_connection_data = response.data;
         }
@@ -81,7 +82,7 @@ const Onboarding = () => {
           "<token>",
           {},
           {
-            project_id: project_id,
+            project_id,
             registry_infra_id: odata.registry_infra_id,
           }
         );
@@ -153,11 +154,13 @@ const Onboarding = () => {
   }, [context?.currentProject?.id]);
 
   const renderOnboarding = () => {
-    if (context?.currentProject?.capi_provisioner_enabled) {
+    if (context?.currentProject?.simplified_view_enabled && context?.currentProject?.capi_provisioner_enabled && context?.currentProject?.beta_features_enabled) {
+      return <CreateClusterForm />;
+  } else if (context?.currentProject?.capi_provisioner_enabled) {
       return (
         <Wrapper>
           <DashboardHeader
-            image={lightning}
+            image={bolt}
             title="Getting started"
             description="Select your existing cloud provider to get started with Porter."
             disableLineBreak

+ 29 - 15
dashboard/src/main/home/sidebar/ClusterList.tsx

@@ -105,17 +105,23 @@ const ClusterList: React.FC = (props) => {
       <>
         <Dropdown>
           {renderOptionList()}
-          {currentProject?.enable_reprovision && (
-            <OptionDiv
-              selected={false}
-              onClick={() => {
+          <OptionDiv
+            selected={false}
+            onClick={() => {
+              setExpanded(false);
+              if (
+                currentProject?.simplified_view_enabled &&
+                currentProject?.capi_provisioner_enabled &&
+                currentProject?.beta_features_enabled
+              ) {
+                pushFiltered(props, "/infrastructure/new", []);
+              } else {
                 setClusterModalVisible(true);
-                setExpanded(false);
-              }}
-            >
-              <Plus>+</Plus> Deploy new cluster
-            </OptionDiv>
-          )}
+              }
+            }}
+          >
+            <Plus>+</Plus> Deploy new cluster
+          </OptionDiv>
         </Dropdown>
       </>
     );
@@ -157,9 +163,17 @@ const ClusterList: React.FC = (props) => {
   return (
     <InitializeButton
       onClick={() => {
-        pushFiltered(props, "/new-cluster", ["cluster_id"], {
-          new_cluster: true,
-        });
+        if (
+          currentProject?.simplified_view_enabled &&
+          currentProject?.capi_provisioner_enabled &&
+          currentProject?.beta_features_enabled
+        ) {
+          pushFiltered(props, "/infrastructure/new", []);
+        } else {
+          pushFiltered(props, "/new-cluster", ["cluster_id"], {
+            new_cluster: true,
+          });
+        }
       }}
     >
       <Plus>+</Plus> Create a cluster
@@ -274,7 +288,7 @@ const MainSelector = styled.div`
     justify-content: center;
     border-radius: 20px;
     background: ${(props: { expanded: boolean }) =>
-    props.expanded ? "#ffffff22" : ""};
+      props.expanded ? "#ffffff22" : ""};
   }
 `;
 
@@ -302,7 +316,7 @@ const NavButton = styled(SidebarLink)`
 
   :hover {
     background: ${(props: NavButtonProps) =>
-    props.active ? "#ffffff11" : "#ffffff08"};
+      props.active ? "#ffffff11" : "#ffffff08"};
   }
 
   &.active {

+ 0 - 1
dashboard/src/main/home/sidebar/ProvisionClusterModal.tsx

@@ -63,7 +63,6 @@ const ProvisionClusterModal: React.FC<Props> = ({
           {gpuModal ? (
             <>
               <ClusterRevisionSelector
-                selectedClusterVersion={selectedClusterVersion}
                 setSelectedClusterVersion={setSelectedClusterVersion}
                 setShowProvisionerStatus={setShowProvisionerStatus}
                 setProvisionFailureReason={setProvisionFailureReason}

+ 52 - 24
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -9,7 +9,6 @@ import Text from "components/porter/Text";
 
 import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { Context } from "shared/Context";
-import { overrideInfraTabEnabled } from "utils/infrastructure";
 import addOns from "assets/add-ons.svg";
 import applications from "assets/applications.svg";
 import category from "assets/category.svg";
@@ -131,14 +130,6 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          {currentProject?.managed_infra_enabled &&
-            (user?.isPorterUser ||
-              overrideInfraTabEnabled({ projectID: currentProject.id })) && (
-              <NavButton path={"/infrastructure"}>
-                <i className="material-icons">build_circle</i>
-                Infrastructure
-              </NavButton>
-            )}
           {this.props.isAuthorized("integrations", "", [
             "get",
             "create",
@@ -215,6 +206,24 @@ class Sidebar extends Component<PropsType, StateType> {
                   !currentProject.db_enabled) && <Image size={15} src={lock} />}
               </Container>
             </NavButton>
+            {this.props.isAuthorized("settings", "", [
+              "get",
+              "update",
+              "delete",
+            ]) &&
+              currentProject?.simplified_view_enabled &&
+              currentProject?.capi_provisioner_enabled &&
+              currentProject?.beta_features_enabled && (
+                <NavButton
+                  path={"/infrastructure"}
+                  active={window.location.pathname.startsWith(
+                    "/infrastructure"
+                  )}
+                >
+                  <Img src={infra} />
+                  Infrastructure
+                </NavButton>
+              )}
             {currentCluster && (
               <>
                 <Spacer y={0.5} />
@@ -237,7 +246,9 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/environment-groups"
-              active={window.location.pathname.startsWith("/environment-groups")}
+              active={window.location.pathname.startsWith(
+                "/environment-groups"
+              )}
             >
               <Img src={sliders} />
               Env groups
@@ -246,17 +257,22 @@ class Sidebar extends Component<PropsType, StateType> {
               "get",
               "update",
               "delete",
-            ]) && (
-              <NavButton
-                path={"/cluster-dashboard"}
-                active={window.location.pathname.startsWith(
-                  "/cluster-dashboard"
-                )}
-              >
-                <Img src={settings} />
-                Infrastructure
-              </NavButton>
-            )}
+            ]) &&
+              !(
+                currentProject?.simplified_view_enabled &&
+                currentProject?.capi_provisioner_enabled &&
+                currentProject?.beta_features_enabled
+              ) && (
+                <NavButton
+                  path={"/cluster-dashboard"}
+                  active={window.location.pathname.startsWith(
+                    "/cluster-dashboard"
+                  )}
+                >
+                  <Img src={infra} />
+                  Infrastructure
+                </NavButton>
+              )}
             <NavButton path="/preview-environments">
               <Container row spaced style={{ width: "100%" }}>
                 <Container row>
@@ -312,7 +328,9 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/environment-groups"
-              active={window.location.pathname.startsWith("/environment-groups")}
+              active={window.location.pathname.startsWith(
+                "/environment-groups"
+              )}
             >
               <Img src={sliders} />
               Env groups
@@ -323,9 +341,19 @@ class Sidebar extends Component<PropsType, StateType> {
               "delete",
             ]) && (
               <NavButton
-                path={"/cluster-dashboard"}
+                path={
+                  currentProject?.simplified_view_enabled &&
+                  currentProject?.capi_provisioner_enabled &&
+                  currentProject?.beta_features_enabled
+                    ? "/infrastructure"
+                    : "/cluster-dashboard"
+                }
                 active={window.location.pathname.startsWith(
-                  "/cluster-dashboard"
+                  currentProject?.simplified_view_enabled &&
+                    currentProject?.capi_provisioner_enabled &&
+                    currentProject?.beta_features_enabled
+                    ? "/infrastructure"
+                    : "/cluster-dashboard"
                 )}
               >
                 <Img src={infra} />

+ 2 - 2
dashboard/src/shared/DeploymentTargetContext.tsx

@@ -103,7 +103,7 @@ const DeploymentTargetProvider = ({
 
   const { data: cluster, isSuccess } = useQuery(
     [
-      "getCluster",
+      "getDeploymentTargetCluster",
       {
         project_id: currentProject?.id,
         cluster_id: deploymentTarget?.cluster_id,
@@ -132,7 +132,7 @@ const DeploymentTargetProvider = ({
   );
 
   useEffect(() => {
-    if (isSuccess && cluster.id !== currentCluster?.id) {
+    if (cluster && cluster.id !== currentCluster?.id && setCurrentCluster) {
       setCurrentCluster(cluster);
     }
   }, [isSuccess, cluster, setCurrentCluster]);

+ 6 - 6
dashboard/src/shared/api.tsx

@@ -1541,12 +1541,12 @@ const createContract = baseApi<Contract, { project_id: number }>(
   }
 );
 
-const getContracts = baseApi<{ cluster_id?: number, latest?: boolean }, { project_id: number }>(
-  "GET",
-  ({ project_id }) => {
-    return `/api/projects/${project_id}/contracts`;
-  }
-);
+const getContracts = baseApi<
+  { cluster_id?: number; latest?: boolean },
+  { project_id: number }
+>("GET", ({ project_id }) => {
+  return `/api/projects/${project_id}/contracts`;
+});
 
 const deleteContract = baseApi<{}, { project_id: number; revision_id: string }>(
   "DELETE",

+ 1 - 0
dashboard/src/shared/themes/standard.ts

@@ -8,6 +8,7 @@ const theme = {
   },
   text: {
     primary: "#fefefe",
+    secondary: "#9999aa",
   },
 }
 

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

@@ -23,8 +23,8 @@ export type ClusterType = {
   name: string;
   vanity_name?: string;
   server: string;
-  service_account_id: number;
-  agent_integration_enabled: boolean;
+  service_account_id?: number;
+  agent_integration_enabled?: boolean;
   infra_id?: number;
   service?: string;
   aws_integration_id?: number;
@@ -311,6 +311,7 @@ export type ProjectListType = {
 export type ProjectType = {
   id: number;
   name: string;
+  advanced_infra_enabled: boolean;
   api_tokens_enabled: boolean;
   azure_enabled: boolean;
   beta_features_enabled: boolean;

+ 0 - 11
dashboard/src/utils/infrastructure.tsx

@@ -1,11 +0,0 @@
-interface OverrideInfraTabEnabledProps {
-  projectID: number;
-}
-
-export const overrideInfraTabEnabled = ({
-  projectID,
-}: OverrideInfraTabEnabledProps) => {
-  const ALLOWED_PROJECTS = [6638];
-
-  return ALLOWED_PROJECTS.some((id) => id === projectID);
-};

+ 10 - 1
internal/models/project.go

@@ -78,6 +78,9 @@ const (
 
 	// ManagedDeploymentTargetsEnabled controls whether a project can use managed deployment targets
 	ManagedDeploymentTargetsEnabled FeatureFlagLabel = "managed_deployment_targets_enabled"
+
+	// AdvancedInfraEnabled controls whether a project can use advanced infrastructure settings
+	AdvancedInfraEnabled FeatureFlagLabel = "advanced_infra_enabled"
 )
 
 // ProjectFeatureFlags keeps track of all project-related feature flags
@@ -103,6 +106,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	StacksEnabled:                   false,
 	ValidateApplyV2:                 true,
 	ManagedDeploymentTargetsEnabled: false,
+	AdvancedInfraEnabled:            false,
 }
 
 type ProjectPlan string
@@ -194,7 +198,8 @@ type Project struct {
 	ValidateApplyV2 bool `gorm:"default:false"`
 	// Deprecated: use p.GetFeatureFlag(EnableReprovision, *features.Client) instead
 
-	EnableReprovision bool `gorm:"default:false"`
+	EnableReprovision    bool `gorm:"default:false"`
+	AdvancedInfraEnabled bool `gorm:"default:false"`
 }
 
 // GetFeatureFlag calls launchdarkly for the specified flag
@@ -241,6 +246,8 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return false
 		case "aws_ack_auth_enabled":
 			return false
+		case "advanced_infra_enabled":
+			return false
 		}
 	}
 
@@ -289,6 +296,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		StacksEnabled:                   p.GetFeatureFlag(StacksEnabled, launchDarklyClient),
 		ValidateApplyV2:                 p.GetFeatureFlag(ValidateApplyV2, launchDarklyClient),
 		ManagedDeploymentTargetsEnabled: p.GetFeatureFlag(ManagedDeploymentTargetsEnabled, launchDarklyClient),
+		AdvancedInfraEnabled:            p.GetFeatureFlag(AdvancedInfraEnabled, launchDarklyClient),
 	}
 }
 
@@ -323,6 +331,7 @@ func (p *Project) ToProjectListType() *types.ProjectList {
 		EnableReprovision:      p.EnableReprovision,
 		ValidateApplyV2:        p.ValidateApplyV2,
 		FullAddOns:             p.FullAddOns,
+		AdvancedInfraEnabled:   p.AdvancedInfraEnabled,
 	}
 }