sdess09 před 2 roky
rodič
revize
30415cbba5

+ 1 - 1
api/server/handlers/project_integration/preflight_check.go

@@ -21,7 +21,7 @@ type CreatePreflightCheckHandler struct {
 	handlers.PorterHandlerReadWriter
 }
 
-// NewCreatePreflightCheckHandler Create Preflight Checks
+// NewCreatePreflightCheckHandler Create Preflight Checks with /integrations/preflightcheck
 func NewCreatePreflightCheckHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,

+ 1 - 0
api/types/project.go

@@ -36,6 +36,7 @@ type Project struct {
 	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
 	DBEnabled              bool    `json:"db_enabled"`
 	EFSEnabled             bool    `json:"efs_enabled"`
+	GPUEnabled             bool    `json:"gpu_enabled"`
 	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 	AzureEnabled           bool    `json:"azure_enabled"`
 	HelmValuesEnabled      bool    `json:"helm_values_enabled"`

+ 3 - 0
dashboard/src/assets/computer-chip.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.5 15.4167H12.5M7.5 15.4167C5.88917 15.4167 4.58333 14.1108 4.58333 12.5M7.5 15.4167L7.5 17.5M4.58333 12.5V7.5M4.58333 12.5H2.5M15.4167 12.5V7.5M15.4167 12.5C15.4167 14.1108 14.1108 15.4167 12.5 15.4167M15.4167 12.5L17.5 12.5M12.5 15.4167V17.5M12.5 4.58333H7.5M12.5 4.58333C14.1108 4.58333 15.4167 5.88917 15.4167 7.5M12.5 4.58333L12.5 2.5M15.4167 7.5L17.5 7.5M7.5 4.58333C5.88917 4.58333 4.58333 5.88917 4.58333 7.5M7.5 4.58333L7.5 2.5M4.58333 7.5L2.5 7.5M17.5 10H15.4167M4.58333 10H2.5M10 2.5V4.58333M10 15.4167V17.5M8.75 12.5H11.25C11.9404 12.5 12.5 11.9404 12.5 11.25V8.75C12.5 8.05964 11.9404 7.5 11.25 7.5H8.75C8.05964 7.5 7.5 8.05964 7.5 8.75V11.25C7.5 11.9404 8.05964 12.5 8.75 12.5Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/lightning.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.28 2.3999L3.67999 13.9199H12L11.36 21.5999L20.32 10.0799H12L13.28 3.0399" stroke="#3a48ca" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 3
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -550,7 +550,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
             options={locationOptions}
             width="350px"
-            disabled={true}
+            disabled={isReadOnly || isLoading}
             value={region}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
@@ -561,7 +561,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
             options={clusterVersionOptions}
             width="350px"
-            disabled={true}
+            disabled={isReadOnly}
             value={clusterVersion}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
@@ -572,7 +572,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
             options={instanceTypes}
             width="350px"
-            disabled={true}
+            disabled={isReadOnly}
             value={instanceType}
             scrollBuffer={true}
             dropdownMaxHeight="240px"

+ 126 - 0
dashboard/src/components/GPUCostConsent.tsx

@@ -0,0 +1,126 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Modal from "./porter/Modal";
+import Text from "./porter/Text";
+import Spacer from "./porter/Spacer";
+import Fieldset from "./porter/Fieldset";
+import Button from "./porter/Button";
+import ExpandableSection from "./porter/ExpandableSection";
+import Input from "./porter/Input";
+import Link from "./porter/Link";
+
+type Props = {
+  setCurrentStep: (step: string) => void;
+  markCostConsentComplete: () => void;
+};
+
+const GPUCostConsent: React.FC<Props> = ({
+  setCurrentStep,
+  markCostConsentComplete,
+}) => {
+  const [confirmCost, setConfirmCost] = useState("");
+
+  return (
+    <>
+
+      <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={<Cost>$224.58 / mo</Cost>}
+        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 ("224.58") below to
+        proceed:
+      </Text>
+      <Spacer y={1} />
+      <Input
+        placeholder="224.58"
+        value={confirmCost}
+        setValue={setConfirmCost}
+        width="100%"
+        height="40px"
+      />
+      <Spacer y={1} />
+      <Button
+        disabled={confirmCost !== "224.58"}
+        onClick={() => {
+          setConfirmCost("");
+          markCostConsentComplete();
+          setCurrentStep("credentials");
+        }}
+      >
+        Continue
+      </Button>
+
+    </>
+  );
+};
+
+export default GPUCostConsent;
+
+const Cost = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+`;
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;

+ 234 - 0
dashboard/src/components/GPUProvisionSettings.tsx

@@ -0,0 +1,234 @@
+import React, { useContext, useState } from "react";
+import {
+  type EKSPreflightValues,
+} from "@porter-dev/api-contracts";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
+
+import Heading from "components/form-components/Heading";
+
+import { type ClusterState } from "shared/types";
+
+import healthy from "assets/status-healthy.png";
+
+import Button from "./porter/Button";
+
+import Select from "./porter/Select";
+import Spacer from "./porter/Spacer";
+import Text from "./porter/Text";
+import VerticalSteps from "./porter/VerticalSteps";
+import PreflightChecks from "./PreflightChecks";
+import InputSlider from "./porter/InputSlider";
+import { Context } from "shared/Context";
+
+
+const gpuMachineTypeOptions = [
+
+  { value: "g4dn.xlarge", label: "g4dn.xlarge" },
+  { value: "g4dn.2xlarge", label: "g4dn.2xlarge" },
+];
+
+
+type Props = RouteComponentProps & {
+  handleClusterStateChange: <K extends keyof ClusterState>(key: K, value: ClusterState[K]) => void;
+  clusterState: ClusterState;
+  isReadOnly: boolean;
+  isLoading: boolean;
+  preflightData: EKSPreflightValues | null;
+  preflightError: string | undefined;
+  preflightFailed: boolean;
+  showHelpMessage: boolean;
+  showEmailMessage: boolean;
+  proceedToProvision: () => void;
+  getStatus: () => React.ReactNode;
+  createCluster: () => void;
+  preflightChecks: () => void;
+  dismissPreflight: () => void;
+  requestQuotasAndProvision: () => void;
+};
+
+const GPUProvisionerSettings: React.FC<Props> = ({
+  handleClusterStateChange,
+  isReadOnly,
+  clusterState,
+  preflightChecks,
+  isLoading,
+  preflightData,
+  createCluster,
+  preflightError,
+  preflightFailed,
+  showEmailMessage,
+  showHelpMessage,
+  proceedToProvision,
+  dismissPreflight,
+  getStatus,
+  requestQuotasAndProvision,
+
+}) => {
+  const [gpuStep, setGPUStep] = useState(0);
+  const {
+    currentProject,
+  } = useContext(Context);
+
+  const renderGPUSettings = (): JSX.Element => {
+    return (
+      <VerticalSteps
+        currentStep={gpuStep}
+        onlyShowCurrentStep={true}
+        steps={[
+          <>
+            <Heading isAtTop> Select GPU Instance Type </Heading>
+            <Spacer y={.5} />
+            <Select
+              options={gpuMachineTypeOptions}
+              width="350px"
+              disabled={isReadOnly}
+              value={clusterState.gpuInstanceType}
+              setValue={(x: string) => {
+                handleClusterStateChange("gpuInstanceType", x)
+                // handleClusterStateChange("machineType", x)
+              }
+              }
+              label="Machine type"
+            />
+            <Spacer y={1} />
+            <InputSlider
+              label="Max Instances: "
+              unit="nodes"
+              min={0}
+              max={5}
+              step={1}
+              width="350px"
+              disabled={isReadOnly || isLoading}
+              value={clusterState.gpuMaxInstances.toString()}
+              setValue={(x: number) => {
+                handleClusterStateChange("gpuMaxInstances", x)
+
+              }}
+            />
+            <Button onClick={() => {
+              setGPUStep(1)
+              preflightChecks();
+            }}>
+              Continue
+            </Button>
+
+            <Spacer y={.5} />
+          </>,
+          <>
+            {showEmailMessage ?
+              <>
+                <CheckItemContainer>
+                  <CheckItemTop>
+                    <StatusIcon src={healthy} />
+                    <Spacer inline x={1} />
+                    <Text style={{ marginLeft: '10px', flex: 1 }}>{"Porter will request to increase quotas when you provision"}</Text>
+                  </CheckItemTop>
+                </CheckItemContainer>
+
+              </> :
+              <>
+                <PreflightChecks provider='AWS' preflightData={preflightData} error={preflightError} />
+                <Spacer y={.5} />
+                {(preflightFailed && preflightData) &&
+                  <>
+                    {(showHelpMessage && currentProject?.quota_increase) ? <>
+                      <Text color="helper">
+                        Your account currently is blocked from provisioning in {clusterState.awsRegion} due to a quota limit imposed by AWS. Either change the region or request to increase quotas.
+                      </Text>
+                      <Spacer y={.5} />
+                      <Text color="helper">
+                        Porter can automatically request quota increases on your behalf and email you once the cluster is provisioned.
+                      </Text>
+                      <Spacer y={.5} />
+                      <div style={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center', gap: '15px' }}>
+                        <Button
+                          disabled={isLoading}
+                          onClick={proceedToProvision}
+
+                        >
+                          Auto request increase
+                        </Button>
+                        <Button
+                          disabled={isLoading}
+                          onClick={dismissPreflight}
+                          color="#313539"
+                        >
+                          I'll do it myself
+                        </Button>
+                      </div>
+
+                    </> : (
+                      <><Text color="helper">
+                        Your account currently is blocked from provisioning in {clusterState.awsRegion} due to a quota limit imposed by AWS. Either change the region or request to increase quotas.
+                      </Text><Spacer y={.5} /><Button
+                        disabled={isLoading}
+                        onClick={preflightChecks}
+
+                      >
+                          Retry checks
+                        </Button></>)}
+                  </>}
+              </>}
+
+            <Spacer y={1} />
+            {showEmailMessage && <>
+              <Text color="helper">
+                After your quota requests have been approved by AWS, Porter will email you when your cluster has been provisioned.
+              </Text>
+              <Spacer y={1} />
+            </>}
+            <StepChangeButtonsContainer>
+              <Button
+                disabled={(preflightFailed && !showEmailMessage) || isLoading}
+                onClick={showEmailMessage ? requestQuotasAndProvision : createCluster}
+                status={getStatus()}
+              >
+                Provision
+              </Button>
+              <Spacer inline x={0.5} />
+              <Button onClick={() => { setGPUStep(0); }} color="#222222">Back</Button>
+            </StepChangeButtonsContainer>
+            <Spacer y={1} /></>,
+
+        ].filter((x) => x)}
+      />
+    );
+  };
+  return (
+    <>
+      {renderGPUSettings()}
+    </>
+  );
+};
+
+export default withRouter(GPUProvisionerSettings);
+
+
+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: ${(props) => (props.hasMessage ? "pointer" : "default")};
+  background: ${(props) => props.theme.clickable.bg};
+`;
+
+const CheckItemTop = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background: ${(props) => props.theme.clickable.bg};
+`;
+
+const StatusIcon = styled.img`
+  height: 14px;
+`;
+
+const StepChangeButtonsContainer = styled.div`
+  display: flex;
+`;

+ 204 - 198
dashboard/src/components/ProvisionerSettings.tsx

@@ -27,7 +27,7 @@ import { useIntercom } from "lib/hooks/useIntercom";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { pushFiltered } from "shared/routing";
-import { type ClusterType } from "shared/types";
+import { type ClusterType, type ClusterState } from "shared/types";
 import { PREFLIGHT_TO_ENUM } from "shared/util";
 import info from "assets/info-outlined.svg";
 import healthy from "assets/status-healthy.png";
@@ -43,27 +43,10 @@ import Text from "./porter/Text";
 import Tooltip from "./porter/Tooltip";
 import VerticalSteps from "./porter/VerticalSteps";
 import PreflightChecks from "./PreflightChecks";
+import { Integer } from "type-fest";
+import InputSlider from "./porter/InputSlider";
+import GPUProvisionSettings from "./GPUProvisionSettings";
 
-type ClusterState = {
-  clusterName: string;
-  awsRegion: string;
-  machineType: string;
-  guardDutyEnabled: boolean;
-  kmsEncryptionEnabled: boolean;
-  loadBalancerType: boolean;
-  wildCardDomain: string;
-  IPAllowList: string;
-  wafV2Enabled: boolean;
-  awsTags: string;
-  wafV2ARN: string;
-  certificateARN: string;
-  minInstances: number;
-  maxInstances: number;
-  additionalNodePolicies: string[];
-  cidrRangeVPC: string;
-  cidrRangeServices: string;
-  clusterVersion: string;
-};
 
 const regionOptions = [
   { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
@@ -116,7 +99,6 @@ const machineTypeOptions = [
   { value: "r6i.16xlarge", label: "r6i.16xlarge" },
   { value: "r6i.24xlarge", label: "r6i.24xlarge" },
   { value: "r6i.32xlarge", label: "r6i.32xlarge" },
-  { value: "g4dn.xlarge", label: "g4dn.xlarge" },
   { value: "m5n.large", label: "m5n.large" },
   { value: "m5n.xlarge", label: "m5n.xlarge" },
   { value: "m5n.2xlarge", label: "m5n.2xlarge" },
@@ -145,14 +127,18 @@ const initialClusterState: ClusterState = {
   cidrRangeVPC: defaultCidrVpc,
   cidrRangeServices: defaultCidrServices,
   clusterVersion: defaultClusterVersion,
+  gpuInstanceType: "g4dn.xlarge",
+  gpuMinInstances: 0,
+  gpuMaxInstances: 5,
 };
 
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
   provisionerError?: string;
   credentialId: string;
-  clusterId?: number;
+  clusterId?: number | null;
   closeModal?: () => void;
+  gpuModal?: boolean;
 };
 
 const ProvisionerSettings: React.FC<Props> = (props) => {
@@ -164,7 +150,6 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     setShouldRefreshClusters,
   } = useContext(Context);
   const [step, setStep] = useState(0);
-
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [isClicked, setIsClicked] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
@@ -201,10 +186,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     } catch (err) {}
   };
 
-  const getStatus = ():
-    | JSX.Element
-    | "Provisioning is still in progress..."
-    | undefined => {
+  const getStatus = (): React.ReactNode => {
     if (isLoading) {
       return <Loading />;
     }
@@ -230,7 +212,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       return false;
     }
     // Split the input string by comma and remove any empty elements
-    const ipAddresses = IPAllowList.split(",").filter(Boolean);
+    const ipAddresses = IPAllowList?.split(",").filter(Boolean);
     // Validate each IP address
     for (const ip of ipAddresses) {
       if (!regex.test(ip.trim())) {
@@ -260,6 +242,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     return !clusterState.clusterName;
   };
   const userProvisioning = (): boolean => {
+    // If the cluster is updating or updating unavailabe but there are no errors do not allow re-provisioning
     return isReadOnly && props.provisionerError === "";
   };
 
@@ -277,19 +260,17 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     }
 
     // Split the input string by comma, then reduce the resulting array to an object
-    const tags = tagString
-      .split(",")
-      .reduce<Record<string, string>>((obj, item) => {
-        // Split each item by "=", and trim whitespace from both key and value
-        const [key, value] = item.split("=").map((part) => part.trim());
-
-        // Only add the key-value pair to the object if both key and value are present
-        if (key && value) {
-          obj[key] = value;
-        }
+    const tags = tagString.split(",").reduce<Record<string, string>>((obj, item) => {
+      // Split each item by "=", and trim whitespace from both key and value
+      const [key, value] = item.split("=").map(part => part.trim());
 
-        return obj;
-      }, {});
+      // Only add the key-value pair to the object if both key and value are present
+      if (key && value) {
+        obj[key] = value;
+      }
+
+      return obj;
+    }, {});
 
     return tags;
   }
@@ -329,6 +310,47 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       }
     }
 
+
+    const nodeGroups = [
+      new EKSNodeGroup({
+        instanceType: "t3.medium",
+        minInstances: 1,
+        maxInstances: 5,
+        nodeGroupType: NodeGroupType.SYSTEM,
+        isStateful: false,
+        additionalPolicies: clusterState.additionalNodePolicies,
+      }),
+      new EKSNodeGroup({
+        instanceType: "t3.large",
+        minInstances: 1,
+        maxInstances: 1,
+        nodeGroupType: NodeGroupType.MONITORING,
+        isStateful: true,
+        additionalPolicies: clusterState.additionalNodePolicies,
+      }),
+      new EKSNodeGroup({
+        instanceType: clusterState.machineType,
+        minInstances: clusterState.minInstances || 1,
+        maxInstances: clusterState.maxInstances || 10,
+        nodeGroupType: NodeGroupType.APPLICATION,
+        isStateful: false,
+        additionalPolicies: clusterState.additionalNodePolicies,
+      }),
+    ];
+
+    // Conditionally add the last EKSNodeGroup if gpuModal is enabled
+    if (props.gpuModal) {
+      nodeGroups.push(new EKSNodeGroup({
+        instanceType: clusterState.gpuInstanceType,
+        minInstances: clusterState.gpuMinInstances || 0,
+        maxInstances: clusterState.gpuMaxInstances || 5,
+        nodeGroupType: NodeGroupType.CUSTOM,
+        isStateful: false,
+        additionalPolicies: clusterState.additionalNodePolicies,
+      }));
+    }
+
+
     const data = new Contract({
       cluster: new Cluster({
         projectId: currentProject.id,
@@ -339,8 +361,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
           case: "eksKind",
           value: new EKS({
             clusterName: clusterState.clusterName,
-            clusterVersion:
-              clusterState.clusterVersion || defaultClusterVersion,
+            clusterVersion: clusterState.clusterVersion || defaultClusterVersion,
             cidrRange: clusterState.cidrRangeVPC || defaultCidrVpc, // deprecated in favour of network.cidrRangeVPC: can be removed after december 2023
             region: clusterState.awsRegion,
             loadBalancer: loadBalancerObj,
@@ -349,35 +370,9 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
             enableKmsEncryption: clusterState.kmsEncryptionEnabled,
             network: new AWSClusterNetwork({
               vpcCidr: clusterState.cidrRangeVPC || defaultCidrVpc,
-              serviceCidr:
-                clusterState.cidrRangeServices || defaultCidrServices,
+              serviceCidr: clusterState.cidrRangeServices || defaultCidrServices,
             }),
-            nodeGroups: [
-              new EKSNodeGroup({
-                instanceType: "t3.medium",
-                minInstances: 1,
-                maxInstances: 5,
-                nodeGroupType: NodeGroupType.SYSTEM,
-                isStateful: false,
-                additionalPolicies: clusterState.additionalNodePolicies,
-              }),
-              new EKSNodeGroup({
-                instanceType: "t3.large",
-                minInstances: 1,
-                maxInstances: 1,
-                nodeGroupType: NodeGroupType.MONITORING,
-                isStateful: true,
-                additionalPolicies: clusterState.additionalNodePolicies,
-              }),
-              new EKSNodeGroup({
-                instanceType: clusterState.machineType,
-                minInstances: clusterState.minInstances || 1,
-                maxInstances: clusterState.maxInstances || 10,
-                nodeGroupType: NodeGroupType.APPLICATION,
-                isStateful: false,
-                additionalPolicies: clusterState.additionalNodePolicies,
-              }),
-            ],
+            nodeGroups,
           }),
         },
       }),
@@ -418,11 +413,19 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
               // setHasFinishedOnboarding(true);
               setCurrentCluster(cluster);
               OFState.actions.goTo("clean_up");
-              pushFiltered(props, "/cluster-dashboard", ["project_id"], {
-                cluster: cluster.name,
-              });
+              if (!props.gpuModal) {
+                pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              }
+              else {
+                if (props.closeModal) {
+                  props.closeModal();
+                }
+              }
             }
           });
+
         })
         .catch((err) => {
           if (err) {
@@ -467,6 +470,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   useEffect(() => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const contract = props.selectedClusterVersion as any;
+    // Unmarshall Contract here
     if (contract?.cluster) {
       const eksValues: EKS = contract.cluster?.eksKind as EKS;
       if (eksValues == null) {
@@ -552,18 +556,21 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
 
   useEffect(() => {
     if (!props.clusterId) {
-      setStep(1);
-      try {
-        // eslint-disable-next-line @typescript-eslint/no-floating-promises
-        preflightChecks();
-        // Handle the resolved value if necessary
-      } catch (error) {
-        if (error) {
-          setStep(0);
+      if (clusterState.clusterName !== "") {
+        setStep(1);
+        try {
+          // eslint-disable-next-line @typescript-eslint/no-floating-promises
+          preflightChecks();
+          // Handle the resolved value if necessary
+        } catch (error) {
+          if (error) {
+            setStep(0);
+          }
         }
+
       }
     }
-  }, [props.selectedClusterVersion, clusterState]);
+  }, [clusterState]);
 
   const proceedToProvision = async (): Promise<void> => {
     setShowEmailMessage(true);
@@ -672,6 +679,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   handleClusterStateChange("clusterVersion", x);
                 }}
                 label="Cluster version (only shown to porter.run emails)"
+                placeholder={""}
               />
             )}
             <Spacer y={1} />
@@ -1145,89 +1153,79 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     } catch (err) {}
   };
 
+
   const renderForm = (): JSX.Element => {
     // Render simplified form if initial create
     if (!props.clusterId) {
       return (
-        <VerticalSteps
-          currentStep={step}
-          steps={[
-            <>
-              <Text size={16}>Select an AWS region</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Porter will automatically provision your infrastructure in the
-                specified region.
-              </Text>
-              <Spacer height="10px" />
-              <SelectRow
-                options={regionOptions}
-                width="350px"
-                disabled={isReadOnly || isLoading}
-                value={clusterState.awsRegion}
-                scrollBuffer={true}
-                dropdownMaxHeight="240px"
-                setActiveValue={(x: string) => {
-                  handleClusterStateChange("awsRegion", x);
-                }}
-                label="📍 AWS region"
-              />
+        <>
+          <VerticalSteps
+            currentStep={step}
+            steps={[
               <>
-                {(user?.isPorterUser || currentProject?.multi_cluster) &&
-                  renderAdvancedSettings()}
-              </>
-            </>,
-            <>
-              {showEmailMessage ? (
+                <Text size={16}>Select an AWS region</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Porter will automatically provision your infrastructure in the
+                  specified region.
+                </Text>
+                <Spacer height="10px" />
+                <SelectRow
+                  options={regionOptions}
+                  width="350px"
+                  disabled={isReadOnly || isLoading}
+                  value={clusterState.awsRegion}
+                  scrollBuffer={true}
+                  dropdownMaxHeight="240px"
+                  setActiveValue={(x: string) => {
+                    handleClusterStateChange("awsRegion", x);
+                  }}
+                  label="📍 AWS region"
+                />
                 <>
-                  <CheckItemContainer>
-                    <CheckItemTop>
-                      <StatusIcon src={healthy} />
-                      <Spacer inline x={1} />
-                      <Text style={{ marginLeft: "10px", flex: 1 }}>
-                        {
-                          "Porter will request to increase quotas when you provision"
-                        }
-                      </Text>
-                    </CheckItemTop>
-                  </CheckItemContainer>
+                  {(user?.isPorterUser || currentProject?.multi_cluster) &&
+                    renderAdvancedSettings()}
                 </>
-              ) : (
-                <>
-                  <PreflightChecks
-                    provider="AWS"
-                    preflightData={preflightData}
-                    error={preflightError}
-                  />
-                  <Spacer y={0.5} />
-                  {preflightFailed && preflightData && (
-                    <>
-                      {showHelpMessage && currentProject?.quota_increase ? (
-                        <>
+              </>,
+              <>
+                {showEmailMessage ? (
+                  <>
+                    <CheckItemContainer>
+                      <CheckItemTop>
+                        <StatusIcon src={healthy} />
+                        <Spacer inline x={1} />
+                        <Text style={{ marginLeft: "10px", flex: 1 }}>
+                          {
+                            "Porter will request to increase quotas when you provision"
+                          }
+                        </Text>
+                      </CheckItemTop>
+                    </CheckItemContainer>
+
+                  </>) :
+                  <>
+                    <PreflightChecks
+                      provider="AWS"
+                      preflightData={preflightData}
+                      error={preflightError}
+                    />
+                    <Spacer y={0.5} />
+                    {preflightFailed && preflightData && (
+                      <>
+                        {(showHelpMessage && currentProject?.quota_increase) ? <>
                           <Text color="helper">
-                            Your account currently is blocked from provisioning
-                            in {clusterState.awsRegion} due to a quota limit
-                            imposed by AWS. Either change the region or request
-                            to increase quotas.
+                            Your account currently is blocked from provisioning in {clusterState.awsRegion} due to a quota limit imposed by AWS. Either change the region or request to increase quotas.
                           </Text>
-                          <Spacer y={0.5} />
+                          <Spacer y={.5} />
                           <Text color="helper">
-                            Porter can automatically request quota increases on
-                            your behalf and email you once the cluster is
-                            provisioned.
+                            Porter can automatically request quota increases on your behalf and email you once the cluster is provisioned.
                           </Text>
-                          <Spacer y={0.5} />
-                          <div
-                            style={{
-                              display: "flex",
-                              justifyContent: "flex-start",
-                              alignItems: "center",
-                              gap: "15px",
-                            }}
-                          >
+                          <Spacer y={.5} />
+                          <div style={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center', gap: '15px' }}>
                             <Button
                               disabled={isLoading}
                               onClick={proceedToProvision}
+
                             >
                               Auto request increase
                             </Button>
@@ -1236,63 +1234,71 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                               onClick={dismissPreflight}
                               color="#313539"
                             >
-                              {"I'll do it myself"}
+                              I'll do it myself
                             </Button>
                           </div>
-                        </>
-                      ) : (
-                        <>
-                          <Text color="helper">
-                            Your account currently is blocked from provisioning
-                            in {clusterState.awsRegion} due to a quota limit
-                            imposed by AWS. Either change the region or request
-                            to increase quotas.
-                          </Text>
-                          <Spacer y={0.5} />
-                          <Button
+
+                        </> : (
+                          <><Text color="helper">
+                            Your account currently is blocked from provisioning in {clusterState.awsRegion} due to a quota limit imposed by AWS. Either change the region or request to increase quotas.
+                          </Text><Spacer y={.5} /><Button
                             disabled={isLoading}
                             onClick={preflightChecks}
+
                           >
-                            Retry checks
-                          </Button>
-                        </>
-                      )}
-                    </>
-                  )}
-                </>
-              )}
-            </>,
-            <>
-              <Text size={16}>Provision your cluster</Text>
-              <Spacer y={1} />
-              {showEmailMessage && (
-                <>
+                              Retry checks
+                            </Button></>)}
+                      </>)}
+                  </>}
+              </>, <>
+                <Text size={16}>Provision your cluster</Text>
+                <Spacer y={1} />
+                {showEmailMessage && <>
                   <Text color="helper">
-                    After your quota requests have been approved by AWS, Porter
-                    will email you when your cluster has been provisioned.
+                    After your quota requests have been approved by AWS, Porter will email you when your cluster has been provisioned.
                   </Text>
                   <Spacer y={1} />
-                </>
-              )}
-              <Button
-                // disabled={isDisabled()}
-                // disabled={isDisabled() || preflightFailed || isLoading}
-                disabled={(preflightFailed && !showEmailMessage) ?? isLoading}
-                onClick={
-                  showEmailMessage ? requestQuotasAndProvision : createCluster
-                }
-                status={getStatus()}
-              >
-                Provision
-              </Button>
-              <Spacer y={1} />
-            </>,
-          ].filter((x) => x)}
-        />
-      );
+                </>}
+                <Button
+                  disabled={(preflightFailed && !showEmailMessage) || isLoading}
+                  onClick={showEmailMessage ? requestQuotasAndProvision : createCluster}
+                  status={getStatus()}
+                >
+                  Provision
+                </Button>
+                <Spacer y={1} /></>
+              ,
+
+            ].filter((x) => x)}
+          />
+        </>
+      )
     }
 
     // If settings, update full form
+    if (props.clusterId && props.gpuModal) {
+      return (
+        <GPUProvisionSettings
+          handleClusterStateChange={handleClusterStateChange}
+          clusterState={clusterState}
+          preflightChecks={preflightChecks}
+          isReadOnly={isReadOnly}
+          isLoading={isLoading}
+          createCluster={createCluster}
+          preflightData={preflightData}
+          preflightFailed={preflightFailed}
+          preflightError={preflightError}
+          proceedToProvision={proceedToProvision}
+          getStatus={getStatus}
+          dismissPreflight={dismissPreflight}
+          showHelpMessage={showHelpMessage}
+          showEmailMessage={showEmailMessage}
+          requestQuotasAndProvision={requestQuotaIncrease}
+        />
+      )
+    }
+
+
     return (
       <>
         <StyledForm>

+ 308 - 171
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -1,212 +1,349 @@
-import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
 import { useEffect, useState } from "react";
-import convert from "convert";
 import { useQuery } from "@tanstack/react-query";
+import convert from "convert";
 import { z } from "zod";
+
+import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
+
 import api from "shared/api";
 
 const DEFAULT_INSTANCE_CLASS = "t3";
 const DEFAULT_INSTANCE_SIZE = "medium";
 
-const clusterNodesValidator = z.object({
-    labels: z.object({
+type EncodedContract = {
+  ID: number;
+  CreatedAt: string;
+  UpdatedAt: string;
+  DeletedAt: string | null;
+  id: string;
+  base64_contract: string;
+  cluster_id: number;
+  project_id: number;
+  condition: string;
+  condition_metadata: Record<string, unknown>;
+};
+
+type NodeGroup = {
+  instanceType: string;
+  minInstances: number;
+  maxInstances: number;
+  nodeGroupType: string;
+  isStateful?: boolean;
+};
+
+type EksKind = {
+  clusterName: string;
+  clusterVersion: string;
+  cidrRange: string;
+  region: string;
+  nodeGroups: NodeGroup[];
+  loadBalancer: {
+    loadBalancerType: string;
+  };
+  logging: Record<string, unknown>;
+  network: {
+    vpcCidr: string;
+    serviceCidr: string;
+  };
+};
+
+type Cluster = {
+  projectId: number;
+  clusterId: number;
+  kind: string;
+  cloudProvider: string;
+  cloudProviderCredentialsId: string;
+  eksKind: EksKind;
+};
+
+type ContractData = {
+  cluster: Cluster;
+  user: {
+    id: number;
+  };
+};
+const clusterNodesValidator = z
+  .object({
+    labels: z
+      .object({
         "beta.kubernetes.io/instance-type": z.string().nullish(),
         "porter.run/workload-kind": z.string().nullish(),
-    }).optional(),
-}).transform((data) => {
+      })
+      .optional(),
+  })
+  .transform((data) => {
     const defaultResources = {
-        maxCPU: AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["vCPU"],
-        maxRAM: AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["RAM"],
-        instanceClass: DEFAULT_INSTANCE_CLASS,
-        instanceSize: DEFAULT_INSTANCE_SIZE,
+      maxCPU:
+        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU,
+      maxRAM:
+        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
+      instanceClass: DEFAULT_INSTANCE_CLASS,
+      instanceSize: DEFAULT_INSTANCE_SIZE,
     };
     if (!data.labels) {
-        return defaultResources;
+      return defaultResources;
     }
     const workloadKind = data.labels["porter.run/workload-kind"];
     if (!workloadKind || workloadKind !== "application") {
-        return defaultResources;
+      return defaultResources;
     }
     const instanceType = data.labels["beta.kubernetes.io/instance-type"];
-    const res = z.tuple([z.string(), z.string()]).safeParse(instanceType?.split("."))
+    const res = z
+      .tuple([z.string(), z.string()])
+      .safeParse(instanceType?.split("."));
     if (!res.success) {
-        return defaultResources;
+      return defaultResources;
     }
     const [instanceClass, instanceSize] = res.data;
-    if (AWS_INSTANCE_LIMITS[instanceClass] && AWS_INSTANCE_LIMITS[instanceClass][instanceSize]) {
-        const { vCPU, RAM } = AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
-        return {
-            maxCPU: vCPU,
-            maxRAM: RAM,
-            instanceClass,
-            instanceSize,
-        };
+    if (
+      AWS_INSTANCE_LIMITS[instanceClass] &&
+      AWS_INSTANCE_LIMITS[instanceClass][instanceSize]
+    ) {
+      const { vCPU, RAM } = AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
+      return {
+        maxCPU: vCPU,
+        maxRAM: RAM,
+        instanceClass,
+        instanceSize,
+      };
     }
     return defaultResources;
-});
+  });
 
-export const useClusterResourceLimits = (
-    {
-        projectId,
-        clusterId,
-    }: {
-        projectId: number | undefined,
-        clusterId: number | undefined,
-    }
-): {
-    maxCPU: number,
-    maxRAM: number,
-    // defaults indicate the resources assigned to new services
-    defaultCPU: number,
-    defaultRAM: number,
-    clusterContainsGPUNodes: boolean,
-    clusterIngressIp: string,
+export const useClusterResourceLimits = ({
+  projectId,
+  clusterId,
+  clusterStatus,
+}: {
+  projectId: number | undefined;
+  clusterId: number | undefined;
+  clusterStatus: string | undefined;
+}): {
+  maxCPU: number;
+  maxRAM: number;
+  // defaults indicate the resources assigned to new services
+  defaultCPU: number;
+  defaultRAM: number;
+  clusterContainsGPUNodes: boolean;
+  clusterIngressIp: string;
 } => {
-    const SMALL_INSTANCE_UPPER_BOUND = 0.75;
-    const LARGE_INSTANCE_UPPER_BOUND = 0.9;
-    const DEFAULT_MULTIPLIER = 0.125;
-    const [clusterContainsGPUNodes, setClusterContainsGPUNodes] = useState(false);
-    const [maxCPU, setMaxCPU] = useState(
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["vCPU"] * SMALL_INSTANCE_UPPER_BOUND
-    );
-    const [maxRAM, setMaxRAM] = useState(
-        // round to nearest 100
-        Math.round(
-            convert(AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["RAM"], "GiB").to("MB") *
-            SMALL_INSTANCE_UPPER_BOUND / 100
-        ) * 100
-    );
-    const [defaultCPU, setDefaultCPU] = useState(
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["vCPU"] * DEFAULT_MULTIPLIER
-    );
-    const [defaultRAM, setDefaultRAM] = useState(
-        // round to nearest 100
-        Math.round(
-            convert(AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE]["RAM"], "GiB").to("MB") *
-            DEFAULT_MULTIPLIER / 100
-        ) * 100
-    );
-    const [clusterIngressIp, setClusterIngressIp] = useState<string>("");
-
-    const getClusterNodes = useQuery(
-        ["getClusterNodes", projectId, clusterId],
-        async () => {
-            if (!projectId || !clusterId || clusterId === -1) {
-                return Promise.resolve([]);
-            }
-
-            const res = await api.getClusterNodes(
-                "<token>",
-                {},
-                {
-                    project_id: projectId,
-                    cluster_id: clusterId,
-                }
-            )
-
-            return await z.array(clusterNodesValidator).parseAsync(res.data);
-        },
+  const SMALL_INSTANCE_UPPER_BOUND = 0.75;
+  const LARGE_INSTANCE_UPPER_BOUND = 0.9;
+  const DEFAULT_MULTIPLIER = 0.125;
+  const [clusterContainsGPUNodes, setClusterContainsGPUNodes] = useState(false);
+  const [maxCPU, setMaxCPU] = useState(
+    AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU *
+      SMALL_INSTANCE_UPPER_BOUND
+  );
+  const [maxRAM, setMaxRAM] = useState(
+    // round to nearest 100
+    Math.round(
+      (convert(
+        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
+        "GiB"
+      ).to("MB") *
+        SMALL_INSTANCE_UPPER_BOUND) /
+        100
+    ) * 100
+  );
+  const [defaultCPU, setDefaultCPU] = useState(
+    AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU *
+      DEFAULT_MULTIPLIER
+  );
+  const [defaultRAM, setDefaultRAM] = useState(
+    // round to nearest 100
+    Math.round(
+      (convert(
+        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
+        "GiB"
+      ).to("MB") *
+        DEFAULT_MULTIPLIER) /
+        100
+    ) * 100
+  );
+  const [clusterIngressIp, setClusterIngressIp] = useState<string>("");
+
+  const getClusterNodes = useQuery(
+    ["getClusterNodes", projectId, clusterId],
+    async () => {
+      if (!projectId || !clusterId || clusterId === -1) {
+        return await Promise.resolve([]);
+      }
+
+      const res = await api.getClusterNodes(
+        "<token>",
+        {},
         {
-            enabled: !!projectId && !!clusterId,
-            refetchOnWindowFocus: false,
-            retry: false,
+          project_id: projectId,
+          cluster_id: clusterId,
         }
-    );
-    useEffect(() => {
-        if (getClusterNodes.isSuccess) {
-            const data = getClusterNodes.data;
-            // this logic handles CPU and RAM independently - we might want to change this later
-            const maxCPU = data.reduce((acc, curr) => {
-                return Math.max(acc, curr.maxCPU);
-            }, 0);
-            const maxRAM = data.reduce((acc, curr) => {
-                return Math.max(acc, curr.maxRAM);
-            }, 0);
-            let maxMultiplier = SMALL_INSTANCE_UPPER_BOUND;
-            // if the instance type has more than 4 GB ram, we use 90% of the ram/cpu
-            // otherwise, we use 75%
-            if (maxRAM > 4) {
-                maxMultiplier = LARGE_INSTANCE_UPPER_BOUND;
-            }
-            // round down to nearest 0.5 cores
-            const newMaxCPU = Math.floor(maxCPU * maxMultiplier * 2) / 2;
-            // round down to nearest 100 MB
-            const newMaxRAM = Math.round(
-                convert(maxRAM, "GiB").to("MB") * maxMultiplier / 100
-            ) * 100;
-            setMaxCPU(newMaxCPU);
-            setMaxRAM(newMaxRAM);
-            setDefaultCPU(Number((newMaxCPU * DEFAULT_MULTIPLIER).toFixed(2)));
-            setDefaultRAM(Number((newMaxRAM * DEFAULT_MULTIPLIER).toFixed(0)));
-            setClusterContainsGPUNodes(data.some(item => item.instanceClass === "g4dn"));
-        }
-    }, [getClusterNodes])
-
-    const getCluster = useQuery(
-        ["getCluster", projectId, clusterId],
-        async () => {
-            if (!projectId || !clusterId || clusterId === -1) {
-                return Promise.resolve({ ingress_ip: "" });
-            }
-
-            const res = await api.getCluster(
-                "<token>",
-                {},
-                {
-                    project_id: projectId,
-                    cluster_id: clusterId,
-                }
-            )
-
-            return await z.object({ ingress_ip: z.string() }).parseAsync(res.data);
-        },
+      );
+      return await z.array(clusterNodesValidator).parseAsync(res.data);
+    },
+    {
+      enabled: !!projectId && !!clusterId,
+      refetchOnWindowFocus: false,
+      retry: false,
+    }
+  );
+
+  const getContract = useQuery(
+    ["getContracts", projectId, clusterId, clusterStatus],
+    async () => {
+      if (!projectId || !clusterId || clusterId === -1) {
+        return "";
+      }
+
+      const res = await api.getContracts(
+        "<token>",
+        {},
+        { project_id: projectId }
+      );
+      const contracts: EncodedContract[] = await z
+        .array(z.any())
+        .parseAsync(res.data);
+      // Use zod to validate the data
+      const latestContract = contracts
+        .filter((contract) => contract.cluster_id === clusterId) // Filter contracts by the currentCluster.id
+        .sort(
+          (a, b) =>
+            new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime()
+        ) // Sort them by the CreatedAt date in descending order
+        .map((contract) => contract)[0];
+
+      const decodedContract = JSON.parse(
+        atob(latestContract.base64_contract)
+      ) as ContractData;
+      // Check for NODE_GROUP_TYPE_CUSTOM with instanceType containing "g4dn"
+
+      return decodedContract;
+    },
+    {
+      enabled: !!projectId,
+      refetchOnWindowFocus: false,
+      retry: false,
+    }
+  );
+
+  useEffect(() => {
+    if (getClusterNodes.isSuccess) {
+      const data = getClusterNodes.data;
+      // this logic handles CPU and RAM independently - we might want to change this later
+      const maxCPU = data.reduce((acc, curr) => {
+        return Math.max(acc, curr.maxCPU);
+      }, 0);
+      const maxRAM = data.reduce((acc, curr) => {
+        return Math.max(acc, curr.maxRAM);
+      }, 0);
+      let maxMultiplier = SMALL_INSTANCE_UPPER_BOUND;
+      // if the instance type has more than 4 GB ram, we use 90% of the ram/cpu
+      // otherwise, we use 75%
+      if (maxRAM > 4) {
+        maxMultiplier = LARGE_INSTANCE_UPPER_BOUND;
+      }
+      // round down to nearest 0.5 cores
+      const newMaxCPU = Math.floor(maxCPU * maxMultiplier * 2) / 2;
+      // round down to nearest 100 MB
+      const newMaxRAM =
+        Math.round((convert(maxRAM, "GiB").to("MB") * maxMultiplier) / 100) *
+        100;
+      setMaxCPU(newMaxCPU);
+      setMaxRAM(newMaxRAM);
+      setDefaultCPU(Number((newMaxCPU * DEFAULT_MULTIPLIER).toFixed(2)));
+      setDefaultRAM(Number((newMaxRAM * DEFAULT_MULTIPLIER).toFixed(0)));
+    }
+  }, [getClusterNodes]);
+
+  const getCluster = useQuery(
+    ["getCluster", projectId, clusterId],
+    async () => {
+      if (!projectId || !clusterId || clusterId === -1) {
+        return await Promise.resolve({ ingress_ip: "" });
+      }
+
+      const res = await api.getCluster(
+        "<token>",
+        {},
         {
-            enabled: !!projectId && !!clusterId,
-            refetchOnWindowFocus: false,
-            retry: false,
+          project_id: projectId,
+          cluster_id: clusterId,
         }
-    );
-    useEffect(() => {
-        if (getCluster.isSuccess) {
-            setClusterIngressIp(getCluster.data.ingress_ip);
-        }
-    }, [getCluster])
+      );
+
+      return await z.object({ ingress_ip: z.string() }).parseAsync(res.data);
+    },
+    {
+      enabled: !!projectId && !!clusterId,
+      refetchOnWindowFocus: false,
+      retry: false,
+    }
+  );
 
+  useEffect(() => {
+    if (getCluster.isSuccess) {
+      setClusterIngressIp(getCluster.data.ingress_ip);
+    }
+  }, [getCluster]);
 
-    return {
-        maxCPU,
-        maxRAM,
-        defaultCPU,
-        defaultRAM,
-        clusterContainsGPUNodes,
-        clusterIngressIp,
+  useEffect(() => {
+    if (getContract.isSuccess && getContract.data) {
+      const containsCustomNodeGroup =
+        getContract.data.cluster.eksKind.nodeGroups.some(
+          (ng: NodeGroup) =>
+            (ng.nodeGroupType === "NODE_GROUP_TYPE_CUSTOM" &&
+              ng.instanceType.includes("g4dn")) ||
+            (ng.nodeGroupType === "NODE_GROUP_TYPE_APPLICATION" &&
+              ng.instanceType.includes("g4dn"))
+        );
+      setClusterContainsGPUNodes(containsCustomNodeGroup);
     }
-}
+  }, [getContract]);
+
+  return {
+    maxCPU,
+    maxRAM,
+    defaultCPU,
+    defaultRAM,
+    clusterContainsGPUNodes,
+    clusterIngressIp,
+  };
+};
 
 // this function returns the fraction which the resource sliders 'snap' to when the user turns on smart optimization
-export const lowestClosestResourceMultipler = (min: number, max: number, value: number): number => {
-    const fractions = [0.5, 0.25, 0.125];
+export const lowestClosestResourceMultipler = (
+  min: number,
+  max: number,
+  value: number
+): number => {
+  const fractions = [0.5, 0.25, 0.125];
 
-    for (const fraction of fractions) {
-        const newValue = fraction * (max - min) + min;
-        if (newValue <= value) {
-            return fraction;
-        }
+  for (const fraction of fractions) {
+    const newValue = fraction * (max - min) + min;
+    if (newValue <= value) {
+      return fraction;
     }
+  }
 
-    return 0.125; // Return 0 if no fraction rounds down
-}
+  return 0.125; // Return 0 if no fraction rounds down
+};
 
 // this function is used to snap both resource sliders in unison when one is changed
-export const closestMultiplier = (min: number, max: number, value: number): number => {
-    const fractions = [0.5, 0.25, 0.125];
-    let closestFraction = 0.125;
-    for (const fraction of fractions) {
-        const newValue = fraction * (max - min) + min;
-        if (Math.abs(newValue - value) < Math.abs(closestFraction * (max - min) + min - value)) {
-            closestFraction = fraction;
-        }
+export const closestMultiplier = (
+  min: number,
+  max: number,
+  value: number
+): number => {
+  const fractions = [0.5, 0.25, 0.125];
+  let closestFraction = 0.125;
+  for (const fraction of fractions) {
+    const newValue = fraction * (max - min) + min;
+    if (
+      Math.abs(newValue - value) <
+      Math.abs(closestFraction * (max - min) + min - value)
+    ) {
+      closestFraction = fraction;
     }
+  }
 
-    return closestFraction;
-}
+  return closestFraction;
+};

+ 63 - 9
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -1,25 +1,27 @@
 import React, { useCallback, useEffect, useState } from "react";
-import AnimateHeight, { Height } from "react-animate-height";
-import styled from "styled-components";
+import AnimateHeight, { type Height } from "react-animate-height";
+import styled, { keyframes } from "styled-components";
 import _ from "lodash";
 
 import web from "assets/web.png";
+import chip from "assets/computer-chip.svg";
+import gpu from "assets/lightning.svg";
 import worker from "assets/worker.png";
 import job from "assets/job.png";
-
+import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import WebTabs from "./tabs/WebTabs";
 import WorkerTabs from "./tabs/WorkerTabs";
 import JobTabs from "./tabs/JobTabs";
-import { ClientService } from "lib/porter-apps/services";
-import { UseFieldArrayUpdate } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
+import { type ClientService } from "lib/porter-apps/services";
+import { type UseFieldArrayUpdate } from "react-hook-form";
+import { type PorterAppFormData } from "lib/porter-apps";
 import { match } from "ts-pattern";
 import useResizeObserver from "lib/hooks/useResizeObserver";
-import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import ServiceStatusFooter from "./ServiceStatusFooter";
 
-interface ServiceProps {
+type ServiceProps = {
   index: number;
   service: ClientService;
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
@@ -45,7 +47,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   maxRAM,
   clusterContainsGPUNodes,
   internalNetworkingDetails,
-  clusterIngressIp, 
+  clusterIngressIp,
 }) => {
   const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
 
@@ -140,7 +142,14 @@ const ServiceContainer: React.FC<ServiceProps> = ({
           {service.name.value.trim().length > 0
             ? service.name.value
             : "New Service"}
+          {service.gpuCoresNvidia.value > 0 &&
+            <><Spacer inline x={1.5} /><TagContainer>
+              <ChipIcon src={chip} alt="Chip Icon" />
+              <TagText>GPU Workload</TagText>
+            </TagContainer></>
+          }
         </ServiceTitle>
+
         {service.canDelete && (
           <ActionButton
             onClick={(e) => {
@@ -260,3 +269,48 @@ const Icon = styled.img`
   height: 18px;
   margin-right: 15px;
 `;
+
+const reflectiveGleam = keyframes`
+  0%, 100% {
+    background-position: 0% 50%;
+  }
+  50% {
+    background-position: 100% 50%;
+  }
+`;
+
+const TagContainer = styled.div`
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 4px 8px;
+  position: relative;
+  width: auto;
+  height: 30px;
+  background-image: linear-gradient(
+    45deg,
+    rgba(255, 255, 255, 0.05) 25%, 
+    rgba(255, 255, 255, 0.2) 50%, 
+    rgba(255, 255, 255, 0.05) 75%
+  );
+  background-size: 200% 200%;
+  border-radius: 10px;
+  animation: ${reflectiveGleam} 4s infinite linear;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+`;
+
+const ChipIcon = styled.img`
+  width: 14px;
+  height: 14px;
+  margin-right: 4px;
+`;
+
+const TagText = styled.span`
+  font-family: 'General Sans';
+  font-weight: 400;
+  font-size: 10px;
+  line-height: 100%;
+  letter-spacing: -0.02em;
+  color: #FFFFFF;
+`;

+ 6 - 6
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -12,9 +12,9 @@ import web from "assets/web.png";
 import worker from "assets/worker.png";
 import job from "assets/job.png";
 import { z } from "zod";
-import { PorterAppFormData } from "lib/porter-apps";
+import { type PorterAppFormData } from "lib/porter-apps";
 import {
-  ClientService,
+  type ClientService,
   defaultSerialized,
   deserializeService,
   isPredeployService,
@@ -26,7 +26,7 @@ import {
   useFormContext,
 } from "react-hook-form";
 import { ControlledInput } from "components/porter/ControlledInput";
-import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 
@@ -72,7 +72,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
 
-  const { currentClusterResources: {maxCPU, maxRAM, clusterContainsGPUNodes, clusterIngressIp, defaultCPU, defaultRAM} } = useClusterResources();
+  const { currentClusterResources: { maxCPU, maxRAM, clusterContainsGPUNodes, clusterIngressIp, defaultCPU, defaultRAM } } = useClusterResources();
 
   // add service modal form
   const {
@@ -233,7 +233,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
       )}
       {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
-        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
+        <Modal closeModal={() => { setShowAddServiceModal(false); }} width="500px">
           <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
@@ -251,7 +251,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 <Select
                   value={serviceType}
                   width="100%"
-                  setValue={(value: string) => onChange(value)}
+                  setValue={(value: string) => { onChange(value); }}
                   options={[
                     { label: "Web", value: "web" },
                     { label: "Worker", value: "worker" },

+ 273 - 173
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx

@@ -1,8 +1,8 @@
-import React, { useEffect, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import Spacer from "components/porter/Spacer";
-import { ClientService } from "lib/porter-apps/services";
+import { type ClientService } from "lib/porter-apps/services";
 import { Controller, useFormContext } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
+import { type PorterAppFormData } from "lib/porter-apps";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Checkbox from "components/porter/Checkbox";
 import Text from "components/porter/Text";
@@ -13,6 +13,12 @@ import SmartOptModal from "main/home/app-dashboard/new-app-flow/tabs/SmartOptMod
 import IntelligentSlider from "./IntelligentSlider";
 import InputSlider from "components/porter/InputSlider";
 import { closestMultiplier, lowestClosestResourceMultipler } from "lib/hooks/useClusterResourceLimits";
+import Loading from "components/Loading";
+import ProvisionClusterModal from "main/home/sidebar/ProvisionClusterModal";
+import { Context } from "shared/Context";
+import Link from "components/porter/Link";
+import Tag from "components/porter/Tag";
+import infra from "assets/cluster.svg";
 
 type ResourcesProps = {
   index: number;
@@ -33,6 +39,8 @@ const Resources: React.FC<ResourcesProps> = ({
 }) => {
   const { control, register, watch, setValue } = useFormContext<PorterAppFormData>();
   const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
+  const [clusterModalVisible, setClusterModalVisible] = useState<boolean>(false);
+  const { currentCluster, currentProject } = useContext(Context);
 
   const autoscalingEnabled = watch(
     `app.services.${index}.config.autoscaling.enabled`, {
@@ -186,181 +194,247 @@ const Resources: React.FC<ResourcesProps> = ({
           />
         )}
       />
-      {clusterContainsGPUNodes && (
+
+      {(currentCluster.cloud_provider === "AWS" && currentProject.gpu_enabled) &&
         <>
           <Spacer y={1} />
           <Controller
             name={`app.services.${index}.gpuCoresNvidia`}
             control={control}
             render={({ field: { value, onChange } }) => (
-              <InputSlider
-                label="GPUs: "
-                unit="Cores"
-                min={0}
-                max={1}
-                step={.1}
-                value={(value.value).toString()}
-                disabled={value.readOnly}
-                width="300px"
-                setValue={(e) => {
-                  onChange({
-                    ...value,
-                    value: e,
-                  });
-                }}
-                disabledTooltip={"You may only edit this field in your porter.yaml."
-                }
-              />
-            )}
-          />
-        </>
-      )
-
-      }
-      {match(service.config)
-        .with({ type: "job" }, () => null)
-        .with({ type: "predeploy" }, () => null)
-        .otherwise((config) => (
-          <>
-            <Spacer y={1} />
-            <ControlledInput
-              type="text"
-              label="Instances"
-              placeholder="ex: 1"
-              disabled={service.instances.readOnly || autoscalingEnabled.value}
-              width="300px"
-              disabledTooltip={
-                service.instances.readOnly
-                  ? "You may only edit this field in your porter.yaml."
-                  : "Disable autoscaling to specify instances."
-              }
-              {...register(`app.services.${index}.instances.value`)}
-            />
-            <Spacer y={1} />
-
-            {!clusterContainsGPUNodes && (<Controller
-              name={`app.services.${index}.config.autoscaling.enabled`}
-              control={control}
-              render={({ field: { value, onChange } }) => (
-                <Checkbox
-                  checked={value.value}
-                  toggleChecked={() => {
-                    onChange({
-                      ...value,
-                      value: !value.value,
-                    });
-                  }}
-                  disabled={value.readOnly}
-                  disabledTooltip={
-                    "You may only edit this field in your porter.yaml."
-                  }
-                >
-                  <Text color="helper">
-                    Enable autoscaling (overrides instances)
-                  </Text>
-                </Checkbox>
-              )}
-            />)}
-
-
-            {autoscalingEnabled.value && (
               <>
-                <Spacer y={1} />
-                <ControlledInput
-                  type="text"
-                  label="Min instances"
-                  placeholder="ex: 1"
-                  disabled={
-                    config.autoscaling?.minInstances?.readOnly ??
-                    !config.autoscaling?.enabled.value
-                  }
-                  width="300px"
-                  disabledTooltip={
-                    config.autoscaling?.minInstances?.readOnly
-                      ? "You may only edit this field in your porter.yaml."
-                      : "Enable autoscaling to specify min instances."
-                  }
-                  {...register(
-                    `app.services.${index}.config.autoscaling.minInstances.value`
-                  )}
-                />
-                <Spacer y={1} />
-                <ControlledInput
-                  type="text"
-                  label="Max instances"
-                  placeholder="ex: 10"
-                  disabled={
-                    config.autoscaling?.maxInstances?.readOnly ??
-                    !config.autoscaling?.enabled.value
-                  }
-                  width="300px"
-                  disabledTooltip={
-                    config.autoscaling?.maxInstances?.readOnly
-                      ? "You may only edit this field in your porter.yaml."
-                      : "Enable autoscaling to specify max instances."
-                  }
-                  {...register(
-                    `app.services.${index}.config.autoscaling.maxInstances.value`
-                  )}
-                />
-                <Spacer y={1} />
-                <Controller
-                  name={`app.services.${index}.config.autoscaling.cpuThresholdPercent`}
-                  control={control}
-                  render={({ field: { value, onChange } }) => (
-                    <InputSlider
-                      label="CPU threshold: "
-                      unit="%"
-                      min={0}
-                      max={100}
-                      value={value?.value.toString() ?? "50"}
-                      disabled={value?.readOnly || !config.autoscaling?.enabled}
-                      width="300px"
-                      setValue={(e) => {
+                <>
+                  <Switch
+                    size="small"
+                    color="primary"
+                    checked={value.value > 0}
+                    disabled={!clusterContainsGPUNodes}
+                    onChange={() => {
+                      if (value.value > 0) {
                         onChange({
                           ...value,
-                          value: e,
+                          value: 0
                         });
-                      }}
-                      disabledTooltip={
-                        value?.readOnly
-                          ? "You may only edit this field in your porter.yaml."
-                          : "Enable autoscaling to specify CPU threshold."
                       }
-                    />
-                  )}
-                />
-                <Spacer y={1} />
-                <Controller
-                  name={`app.services.${index}.config.autoscaling.memoryThresholdPercent`}
-                  control={control}
-                  render={({ field: { value, onChange } }) => (
-                    <InputSlider
-                      label="RAM threshold: "
-                      unit="%"
-                      min={0}
-                      max={100}
-                      value={value?.value.toString() ?? "50"}
-                      disabled={value?.readOnly || !config.autoscaling?.enabled}
-                      width="300px"
-                      setValue={(e) => {
+                      else
                         onChange({
                           ...value,
-                          value: e,
+                          value: 1
                         });
-                      }}
-                      disabledTooltip={
-                        value?.readOnly
-                          ? "You may only edit this field in your porter.yaml."
-                          : "Enable autoscaling to specify RAM threshold."
-                      }
-                    />
-                  )}
-                />
+                    }}
+
+                    inputProps={{ 'aria-label': 'controlled' }} /><Spacer inline x={.5} /><Text >
+                    <>
+                      <span>Enable GPU</span>
+                    </>
+                  </Text>
+                  {
+                    !clusterContainsGPUNodes &&
+                    <>
+
+                      <Spacer inline x={2} />
+                      <Text
+                        color="helper"
+                      >
+                        You cluster has no GPU nodes available.
+                      </Text>
+                      <Spacer inline x={.5} />
+                      <Link
+                        onClick={() => { setClusterModalVisible(true); }}
+                        hasunderline
+                      >
+                        Add GPU nodes
+                      </Link>
+                      {/* <a
+                        href="https://docs.porter.run/enterprise/deploying-applications/zero-downtime-deployments#health-checks"
+                        target="_blank" rel="noreferrer"
+                      >
+                        &nbsp;(?)
+                      </a> */}
+                    </>
+
+                  }
+                </>
+                <Spacer y={.5} />
+                {
+                  clusterModalVisible && <ProvisionClusterModal
+                    closeModal={() => {
+                      setClusterModalVisible(false);
+                    }}
+                    gpuModal={true}
+                  />
+                }
               </>
-            )}
-          </>
-        ))}
+            )} />
+          {(currentCluster.status === "UPDATING" && clusterContainsGPUNodes) &&
+            < CheckItemContainer >
+              <CheckItemTop >
+                <Loading
+                  offset="0px"
+                  width="20px"
+                  height="20px" />
+                <Spacer inline x={1} />
+                <Text style={{ marginLeft: '10px', flex: 1 }}>{"Creating GPU nodes..."}</Text>
+                <Spacer inline x={1} />
+                <Tag>
+                  <Link
+                    to={`/cluster-dashboard`}
+                  >
+                    <TagIcon src={infra} />
+                    View Status
+                  </Link>
+                </Tag>
+              </CheckItemTop>
+            </CheckItemContainer>
+          }
+        </>
+      }
+      {
+        match(service.config)
+          .with({ type: "job" }, () => null)
+          .with({ type: "predeploy" }, () => null)
+          .otherwise((config) => (
+            <>
+              <Spacer y={1} />
+              <ControlledInput
+                type="text"
+                label="Instances"
+                placeholder="ex: 1"
+                disabled={service.instances.readOnly || autoscalingEnabled.value}
+                width="300px"
+                disabledTooltip={
+                  service.instances.readOnly
+                    ? "You may only edit this field in your porter.yaml."
+                    : "Disable autoscaling to specify instances."
+                }
+                {...register(`app.services.${index}.instances.value`)}
+              />
+              <Spacer y={1} />
+
+              {!clusterContainsGPUNodes && (<Controller
+                name={`app.services.${index}.config.autoscaling.enabled`}
+                control={control}
+                render={({ field: { value, onChange } }) => (
+                  <Checkbox
+                    checked={value.value}
+                    toggleChecked={() => {
+                      onChange({
+                        ...value,
+                        value: !value.value,
+                      });
+                    }}
+                    disabled={value.readOnly}
+                    disabledTooltip={
+                      "You may only edit this field in your porter.yaml."
+                    }
+                  >
+                    <Text color="helper">
+                      Enable autoscaling (overrides instances)
+                    </Text>
+                  </Checkbox>
+                )}
+              />)}
+
+
+              {autoscalingEnabled.value && (
+                <>
+                  <Spacer y={1} />
+                  <ControlledInput
+                    type="text"
+                    label="Min instances"
+                    placeholder="ex: 1"
+                    disabled={
+                      config.autoscaling?.minInstances?.readOnly ??
+                      !config.autoscaling?.enabled.value
+                    }
+                    width="300px"
+                    disabledTooltip={
+                      config.autoscaling?.minInstances?.readOnly
+                        ? "You may only edit this field in your porter.yaml."
+                        : "Enable autoscaling to specify min instances."
+                    }
+                    {...register(
+                      `app.services.${index}.config.autoscaling.minInstances.value`
+                    )}
+                  />
+                  <Spacer y={1} />
+                  <ControlledInput
+                    type="text"
+                    label="Max instances"
+                    placeholder="ex: 10"
+                    disabled={
+                      config.autoscaling?.maxInstances?.readOnly ??
+                      !config.autoscaling?.enabled.value
+                    }
+                    width="300px"
+                    disabledTooltip={
+                      config.autoscaling?.maxInstances?.readOnly
+                        ? "You may only edit this field in your porter.yaml."
+                        : "Enable autoscaling to specify max instances."
+                    }
+                    {...register(
+                      `app.services.${index}.config.autoscaling.maxInstances.value`
+                    )}
+                  />
+                  <Spacer y={1} />
+                  <Controller
+                    name={`app.services.${index}.config.autoscaling.cpuThresholdPercent`}
+                    control={control}
+                    render={({ field: { value, onChange } }) => (
+                      <InputSlider
+                        label="CPU threshold: "
+                        unit="%"
+                        min={0}
+                        max={100}
+                        value={value?.value.toString() ?? "50"}
+                        disabled={value?.readOnly || !config.autoscaling?.enabled}
+                        width="300px"
+                        setValue={(e) => {
+                          onChange({
+                            ...value,
+                            value: e,
+                          });
+                        }}
+                        disabledTooltip={
+                          value?.readOnly
+                            ? "You may only edit this field in your porter.yaml."
+                            : "Enable autoscaling to specify CPU threshold."
+                        }
+                      />
+                    )}
+                  />
+                  <Spacer y={1} />
+                  <Controller
+                    name={`app.services.${index}.config.autoscaling.memoryThresholdPercent`}
+                    control={control}
+                    render={({ field: { value, onChange } }) => (
+                      <InputSlider
+                        label="RAM threshold: "
+                        unit="%"
+                        min={0}
+                        max={100}
+                        value={value?.value.toString() ?? "50"}
+                        disabled={value?.readOnly || !config.autoscaling?.enabled}
+                        width="300px"
+                        setValue={(e) => {
+                          onChange({
+                            ...value,
+                            value: e,
+                          });
+                        }}
+                        disabledTooltip={
+                          value?.readOnly
+                            ? "You may only edit this field in your porter.yaml."
+                            : "Enable autoscaling to specify RAM threshold."
+                        }
+                      />
+                    )}
+                  />
+                </>
+              )}
+            </>
+          ))
+      }
     </>
   );
 };
@@ -368,16 +442,42 @@ const Resources: React.FC<ResourcesProps> = ({
 export default Resources;
 
 const StyledIcon = styled.i`
-  cursor: pointer;
-  font-size: 16px; 
-  margin-right : 5px;
-  &:hover {
-    color: #666;  
+      cursor: pointer;
+      font-size: 16px;
+      margin-right : 5px;
+      &:hover {
+        color: #666;  
   }
-`;
+      `;
 
 const SmartOptHeader = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-`
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      `
+
+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: ${props => (props.hasMessage ? 'pointer' : 'default')};
+      background: ${props => props.theme.clickable.bg};
+
+      `;
+
+const CheckItemTop = styled.div`
+      display: flex;
+      align-items: center;
+      padding: 10px;
+      background: ${props => props.theme.clickable.bg};
+      `;
+
+const TagIcon = styled.img`
+      height: 12px;
+      margin-right: 3px;
+      `;

+ 78 - 78
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterRevisionSelector.tsx

@@ -8,14 +8,7 @@ import warning from "assets/warning.png";
 import { readableDate } from "shared/string_utils";
 import { Context } from "shared/Context";
 import ExpandableSection from "components/porter/ExpandableSection";
-import {
-  Contract,
-  Cluster,
-  EKS,
-  NodeGroupType,
-  EnumKubernetesKind,
-  EnumCloudProvider,
-} from "@porter-dev/api-contracts";
+
 import Spacer from "components/porter/Spacer";
 import { createPortal } from "react-dom";
 import ConfirmOverlay from "components/ConfirmOverlay";
@@ -25,6 +18,7 @@ type Props = {
   setSelectedClusterVersion: any;
   setShowProvisionerStatus: any;
   setProvisionFailureReason: any;
+  gpuModal?: boolean;
 };
 
 const ClusterRevisionSelector: React.FC<Props> = ({
@@ -32,6 +26,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
   setSelectedClusterVersion,
   setShowProvisionerStatus,
   setProvisionFailureReason,
+  gpuModal
 }) => {
   const [showConfirmOverlay, setShowConfirmOverlay] = useState(false);
   const { currentProject, currentCluster } = useContext(Context);
@@ -108,7 +103,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
         .createContract("<token>", selectedClusterVersion, {
           project_id: currentProject.id,
         })
-        .then(() => {})
+        .then(() => { })
         .catch((err) => {
           console.log(err);
         });
@@ -188,7 +183,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
         <Td>{readableDate(pendingContract.CreatedAt)}</Td>
         {failedContractId && (
           <DeleteButton>
-            <div onClick={() => setShowConfirmOverlay(true)}>
+            <div onClick={() => { setShowConfirmOverlay(true); }}>
               Clear Revision
             </div>
           </DeleteButton>
@@ -209,74 +204,79 @@ const ClusterRevisionSelector: React.FC<Props> = ({
 
   return (
     <>
-      {hideSelector ? (
-        <></>
-      ) : (
+      {
+        !gpuModal &&
         <>
-          <StyledClusterRevisionSelector>
-            <ExpandableSection
-              isInitiallyExpanded={false}
-              color={selectedId <= 0 ? "#ffffff66" : "#f5cb42"}
-              Header={
-                <>
-                  <Label isCurrent={selectedId <= 0}>
-                    {selectedId === 0
-                      ? "Current version -"
-                      : selectedId === -1
-                      ? failedContractId
-                        ? ""
-                        : "In progress -"
-                      : "Previewing version (not deployed) -"}
-                  </Label>
-                  {selectedId === -1 ? (
-                    failedContractId ? (
-                      <>
-                        <WarningIcon src={warning} /> Last update failed
-                      </>
-                    ) : (
-                      <>
-                        <Img src={loading} /> Updating
-                      </>
-                    )
-                  ) : (
-                    `No. ${versions?.length - selectedId}`
-                  )}
-                </>
-              }
-              ExpandedSection={
-                <TableWrapper>
-                  <RevisionsTable>
-                    <tbody>
-                      <Tr disableHover={true}>
-                        <Th>Version no.</Th>
-                        <Th>Created</Th>
-                        {/* <Th>Rollback</Th> */}
-                      </Tr>
-                      {(pendingContract || failedContractId) &&
-                        renderActiveAttempt()}
-                      {renderVersionList()}
-                    </tbody>
-                  </RevisionsTable>
-                </TableWrapper>
-              }
-            />
-          </StyledClusterRevisionSelector>
-          <Spacer y={1} />
+          {hideSelector ? (
+            <></>
+          ) : (
+            <>
+              <StyledClusterRevisionSelector>
+                <ExpandableSection
+                  isInitiallyExpanded={false}
+                  color={selectedId <= 0 ? "#ffffff66" : "#f5cb42"}
+                  Header={
+                    <>
+                      <Label isCurrent={selectedId <= 0}>
+                        {selectedId === 0
+                          ? "Current version -"
+                          : selectedId === -1
+                            ? failedContractId
+                              ? ""
+                              : "In progress -"
+                            : "Previewing version (not deployed) -"}
+                      </Label>
+                      {selectedId === -1 ? (
+                        failedContractId ? (
+                          <>
+                            <WarningIcon src={warning} /> Last update failed
+                          </>
+                        ) : (
+                          <>
+                            <Img src={loading} /> Updating
+                          </>
+                        )
+                      ) : (
+                        `No. ${versions?.length - selectedId}`
+                      )}
+                    </>
+                  }
+                  ExpandedSection={
+                    <TableWrapper>
+                      <RevisionsTable>
+                        <tbody>
+                          <Tr disableHover={true}>
+                            <Th>Version no.</Th>
+                            <Th>Created</Th>
+                            {/* <Th>Rollback</Th> */}
+                          </Tr>
+                          {(pendingContract || failedContractId) &&
+                            renderActiveAttempt()}
+                          {renderVersionList()}
+                        </tbody>
+                      </RevisionsTable>
+                    </TableWrapper>
+                  }
+                />
+              </StyledClusterRevisionSelector>
+              <Spacer y={1} />
+            </>
+          )}
+          {showConfirmOverlay &&
+            createPortal(
+              <ConfirmOverlay
+                show={true}
+                message={`Clear the failed revision?`}
+                onYes={() => {
+                  deleteContract();
+                  setShowConfirmOverlay(false);
+                }}
+                onNo={() => { setShowConfirmOverlay(false); }}
+              />,
+              document.body
+            )}
         </>
-      )}
-      {showConfirmOverlay &&
-        createPortal(
-          <ConfirmOverlay
-            show={true}
-            message={`Clear the failed revision?`}
-            onYes={() => {
-              deleteContract();
-              setShowConfirmOverlay(false);
-            }}
-            onNo={() => setShowConfirmOverlay(false)}
-          />,
-          document.body
-        )}
+      }
     </>
   );
 };
@@ -353,7 +353,7 @@ const RollbackButton = styled.div`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#405eddbb"};
+    props.disabled ? "" : "#405eddbb"};
   }
 `;
 
@@ -367,7 +367,7 @@ const Tr = styled.tr`
     props.selected ? "#ffffff11" : ""};
   :hover {
     background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-      props.disableHover ? "" : "#ffffff22"};
+    props.disableHover ? "" : "#ffffff22"};
   }
 `;
 

+ 3 - 0
dashboard/src/main/home/sidebar/AddCluster/AWSCredentialList.tsx

@@ -14,6 +14,7 @@ import ProvisionerFlow from "components/ProvisionerFlow";
 type Props = {
   selectCredential: (aws_integration_id: number) => void;
   setTargetARN: (target_arn: string) => void;
+  gpuModal?: boolean;
 };
 
 type AWSCredential = {
@@ -27,6 +28,7 @@ type AWSCredential = {
 const AWSCredentialsList: React.FunctionComponent<Props> = ({
   selectCredential,
   setTargetARN,
+  gpuModal,
 }) => {
   const { currentProject, setCurrentError } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
@@ -97,6 +99,7 @@ const AWSCredentialsList: React.FunctionComponent<Props> = ({
           setTargetARN={setTargetARN}
           shouldCreateCred={() => setShouldCreateCred(true)}
           addNewText="Create new CloudFormation stack"
+          gpuModal={gpuModal}
         />
       </>
     );

+ 2 - 1
dashboard/src/main/home/sidebar/AddCluster/CredentialList.tsx

@@ -10,6 +10,7 @@ type Props = {
   isLink?: boolean;
   linkHref?: string;
   setTargetARN: (targetARN: string) => void;
+  gpuModal?: boolean;
 };
 
 type GenericCredential = {
@@ -55,7 +56,7 @@ const CredentialList: React.FunctionComponent<Props> = (props) => {
           </PreviewRow>
         );
       })}
-      {renderCreateSection()}
+      {!props.gpuModal && renderCreateSection()}
     </>
   );
 };

+ 2 - 2
dashboard/src/main/home/sidebar/ClusterList.tsx

@@ -274,7 +274,7 @@ const MainSelector = styled.div`
     justify-content: center;
     border-radius: 20px;
     background: ${(props: { expanded: boolean }) =>
-      props.expanded ? "#ffffff22" : ""};
+    props.expanded ? "#ffffff22" : ""};
   }
 `;
 
@@ -302,7 +302,7 @@ const NavButton = styled(SidebarLink)`
 
   :hover {
     background: ${(props: NavButtonProps) =>
-      props.active ? "#ffffff11" : "#ffffff08"};
+    props.active ? "#ffffff11" : "#ffffff08"};
   }
 
   &.active {

+ 92 - 29
dashboard/src/main/home/sidebar/ProvisionClusterModal.tsx

@@ -1,51 +1,114 @@
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
 import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
+import api from "shared/api";
 
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import ProvisionerSettings from "components/ProvisionerSettings";
-
-import { type InfraCredentials } from "shared/types";
+import GPUCostConsent from "components/GPUCostConsent";
+import { Context } from "shared/Context";
+import ClusterRevisionSelector from "../cluster-dashboard/dashboard/ClusterRevisionSelector";
 
 import AWSCredentialsList from "./AddCluster/AWSCredentialList";
+import { type InfraCredentials } from "shared/types";
+import { z } from "zod";
 
 type Props = RouteComponentProps & {
   closeModal: () => void;
-};
+  gpuModal?: boolean;
+}
+
+
+const ProvisionClusterModal: React.FC<Props> = ({
+  closeModal,
+  gpuModal,
+}) => {
+  const {
+    currentCluster,
+    currentProject
+  } = useContext(Context);
+
+  const [currentCredential, setCurrentCredential] = useState<InfraCredentials>(
+    null
+  );
+  const [currentStep, setCurrentStep] = useState("cloud");
+  const [targetArn, setTargetARN] = useState("")
+  const [selectedClusterVersion, setSelectedClusterVersion] = useState<ContractData>();
+  const [showProvisionerStatus, setShowProvisionerStatus] = useState(false);
+  const [provisionFailureReason, setProvisionFailureReason] = useState("");
+
 
-const ProvisionClusterModal: React.FC<Props> = ({ closeModal }) => {
-  const [currentCredential, setCurrentCredential] =
-    useState<InfraCredentials | null>(null);
-  const [targetArn, setTargetARN] = useState("");
 
   return (
     <Modal closeModal={closeModal} width={"1000px"}>
-      <Text size={16}>Provision A New Cluster</Text>
+      {gpuModal ? <>
+        <Text size={16}>
+          Add A GPU workload
+        </Text>
+        <Spacer y={.5} />
+        <Text color="helper" >
+          To enable GPU workloads on this service, you need to provision new GPU nodes.
+        </Text>
+      </>
+        : <Text size={16}>
+          Provision A New Cluster
+        </Text>}
       <Spacer y={1} />
+
+
       <ScrollableContent>
-        {currentCredential && targetArn ? (
-          <>
-            <ProvisionerSettings
-              credentialId={targetArn}
-              closeModal={closeModal}
-            />
-          </>
-        ) : (
-          <AWSCredentialsList
-            setTargetARN={setTargetARN}
-            selectCredential={(i) => {
-              setCurrentCredential({
-                aws_integration_id: i,
-              });
-            }}
-          />
-        )}
+        <>
+          {gpuModal ? (
+            <>
+              <ClusterRevisionSelector
+                selectedClusterVersion={selectedClusterVersion}
+                setSelectedClusterVersion={setSelectedClusterVersion}
+                setShowProvisionerStatus={setShowProvisionerStatus}
+                setProvisionFailureReason={setProvisionFailureReason}
+                gpuModal={true}
+              />
+
+              <ProvisionerSettings
+                clusterId={gpuModal ? currentCluster?.id : null}
+                gpuModal={gpuModal}
+                credentialId={currentCluster.cloud_provider_credential_identifier}
+                selectedClusterVersion={selectedClusterVersion}
+                closeModal={closeModal}
+              />
+            </>
+          ) :
+            (
+              currentCredential && targetArn ? (
+                <>
+                  <ProvisionerSettings
+                    credentialId={targetArn}
+                    closeModal={closeModal}
+                    clusterId={gpuModal ? currentCluster?.id : null}
+                  />
+                </>
+              ) : (
+                <AWSCredentialsList
+                  setTargetARN={setTargetARN}
+                  selectCredential={
+                    (i) => {
+                      setCurrentCredential({
+                        aws_integration_id: i,
+                      });
+                    }
+                  }
+                  gpuModal={gpuModal}
+                />
+              )
+            )}
+        </>
       </ScrollableContent>
-    </Modal>
-  );
-};
+
+
+    </Modal >
+  )
+}
 
 export default withRouter(ProvisionClusterModal);
 

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

@@ -1,6 +1,5 @@
-import React from "react";
+import React, { createContext, useContext } from "react";
 import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
-import { createContext, useContext } from "react";
 import { Context } from "./Context";
 
 export type ClusterResources = {
@@ -32,6 +31,7 @@ const ClusterResourcesProvider = ({ children }: { children: JSX.Element }) => {
   const { maxCPU, maxRAM, defaultCPU, defaultRAM, clusterContainsGPUNodes, clusterIngressIp } = useClusterResourceLimits({
     projectId: currentProject?.id,
     clusterId: currentCluster?.id,
+    clusterStatus: currentCluster?.status
   });
 
   return (

+ 33 - 7
dashboard/src/shared/types.tsx

@@ -13,7 +13,8 @@ export type ClusterType = {
   cloud_provider_credential_identifier?: string;
   status?: string;
   cloud_provider: string;
-};
+  gpuCluster?: boolean;
+}
 
 export type AddonCard = {
   id: string;
@@ -246,15 +247,15 @@ export type FormElement = {
 export type RepoType = {
   FullName: string;
 } & (
-  | {
+    | {
       Kind: "github";
       GHRepoID: number;
     }
-  | {
+    | {
       Kind: "gitlab";
       GitIntegrationId: number;
     }
-);
+  );
 
 export type FileType = {
   path: string;
@@ -277,6 +278,7 @@ export type ProjectType = {
   stacks_enabled: boolean;
   simplified_view_enabled: boolean;
   azure_enabled: boolean;
+  gpu_enabled: boolean;
   helm_values_enabled: boolean;
   multi_cluster: boolean;
   full_add_ons: boolean;
@@ -326,15 +328,15 @@ export type ActionConfigType = {
   image_repo_uri: string;
   dockerfile_path?: string;
 } & (
-  | {
+    | {
       kind: "gitlab";
       gitlab_integration_id: number;
     }
-  | {
+    | {
       kind: "github";
       git_repo_id: number;
     }
-);
+  );
 
 export type GithubActionConfigType = ActionConfigType & {
   kind: "github";
@@ -674,3 +676,27 @@ export type CreateUpdatePorterAppOptions = {
   override_release?: boolean;
   full_helm_values?: string;
 };
+
+export type ClusterState = {
+  clusterName: string;
+  awsRegion: string;
+  machineType: string;
+  guardDutyEnabled: boolean;
+  kmsEncryptionEnabled: boolean;
+  loadBalancerType: boolean;
+  wildCardDomain: string;
+  IPAllowList: string;
+  wafV2Enabled: boolean;
+  awsTags: string;
+  wafV2ARN: string;
+  certificateARN: string;
+  minInstances: number;
+  maxInstances: number;
+  additionalNodePolicies: string[];
+  cidrRangeVPC: string;
+  cidrRangeServices: string;
+  clusterVersion: string;
+  gpuInstanceType?: string;
+  gpuMinInstances: number;
+  gpuMaxInstances: number;
+};

+ 7 - 0
internal/models/project.go

@@ -37,6 +37,9 @@ const (
 	// FullAddOns shows all addons, not just curated
 	FullAddOns FeatureFlagLabel = "full_add_ons"
 
+	// GPUEnabled enables the "GPU for users"
+	GPUEnabled FeatureFlagLabel = "gpu_enabled"
+
 	// HelmValuesEnabled shows the helm values tab for porter apps (when simplified_view_enabled=true)
 	HelmValuesEnabled FeatureFlagLabel = "helm_values_enabled"
 
@@ -77,6 +80,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	EFSEnabled:             false,
 	EnableReprovision:      false,
 	FullAddOns:             false,
+	GPUEnabled:             false,
 	HelmValuesEnabled:      false,
 	ManagedInfraEnabled:    false,
 	MultiCluster:           false,
@@ -199,6 +203,8 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return p.EnableReprovision
 		case "full_add_ons":
 			return p.FullAddOns
+		case "gpu_enabled":
+			return false
 		case "helm_values_enabled":
 			return p.HelmValuesEnabled
 		case "managed_infra_enabled":
@@ -255,6 +261,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		DBEnabled:              p.GetFeatureFlag(DBEnabled, launchDarklyClient),
 		SimplifiedViewEnabled:  p.GetFeatureFlag(SimplifiedViewEnabled, launchDarklyClient),
 		AzureEnabled:           p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
+		GPUEnabled:             p.GetFeatureFlag(GPUEnabled, launchDarklyClient),
 		HelmValuesEnabled:      p.GetFeatureFlag(HelmValuesEnabled, launchDarklyClient),
 		MultiCluster:           p.GetFeatureFlag(MultiCluster, launchDarklyClient),
 		EnableReprovision:      p.GetFeatureFlag(EnableReprovision, launchDarklyClient),