sdess09 2 лет назад
Родитель
Сommit
30415cbba5

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

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

+ 1 - 0
api/types/project.go

@@ -36,6 +36,7 @@ type Project struct {
 	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
 	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
 	DBEnabled              bool    `json:"db_enabled"`
 	DBEnabled              bool    `json:"db_enabled"`
 	EFSEnabled             bool    `json:"efs_enabled"`
 	EFSEnabled             bool    `json:"efs_enabled"`
+	GPUEnabled             bool    `json:"gpu_enabled"`
 	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 	AzureEnabled           bool    `json:"azure_enabled"`
 	AzureEnabled           bool    `json:"azure_enabled"`
 	HelmValuesEnabled      bool    `json:"helm_values_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
           <SelectRow
             options={locationOptions}
             options={locationOptions}
             width="350px"
             width="350px"
-            disabled={true}
+            disabled={isReadOnly || isLoading}
             value={region}
             value={region}
             scrollBuffer={true}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
             dropdownMaxHeight="240px"
@@ -561,7 +561,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
           <SelectRow
             options={clusterVersionOptions}
             options={clusterVersionOptions}
             width="350px"
             width="350px"
-            disabled={true}
+            disabled={isReadOnly}
             value={clusterVersion}
             value={clusterVersion}
             scrollBuffer={true}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
             dropdownMaxHeight="240px"
@@ -572,7 +572,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           <SelectRow
           <SelectRow
             options={instanceTypes}
             options={instanceTypes}
             width="350px"
             width="350px"
-            disabled={true}
+            disabled={isReadOnly}
             value={instanceType}
             value={instanceType}
             scrollBuffer={true}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
             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 api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { pushFiltered } from "shared/routing";
 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 { PREFLIGHT_TO_ENUM } from "shared/util";
 import info from "assets/info-outlined.svg";
 import info from "assets/info-outlined.svg";
 import healthy from "assets/status-healthy.png";
 import healthy from "assets/status-healthy.png";
@@ -43,27 +43,10 @@ import Text from "./porter/Text";
 import Tooltip from "./porter/Tooltip";
 import Tooltip from "./porter/Tooltip";
 import VerticalSteps from "./porter/VerticalSteps";
 import VerticalSteps from "./porter/VerticalSteps";
 import PreflightChecks from "./PreflightChecks";
 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 = [
 const regionOptions = [
   { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
   { 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.16xlarge", label: "r6i.16xlarge" },
   { value: "r6i.24xlarge", label: "r6i.24xlarge" },
   { value: "r6i.24xlarge", label: "r6i.24xlarge" },
   { value: "r6i.32xlarge", label: "r6i.32xlarge" },
   { value: "r6i.32xlarge", label: "r6i.32xlarge" },
-  { value: "g4dn.xlarge", label: "g4dn.xlarge" },
   { value: "m5n.large", label: "m5n.large" },
   { value: "m5n.large", label: "m5n.large" },
   { value: "m5n.xlarge", label: "m5n.xlarge" },
   { value: "m5n.xlarge", label: "m5n.xlarge" },
   { value: "m5n.2xlarge", label: "m5n.2xlarge" },
   { value: "m5n.2xlarge", label: "m5n.2xlarge" },
@@ -145,14 +127,18 @@ const initialClusterState: ClusterState = {
   cidrRangeVPC: defaultCidrVpc,
   cidrRangeVPC: defaultCidrVpc,
   cidrRangeServices: defaultCidrServices,
   cidrRangeServices: defaultCidrServices,
   clusterVersion: defaultClusterVersion,
   clusterVersion: defaultClusterVersion,
+  gpuInstanceType: "g4dn.xlarge",
+  gpuMinInstances: 0,
+  gpuMaxInstances: 5,
 };
 };
 
 
 type Props = RouteComponentProps & {
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
   selectedClusterVersion?: Contract;
   provisionerError?: string;
   provisionerError?: string;
   credentialId: string;
   credentialId: string;
-  clusterId?: number;
+  clusterId?: number | null;
   closeModal?: () => void;
   closeModal?: () => void;
+  gpuModal?: boolean;
 };
 };
 
 
 const ProvisionerSettings: React.FC<Props> = (props) => {
 const ProvisionerSettings: React.FC<Props> = (props) => {
@@ -164,7 +150,6 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     setShouldRefreshClusters,
     setShouldRefreshClusters,
   } = useContext(Context);
   } = useContext(Context);
   const [step, setStep] = useState(0);
   const [step, setStep] = useState(0);
-
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [isClicked, setIsClicked] = useState(false);
   const [isClicked, setIsClicked] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
@@ -201,10 +186,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     } catch (err) {}
     } catch (err) {}
   };
   };
 
 
-  const getStatus = ():
-    | JSX.Element
-    | "Provisioning is still in progress..."
-    | undefined => {
+  const getStatus = (): React.ReactNode => {
     if (isLoading) {
     if (isLoading) {
       return <Loading />;
       return <Loading />;
     }
     }
@@ -230,7 +212,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       return false;
       return false;
     }
     }
     // Split the input string by comma and remove any empty elements
     // 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
     // Validate each IP address
     for (const ip of ipAddresses) {
     for (const ip of ipAddresses) {
       if (!regex.test(ip.trim())) {
       if (!regex.test(ip.trim())) {
@@ -260,6 +242,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     return !clusterState.clusterName;
     return !clusterState.clusterName;
   };
   };
   const userProvisioning = (): boolean => {
   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 === "";
     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
     // 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;
     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({
     const data = new Contract({
       cluster: new Cluster({
       cluster: new Cluster({
         projectId: currentProject.id,
         projectId: currentProject.id,
@@ -339,8 +361,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
           case: "eksKind",
           case: "eksKind",
           value: new EKS({
           value: new EKS({
             clusterName: clusterState.clusterName,
             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
             cidrRange: clusterState.cidrRangeVPC || defaultCidrVpc, // deprecated in favour of network.cidrRangeVPC: can be removed after december 2023
             region: clusterState.awsRegion,
             region: clusterState.awsRegion,
             loadBalancer: loadBalancerObj,
             loadBalancer: loadBalancerObj,
@@ -349,35 +370,9 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
             enableKmsEncryption: clusterState.kmsEncryptionEnabled,
             enableKmsEncryption: clusterState.kmsEncryptionEnabled,
             network: new AWSClusterNetwork({
             network: new AWSClusterNetwork({
               vpcCidr: clusterState.cidrRangeVPC || defaultCidrVpc,
               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);
               // setHasFinishedOnboarding(true);
               setCurrentCluster(cluster);
               setCurrentCluster(cluster);
               OFState.actions.goTo("clean_up");
               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) => {
         .catch((err) => {
           if (err) {
           if (err) {
@@ -467,6 +470,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   useEffect(() => {
   useEffect(() => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const contract = props.selectedClusterVersion as any;
     const contract = props.selectedClusterVersion as any;
+    // Unmarshall Contract here
     if (contract?.cluster) {
     if (contract?.cluster) {
       const eksValues: EKS = contract.cluster?.eksKind as EKS;
       const eksValues: EKS = contract.cluster?.eksKind as EKS;
       if (eksValues == null) {
       if (eksValues == null) {
@@ -552,18 +556,21 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
 
 
   useEffect(() => {
   useEffect(() => {
     if (!props.clusterId) {
     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> => {
   const proceedToProvision = async (): Promise<void> => {
     setShowEmailMessage(true);
     setShowEmailMessage(true);
@@ -672,6 +679,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   handleClusterStateChange("clusterVersion", x);
                   handleClusterStateChange("clusterVersion", x);
                 }}
                 }}
                 label="Cluster version (only shown to porter.run emails)"
                 label="Cluster version (only shown to porter.run emails)"
+                placeholder={""}
               />
               />
             )}
             )}
             <Spacer y={1} />
             <Spacer y={1} />
@@ -1145,89 +1153,79 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     } catch (err) {}
     } catch (err) {}
   };
   };
 
 
+
   const renderForm = (): JSX.Element => {
   const renderForm = (): JSX.Element => {
     // Render simplified form if initial create
     // Render simplified form if initial create
     if (!props.clusterId) {
     if (!props.clusterId) {
       return (
       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">
                           <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>
                           </Text>
-                          <Spacer y={0.5} />
+                          <Spacer y={.5} />
                           <Text color="helper">
                           <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>
                           </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
                             <Button
                               disabled={isLoading}
                               disabled={isLoading}
                               onClick={proceedToProvision}
                               onClick={proceedToProvision}
+
                             >
                             >
                               Auto request increase
                               Auto request increase
                             </Button>
                             </Button>
@@ -1236,63 +1234,71 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                               onClick={dismissPreflight}
                               onClick={dismissPreflight}
                               color="#313539"
                               color="#313539"
                             >
                             >
-                              {"I'll do it myself"}
+                              I'll do it myself
                             </Button>
                             </Button>
                           </div>
                           </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}
                             disabled={isLoading}
                             onClick={preflightChecks}
                             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">
                   <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>
                   </Text>
                   <Spacer y={1} />
                   <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 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 (
     return (
       <>
       <>
         <StyledForm>
         <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 { useEffect, useState } from "react";
-import convert from "convert";
 import { useQuery } from "@tanstack/react-query";
 import { useQuery } from "@tanstack/react-query";
+import convert from "convert";
 import { z } from "zod";
 import { z } from "zod";
+
+import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
+
 import api from "shared/api";
 import api from "shared/api";
 
 
 const DEFAULT_INSTANCE_CLASS = "t3";
 const DEFAULT_INSTANCE_CLASS = "t3";
 const DEFAULT_INSTANCE_SIZE = "medium";
 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(),
         "beta.kubernetes.io/instance-type": z.string().nullish(),
         "porter.run/workload-kind": z.string().nullish(),
         "porter.run/workload-kind": z.string().nullish(),
-    }).optional(),
-}).transform((data) => {
+      })
+      .optional(),
+  })
+  .transform((data) => {
     const defaultResources = {
     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) {
     if (!data.labels) {
-        return defaultResources;
+      return defaultResources;
     }
     }
     const workloadKind = data.labels["porter.run/workload-kind"];
     const workloadKind = data.labels["porter.run/workload-kind"];
     if (!workloadKind || workloadKind !== "application") {
     if (!workloadKind || workloadKind !== "application") {
-        return defaultResources;
+      return defaultResources;
     }
     }
     const instanceType = data.labels["beta.kubernetes.io/instance-type"];
     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) {
     if (!res.success) {
-        return defaultResources;
+      return defaultResources;
     }
     }
     const [instanceClass, instanceSize] = res.data;
     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;
     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
 // 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
 // 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 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 _ from "lodash";
 
 
 import web from "assets/web.png";
 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 worker from "assets/worker.png";
 import job from "assets/job.png";
 import job from "assets/job.png";
-
+import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import WebTabs from "./tabs/WebTabs";
 import WebTabs from "./tabs/WebTabs";
 import WorkerTabs from "./tabs/WorkerTabs";
 import WorkerTabs from "./tabs/WorkerTabs";
 import JobTabs from "./tabs/JobTabs";
 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 { match } from "ts-pattern";
 import useResizeObserver from "lib/hooks/useResizeObserver";
 import useResizeObserver from "lib/hooks/useResizeObserver";
-import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import ServiceStatusFooter from "./ServiceStatusFooter";
 import ServiceStatusFooter from "./ServiceStatusFooter";
 
 
-interface ServiceProps {
+type ServiceProps = {
   index: number;
   index: number;
   service: ClientService;
   service: ClientService;
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
@@ -45,7 +47,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   maxRAM,
   maxRAM,
   clusterContainsGPUNodes,
   clusterContainsGPUNodes,
   internalNetworkingDetails,
   internalNetworkingDetails,
-  clusterIngressIp, 
+  clusterIngressIp,
 }) => {
 }) => {
   const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
   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.trim().length > 0
             ? service.name.value
             ? service.name.value
             : "New Service"}
             : "New Service"}
+          {service.gpuCoresNvidia.value > 0 &&
+            <><Spacer inline x={1.5} /><TagContainer>
+              <ChipIcon src={chip} alt="Chip Icon" />
+              <TagText>GPU Workload</TagText>
+            </TagContainer></>
+          }
         </ServiceTitle>
         </ServiceTitle>
+
         {service.canDelete && (
         {service.canDelete && (
           <ActionButton
           <ActionButton
             onClick={(e) => {
             onClick={(e) => {
@@ -260,3 +269,48 @@ const Icon = styled.img`
   height: 18px;
   height: 18px;
   margin-right: 15px;
   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 worker from "assets/worker.png";
 import job from "assets/job.png";
 import job from "assets/job.png";
 import { z } from "zod";
 import { z } from "zod";
-import { PorterAppFormData } from "lib/porter-apps";
+import { type PorterAppFormData } from "lib/porter-apps";
 import {
 import {
-  ClientService,
+  type ClientService,
   defaultSerialized,
   defaultSerialized,
   deserializeService,
   deserializeService,
   isPredeployService,
   isPredeployService,
@@ -26,7 +26,7 @@ import {
   useFormContext,
   useFormContext,
 } from "react-hook-form";
 } from "react-hook-form";
 import { ControlledInput } from "components/porter/ControlledInput";
 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 { zodResolver } from "@hookform/resolvers/zod";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 
 
@@ -72,7 +72,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
   // top level app form
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
   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
   // add service modal form
   const {
   const {
@@ -233,7 +233,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
       )}
       )}
       {maybeRenderAddServicesButton()}
       {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
       {showAddServiceModal && (
-        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
+        <Modal closeModal={() => { setShowAddServiceModal(false); }} width="500px">
           <Text size={16}>{addNewText}</Text>
           <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
           <Text color="helper">Select a service type:</Text>
@@ -251,7 +251,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 <Select
                 <Select
                   value={serviceType}
                   value={serviceType}
                   width="100%"
                   width="100%"
-                  setValue={(value: string) => onChange(value)}
+                  setValue={(value: string) => { onChange(value); }}
                   options={[
                   options={[
                     { label: "Web", value: "web" },
                     { label: "Web", value: "web" },
                     { label: "Worker", value: "worker" },
                     { 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 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 { 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 { ControlledInput } from "components/porter/ControlledInput";
 import Checkbox from "components/porter/Checkbox";
 import Checkbox from "components/porter/Checkbox";
 import Text from "components/porter/Text";
 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 IntelligentSlider from "./IntelligentSlider";
 import InputSlider from "components/porter/InputSlider";
 import InputSlider from "components/porter/InputSlider";
 import { closestMultiplier, lowestClosestResourceMultipler } from "lib/hooks/useClusterResourceLimits";
 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 = {
 type ResourcesProps = {
   index: number;
   index: number;
@@ -33,6 +39,8 @@ const Resources: React.FC<ResourcesProps> = ({
 }) => {
 }) => {
   const { control, register, watch, setValue } = useFormContext<PorterAppFormData>();
   const { control, register, watch, setValue } = useFormContext<PorterAppFormData>();
   const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
   const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
+  const [clusterModalVisible, setClusterModalVisible] = useState<boolean>(false);
+  const { currentCluster, currentProject } = useContext(Context);
 
 
   const autoscalingEnabled = watch(
   const autoscalingEnabled = watch(
     `app.services.${index}.config.autoscaling.enabled`, {
     `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} />
           <Spacer y={1} />
           <Controller
           <Controller
             name={`app.services.${index}.gpuCoresNvidia`}
             name={`app.services.${index}.gpuCoresNvidia`}
             control={control}
             control={control}
             render={({ field: { value, onChange } }) => (
             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({
                         onChange({
                           ...value,
                           ...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({
                         onChange({
                           ...value,
                           ...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;
 export default Resources;
 
 
 const StyledIcon = styled.i`
 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`
 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 { readableDate } from "shared/string_utils";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import ExpandableSection from "components/porter/ExpandableSection";
 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 Spacer from "components/porter/Spacer";
 import { createPortal } from "react-dom";
 import { createPortal } from "react-dom";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import ConfirmOverlay from "components/ConfirmOverlay";
@@ -25,6 +18,7 @@ type Props = {
   setSelectedClusterVersion: any;
   setSelectedClusterVersion: any;
   setShowProvisionerStatus: any;
   setShowProvisionerStatus: any;
   setProvisionFailureReason: any;
   setProvisionFailureReason: any;
+  gpuModal?: boolean;
 };
 };
 
 
 const ClusterRevisionSelector: React.FC<Props> = ({
 const ClusterRevisionSelector: React.FC<Props> = ({
@@ -32,6 +26,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
   setSelectedClusterVersion,
   setSelectedClusterVersion,
   setShowProvisionerStatus,
   setShowProvisionerStatus,
   setProvisionFailureReason,
   setProvisionFailureReason,
+  gpuModal
 }) => {
 }) => {
   const [showConfirmOverlay, setShowConfirmOverlay] = useState(false);
   const [showConfirmOverlay, setShowConfirmOverlay] = useState(false);
   const { currentProject, currentCluster } = useContext(Context);
   const { currentProject, currentCluster } = useContext(Context);
@@ -108,7 +103,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
         .createContract("<token>", selectedClusterVersion, {
         .createContract("<token>", selectedClusterVersion, {
           project_id: currentProject.id,
           project_id: currentProject.id,
         })
         })
-        .then(() => {})
+        .then(() => { })
         .catch((err) => {
         .catch((err) => {
           console.log(err);
           console.log(err);
         });
         });
@@ -188,7 +183,7 @@ const ClusterRevisionSelector: React.FC<Props> = ({
         <Td>{readableDate(pendingContract.CreatedAt)}</Td>
         <Td>{readableDate(pendingContract.CreatedAt)}</Td>
         {failedContractId && (
         {failedContractId && (
           <DeleteButton>
           <DeleteButton>
-            <div onClick={() => setShowConfirmOverlay(true)}>
+            <div onClick={() => { setShowConfirmOverlay(true); }}>
               Clear Revision
               Clear Revision
             </div>
             </div>
           </DeleteButton>
           </DeleteButton>
@@ -209,74 +204,79 @@ const ClusterRevisionSelector: React.FC<Props> = ({
 
 
   return (
   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"};
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
   :hover {
     background: ${(props: { disabled: boolean }) =>
     background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#405eddbb"};
+    props.disabled ? "" : "#405eddbb"};
   }
   }
 `;
 `;
 
 
@@ -367,7 +367,7 @@ const Tr = styled.tr`
     props.selected ? "#ffffff11" : ""};
     props.selected ? "#ffffff11" : ""};
   :hover {
   :hover {
     background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
     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 = {
 type Props = {
   selectCredential: (aws_integration_id: number) => void;
   selectCredential: (aws_integration_id: number) => void;
   setTargetARN: (target_arn: string) => void;
   setTargetARN: (target_arn: string) => void;
+  gpuModal?: boolean;
 };
 };
 
 
 type AWSCredential = {
 type AWSCredential = {
@@ -27,6 +28,7 @@ type AWSCredential = {
 const AWSCredentialsList: React.FunctionComponent<Props> = ({
 const AWSCredentialsList: React.FunctionComponent<Props> = ({
   selectCredential,
   selectCredential,
   setTargetARN,
   setTargetARN,
+  gpuModal,
 }) => {
 }) => {
   const { currentProject, setCurrentError } = useContext(Context);
   const { currentProject, setCurrentError } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
@@ -97,6 +99,7 @@ const AWSCredentialsList: React.FunctionComponent<Props> = ({
           setTargetARN={setTargetARN}
           setTargetARN={setTargetARN}
           shouldCreateCred={() => setShouldCreateCred(true)}
           shouldCreateCred={() => setShouldCreateCred(true)}
           addNewText="Create new CloudFormation stack"
           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;
   isLink?: boolean;
   linkHref?: string;
   linkHref?: string;
   setTargetARN: (targetARN: string) => void;
   setTargetARN: (targetARN: string) => void;
+  gpuModal?: boolean;
 };
 };
 
 
 type GenericCredential = {
 type GenericCredential = {
@@ -55,7 +56,7 @@ const CredentialList: React.FunctionComponent<Props> = (props) => {
           </PreviewRow>
           </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;
     justify-content: center;
     border-radius: 20px;
     border-radius: 20px;
     background: ${(props: { expanded: boolean }) =>
     background: ${(props: { expanded: boolean }) =>
-      props.expanded ? "#ffffff22" : ""};
+    props.expanded ? "#ffffff22" : ""};
   }
   }
 `;
 `;
 
 
@@ -302,7 +302,7 @@ const NavButton = styled(SidebarLink)`
 
 
   :hover {
   :hover {
     background: ${(props: NavButtonProps) =>
     background: ${(props: NavButtonProps) =>
-      props.active ? "#ffffff11" : "#ffffff08"};
+    props.active ? "#ffffff11" : "#ffffff08"};
   }
   }
 
 
   &.active {
   &.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 { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
 import styled from "styled-components";
+import api from "shared/api";
 
 
 import Modal from "components/porter/Modal";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import ProvisionerSettings from "components/ProvisionerSettings";
 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 AWSCredentialsList from "./AddCluster/AWSCredentialList";
+import { type InfraCredentials } from "shared/types";
+import { z } from "zod";
 
 
 type Props = RouteComponentProps & {
 type Props = RouteComponentProps & {
   closeModal: () => void;
   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 (
   return (
     <Modal closeModal={closeModal} width={"1000px"}>
     <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} />
       <Spacer y={1} />
+
+
       <ScrollableContent>
       <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>
       </ScrollableContent>
-    </Modal>
-  );
-};
+
+
+    </Modal >
+  )
+}
 
 
 export default withRouter(ProvisionClusterModal);
 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 { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
-import { createContext, useContext } from "react";
 import { Context } from "./Context";
 import { Context } from "./Context";
 
 
 export type ClusterResources = {
 export type ClusterResources = {
@@ -32,6 +31,7 @@ const ClusterResourcesProvider = ({ children }: { children: JSX.Element }) => {
   const { maxCPU, maxRAM, defaultCPU, defaultRAM, clusterContainsGPUNodes, clusterIngressIp } = useClusterResourceLimits({
   const { maxCPU, maxRAM, defaultCPU, defaultRAM, clusterContainsGPUNodes, clusterIngressIp } = useClusterResourceLimits({
     projectId: currentProject?.id,
     projectId: currentProject?.id,
     clusterId: currentCluster?.id,
     clusterId: currentCluster?.id,
+    clusterStatus: currentCluster?.status
   });
   });
 
 
   return (
   return (

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

@@ -13,7 +13,8 @@ export type ClusterType = {
   cloud_provider_credential_identifier?: string;
   cloud_provider_credential_identifier?: string;
   status?: string;
   status?: string;
   cloud_provider: string;
   cloud_provider: string;
-};
+  gpuCluster?: boolean;
+}
 
 
 export type AddonCard = {
 export type AddonCard = {
   id: string;
   id: string;
@@ -246,15 +247,15 @@ export type FormElement = {
 export type RepoType = {
 export type RepoType = {
   FullName: string;
   FullName: string;
 } & (
 } & (
-  | {
+    | {
       Kind: "github";
       Kind: "github";
       GHRepoID: number;
       GHRepoID: number;
     }
     }
-  | {
+    | {
       Kind: "gitlab";
       Kind: "gitlab";
       GitIntegrationId: number;
       GitIntegrationId: number;
     }
     }
-);
+  );
 
 
 export type FileType = {
 export type FileType = {
   path: string;
   path: string;
@@ -277,6 +278,7 @@ export type ProjectType = {
   stacks_enabled: boolean;
   stacks_enabled: boolean;
   simplified_view_enabled: boolean;
   simplified_view_enabled: boolean;
   azure_enabled: boolean;
   azure_enabled: boolean;
+  gpu_enabled: boolean;
   helm_values_enabled: boolean;
   helm_values_enabled: boolean;
   multi_cluster: boolean;
   multi_cluster: boolean;
   full_add_ons: boolean;
   full_add_ons: boolean;
@@ -326,15 +328,15 @@ export type ActionConfigType = {
   image_repo_uri: string;
   image_repo_uri: string;
   dockerfile_path?: string;
   dockerfile_path?: string;
 } & (
 } & (
-  | {
+    | {
       kind: "gitlab";
       kind: "gitlab";
       gitlab_integration_id: number;
       gitlab_integration_id: number;
     }
     }
-  | {
+    | {
       kind: "github";
       kind: "github";
       git_repo_id: number;
       git_repo_id: number;
     }
     }
-);
+  );
 
 
 export type GithubActionConfigType = ActionConfigType & {
 export type GithubActionConfigType = ActionConfigType & {
   kind: "github";
   kind: "github";
@@ -674,3 +676,27 @@ export type CreateUpdatePorterAppOptions = {
   override_release?: boolean;
   override_release?: boolean;
   full_helm_values?: string;
   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 shows all addons, not just curated
 	FullAddOns FeatureFlagLabel = "full_add_ons"
 	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 shows the helm values tab for porter apps (when simplified_view_enabled=true)
 	HelmValuesEnabled FeatureFlagLabel = "helm_values_enabled"
 	HelmValuesEnabled FeatureFlagLabel = "helm_values_enabled"
 
 
@@ -77,6 +80,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	EFSEnabled:             false,
 	EFSEnabled:             false,
 	EnableReprovision:      false,
 	EnableReprovision:      false,
 	FullAddOns:             false,
 	FullAddOns:             false,
+	GPUEnabled:             false,
 	HelmValuesEnabled:      false,
 	HelmValuesEnabled:      false,
 	ManagedInfraEnabled:    false,
 	ManagedInfraEnabled:    false,
 	MultiCluster:           false,
 	MultiCluster:           false,
@@ -199,6 +203,8 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return p.EnableReprovision
 			return p.EnableReprovision
 		case "full_add_ons":
 		case "full_add_ons":
 			return p.FullAddOns
 			return p.FullAddOns
+		case "gpu_enabled":
+			return false
 		case "helm_values_enabled":
 		case "helm_values_enabled":
 			return p.HelmValuesEnabled
 			return p.HelmValuesEnabled
 		case "managed_infra_enabled":
 		case "managed_infra_enabled":
@@ -255,6 +261,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		DBEnabled:              p.GetFeatureFlag(DBEnabled, launchDarklyClient),
 		DBEnabled:              p.GetFeatureFlag(DBEnabled, launchDarklyClient),
 		SimplifiedViewEnabled:  p.GetFeatureFlag(SimplifiedViewEnabled, launchDarklyClient),
 		SimplifiedViewEnabled:  p.GetFeatureFlag(SimplifiedViewEnabled, launchDarklyClient),
 		AzureEnabled:           p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
 		AzureEnabled:           p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
+		GPUEnabled:             p.GetFeatureFlag(GPUEnabled, launchDarklyClient),
 		HelmValuesEnabled:      p.GetFeatureFlag(HelmValuesEnabled, launchDarklyClient),
 		HelmValuesEnabled:      p.GetFeatureFlag(HelmValuesEnabled, launchDarklyClient),
 		MultiCluster:           p.GetFeatureFlag(MultiCluster, launchDarklyClient),
 		MultiCluster:           p.GetFeatureFlag(MultiCluster, launchDarklyClient),
 		EnableReprovision:      p.GetFeatureFlag(EnableReprovision, launchDarklyClient),
 		EnableReprovision:      p.GetFeatureFlag(EnableReprovision, launchDarklyClient),