Explorar o código

[POR-1794] Smart opt v2 (#3679)

Co-authored-by: Feroze Mohideen <feroze@porter.run>
sdess09 %!s(int64=2) %!d(string=hai) anos
pai
achega
531a2504c7

+ 144 - 0
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -0,0 +1,144 @@
+import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
+import { useEffect, useState } from "react";
+import convert from "convert";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+import api from "shared/api";
+
+const clusterDataValidator = z.object({
+    labels: z.object({
+        "beta.kubernetes.io/instance-type": z.string().nullish(),
+    }).optional(),
+}).transform((data) => {
+    const defaultResources = {
+        maxCPU: AWS_INSTANCE_LIMITS["t3"]["medium"]["vCPU"],
+        maxRAM: AWS_INSTANCE_LIMITS["t3"]["medium"]["RAM"],
+    };
+    if (!data.labels) {
+        return defaultResources;
+    }
+    const instanceType = data.labels["beta.kubernetes.io/instance-type"];
+    const res = z.tuple([z.string(), z.string()]).safeParse(instanceType)
+    if (!res.success) {
+        return defaultResources;
+    }
+    const [instanceClass, instanceSize] = res.data;
+    if (AWS_INSTANCE_LIMITS[instanceClass] && AWS_INSTANCE_LIMITS[instanceClass][instanceSize]) {
+        const { vCPU, RAM } = AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
+        return {
+            maxCPU: vCPU,
+            maxRAM: RAM,
+        };
+    }
+    return defaultResources;
+});
+export const useClusterResourceLimits = (
+    {
+        projectId,
+        clusterId,
+    }: {
+        projectId: number | undefined,
+        clusterId: number | undefined,
+    }
+) => {
+    const UPPER_BOUND = 0.75;
+
+    const [maxCPU, setMaxCPU] = useState(
+        AWS_INSTANCE_LIMITS["t3"]["medium"]["vCPU"] * UPPER_BOUND
+    ); //default is set to a t3 medium
+    const [maxRAM, setMaxRAM] = useState(
+        // round to nearest 100
+        Math.round(
+            convert(AWS_INSTANCE_LIMITS["t3"]["medium"]["RAM"], "GiB").to("MB") *
+            UPPER_BOUND / 100
+        ) * 100
+    ); //default is set to a t3 medium
+
+    const { data } = useQuery(
+        ["getClusterNodes", projectId, clusterId],
+        async () => {
+            if (!projectId || !clusterId) {
+                return Promise.resolve([]);
+            }
+
+            const res = await api.getClusterNodes(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                }
+            )
+
+            return await z.array(clusterDataValidator).parseAsync(res.data);
+        },
+        {
+            enabled: !!projectId && !!clusterId,
+            refetchOnWindowFocus: false,
+        }
+    );
+
+    useEffect(() => {
+        if (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);
+            // if the instance type has more than 4 GB ram, we use 90% of the ram/cpu
+            // otherwise, we use 75%
+            if (maxRAM > 4) {
+                // round down to nearest 0.5 cores
+                setMaxCPU(Math.floor(maxCPU * 0.9 * 2) / 2);
+                setMaxRAM(
+                    Math.round(
+                        convert(maxRAM, "GiB").to("MB") * 0.9 / 100
+                    ) * 100
+                );
+            } else {
+                setMaxCPU(Math.floor(maxCPU * UPPER_BOUND * 2) / 2);
+                setMaxRAM(
+                    Math.round(
+                        convert(maxRAM, "GiB").to("MB") * UPPER_BOUND / 100
+                    ) * 100
+                );
+            }
+        }
+    }, [data])
+
+
+    return {
+        maxCPU,
+        maxRAM
+    }
+}
+
+// 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];
+
+    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
+}
+
+// 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;
+        }
+    }
+
+    return closestFraction;
+}

+ 11 - 9
dashboard/src/lib/porter-apps/index.ts

@@ -188,7 +188,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
   const { app, source } = data;
 
   const services = app.services.reduce((acc: Record<string, Service>, svc) => {
-    acc[svc.name.value] = serviceProto(serializeService(svc));
+    const serialized = serializeService(svc)
+    const proto = serviceProto(serialized)
+    acc[svc.name.value] = proto
     return acc;
   }, {});
 
@@ -363,15 +365,15 @@ export function clientAppFromProto({
   const predeployOverrides = serializeService(overrides.predeploy);
   const predeploy = proto.predeploy
     ? [
-        deserializeService({
-          service: serializedServiceFromProto({
-            name: "pre-deploy",
-            service: proto.predeploy,
-            isPredeploy: true,
-          }),
-          override: predeployOverrides,
+      deserializeService({
+        service: serializedServiceFromProto({
+          name: "pre-deploy",
+          service: proto.predeploy,
+          isPredeploy: true,
         }),
-      ]
+        override: predeployOverrides,
+      }),
+    ]
     : undefined;
 
   return {

+ 58 - 50
dashboard/src/lib/porter-apps/services.ts

@@ -70,6 +70,7 @@ export const serviceValidator = z.object({
   port: serviceNumberValidator,
   cpuCores: serviceNumberValidator,
   ramMegabytes: serviceNumberValidator,
+  smartOptimization: serviceBooleanValidator.optional(),
   config: z.discriminatedUnion("type", [
     webConfigValidator,
     workerConfigValidator,
@@ -95,30 +96,31 @@ export type SerializedService = {
   port: number;
   cpuCores: number;
   ramMegabytes: number;
+  smartOptimization?: boolean;
   config:
-    | {
-        type: "web";
-        domains: {
-          name: string;
-        }[];
-        autoscaling?: SerializedAutoscaling;
-        healthCheck?: SerializedHealthcheck;
-        private?: boolean;
-      }
-    | {
-        type: "worker";
-        autoscaling?: SerializedAutoscaling;
-      }
-    | {
-        type: "job";
-        allowConcurrent?: boolean;
-        cron: string;
-        suspendCron?: boolean;
-        timeoutSeconds: number;
-      }
-    | {
-        type: "predeploy";
-      };
+  | {
+    type: "web";
+    domains: {
+      name: string;
+    }[];
+    autoscaling?: SerializedAutoscaling;
+    healthCheck?: SerializedHealthcheck;
+    private?: boolean;
+  }
+  | {
+    type: "worker";
+    autoscaling?: SerializedAutoscaling;
+  }
+  | {
+    type: "job";
+    allowConcurrent?: boolean;
+    cron: string;
+    suspendCron?: boolean;
+    timeoutSeconds: number;
+  }
+  | {
+    type: "predeploy";
+  };
 };
 
 export function isPredeployService(service: SerializedService | ClientService) {
@@ -146,6 +148,7 @@ export function defaultSerialized({
     port: 3000,
     cpuCores: 0.1,
     ramMegabytes: 256,
+    smartOptimization: true,
   };
 
   const defaultAutoscaling: SerializedAutoscaling = {
@@ -211,6 +214,7 @@ export function serializeService(service: ClientService): SerializedService {
         port: service.port.value,
         cpuCores: service.cpuCores.value,
         ramMegabytes: service.ramMegabytes.value,
+        smartOptimization: service.smartOptimization?.value,
         config: {
           type: "web" as const,
           autoscaling: serializeAutoscaling({
@@ -232,6 +236,7 @@ export function serializeService(service: ClientService): SerializedService {
         port: service.port.value,
         cpuCores: service.cpuCores.value,
         ramMegabytes: service.ramMegabytes.value,
+        smartOptimization: service.smartOptimization?.value,
         config: {
           type: "worker" as const,
           autoscaling: serializeAutoscaling({
@@ -248,6 +253,7 @@ export function serializeService(service: ClientService): SerializedService {
         port: service.port.value,
         cpuCores: service.cpuCores.value,
         ramMegabytes: service.ramMegabytes.value,
+        smartOptimization: service.smartOptimization?.value,
         config: {
           type: "job" as const,
           allowConcurrent: config.allowConcurrent?.value,
@@ -264,6 +270,7 @@ export function serializeService(service: ClientService): SerializedService {
         instances: service.instances.value,
         port: service.port.value,
         cpuCores: service.cpuCores.value,
+        smartOptimization: service.smartOptimization?.value,
         ramMegabytes: service.ramMegabytes.value,
         config: {
           type: "predeploy" as const,
@@ -296,6 +303,7 @@ export function deserializeService({
       service.ramMegabytes,
       override?.ramMegabytes
     ),
+    smartOptimization: ServiceField.boolean(service.smartOptimization, override?.smartOptimization),
     domainDeletions: [],
   };
 
@@ -334,7 +342,7 @@ export function deserializeService({
           })),
           private:
             typeof config.private === "boolean" ||
-            typeof overrideWebConfig?.private === "boolean"
+              typeof overrideWebConfig?.private === "boolean"
               ? ServiceField.boolean(config.private, overrideWebConfig?.private)
               : undefined,
         },
@@ -365,28 +373,28 @@ export function deserializeService({
           type: "job" as const,
           allowConcurrent:
             typeof config.allowConcurrent === "boolean" ||
-            typeof overrideJobConfig?.allowConcurrent === "boolean"
+              typeof overrideJobConfig?.allowConcurrent === "boolean"
               ? ServiceField.boolean(
-                  config.allowConcurrent,
-                  overrideJobConfig?.allowConcurrent
-                )
+                config.allowConcurrent,
+                overrideJobConfig?.allowConcurrent
+              )
               : ServiceField.boolean(false, undefined),
           cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
           suspendCron:
             typeof config.suspendCron === "boolean" ||
-            typeof overrideJobConfig?.suspendCron === "boolean"
+              typeof overrideJobConfig?.suspendCron === "boolean"
               ? ServiceField.boolean(
-                  config.suspendCron,
-                  overrideJobConfig?.suspendCron
-                )
+                config.suspendCron,
+                overrideJobConfig?.suspendCron
+              )
               : ServiceField.boolean(false, undefined),
           timeoutSeconds:
             config.timeoutSeconds == 0
               ? ServiceField.number(3600, overrideJobConfig?.timeoutSeconds)
               : ServiceField.number(
-                  config.timeoutSeconds,
-                  overrideJobConfig?.timeoutSeconds
-                ),
+                config.timeoutSeconds,
+                overrideJobConfig?.timeoutSeconds
+              ),
         },
       };
     })
@@ -511,22 +519,22 @@ export function serializedServiceFromProto({
     .with({ case: "jobConfig" }, ({ value }) =>
       isPredeploy
         ? {
-            ...service,
-            name,
-            config: {
-              type: "predeploy" as const,
-            },
-          }
+          ...service,
+          name,
+          config: {
+            type: "predeploy" as const,
+          },
+        }
         : {
-            ...service,
-            name,
-            config: {
-              type: "job" as const,
-              ...value,
-              allowConcurrent: value.allowConcurrentOptional,
-              timeoutSeconds: Number(value.timeoutSeconds),
-            },
-          }
+          ...service,
+          name,
+          config: {
+            type: "job" as const,
+            ...value,
+            allowConcurrent: value.allowConcurrentOptional,
+            timeoutSeconds: Number(value.timeoutSeconds),
+          },
+        }
     )
     .exhaustive();
 }

+ 8 - 5
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -34,6 +34,7 @@ import { PorterApp } from "@porter-dev/api-contracts";
 import JobsTab from "./tabs/JobsTab";
 import ConfirmRedeployModal from "./ConfirmRedeployModal";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -80,6 +81,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     deploymentTargetID: deploymentTarget.id,
   });
 
+  const { maxCPU, maxRAM } = useClusterResourceLimits({ projectId, clusterId });
+
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
       return tabParam as ValidTab;
@@ -295,8 +298,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       app: clientAppFromProto({
         proto: previewRevision
           ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
-              ignoreUnknownFields: true,
-            })
+            ignoreUnknownFields: true,
+          })
           : latestProto,
         overrides: servicesFromYaml,
         variables: appEnv?.variables,
@@ -322,8 +325,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       app: clientAppFromProto({
         proto: previewRevision
           ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
-              ignoreUnknownFields: true,
-            })
+            ignoreUnknownFields: true,
+          })
           : latestProto,
         overrides: servicesFromYaml,
         variables: appEnv?.variables,
@@ -419,7 +422,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         <Spacer y={1} />
         {match(currentTab)
           .with("activity", () => <Activity />)
-          .with("overview", () => <Overview />)
+          .with("overview", () => <Overview maxCPU={maxCPU} maxRAM={maxRAM} />)
           .with("build-settings", () => <BuildSettings />)
           .with("environment", () => (
             <Environment latestSource={latestSource} />

+ 9 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -14,7 +14,11 @@ import Button from "components/porter/Button";
 import { useLatestRevision } from "../LatestRevisionContext";
 import { useAppStatus } from "lib/hooks/useAppStatus";
 
-const Overview: React.FC = () => {
+type Props = {
+  maxCPU: number;
+  maxRAM: number;
+}
+const Overview: React.FC<Props> = ({ maxCPU, maxRAM }) => {
   const { formState } = useFormContext<PorterAppFormData>();
   const {
     porterApp,
@@ -62,6 +66,8 @@ const Overview: React.FC = () => {
             existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
             isPredeploy
             fieldArrayName={"app.predeploy"}
+            maxCPU={maxCPU}
+            maxRAM={maxRAM}
           />
           <Spacer y={0.5} />
         </>
@@ -73,6 +79,8 @@ const Overview: React.FC = () => {
         fieldArrayName={"app.services"}
         existingServiceNames={Object.keys(latestProto.services)}
         serviceVersionStatus={serviceVersionStatus}
+        maxCPU={maxCPU}
+        maxRAM={maxRAM}
       />
       <Spacer y={0.75} />
       <Button

+ 11 - 3
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -49,6 +49,7 @@ import {
   populatedEnvGroup,
 } from "../validate-apply/app-settings/types";
 import EnvSettings from "../validate-apply/app-settings/EnvSettings";
+import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -78,6 +79,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     variables: {},
     secrets: {},
   });
+  const { maxCPU, maxRAM } = useClusterResourceLimits({
+    projectId: currentProject?.id,
+    clusterId: currentCluster?.id,
+  })
 
   const { data: porterApps = [] } = useQuery<string[]>(
     ["getPorterApps", currentProject?.id, currentCluster?.id],
@@ -604,9 +609,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                             }
                           >
                             {detectedServices.count > 0
-                              ? `Detected ${detectedServices.count} service${
-                                  detectedServices.count > 1 ? "s" : ""
-                                } from porter.yaml.`
+                              ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : ""
+                              } from porter.yaml.`
                               : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
                           </Text>
                         </AppearingDiv>
@@ -616,6 +620,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <ServiceList
                       addNewText={"Add a new service"}
                       fieldArrayName={"app.services"}
+                      maxCPU={maxCPU}
+                      maxRAM={maxRAM}
                     />
                   </>,
                   <>
@@ -647,6 +653,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                         })}
                         isPredeploy
                         fieldArrayName={"app.predeploy"}
+                        maxCPU={maxCPU}
+                        maxRAM={maxRAM}
                       />
                     </>
                   ),

+ 2 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/tabs/NodeInfoModal.tsx

@@ -20,12 +20,12 @@ const NodeInfoModal: React.FC<Props> = ({
             <Text size={16}>Resource Optimization on Porter</Text>
             <Spacer y={1} />
             <Text color="helper">
-                The recommended marks are so that the application can run cost-efficiently on Porter.
+                Using the recommended marks ensures that your service runs cost-efficiently on Porter.
             </Text>
             <Spacer y={1} />
             <Text color="helper">
                 <Link to="https://docs.porter.run/other/kubernetes-101" target="_blank">
-                    For more information about Kubernetes resource management visit our docs.
+                    For more information about Kubernetes resource management, visit our docs.
                 </Link>
             </Text>
 

+ 2 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/tabs/SmartOptModal.tsx

@@ -1,5 +1,5 @@
 
-import React, { useEffect, useRef, useState } from "react";
+import React from "react";
 import Modal from "components/porter/Modal";
 
 import Text from "components/porter/Text";
@@ -40,7 +40,7 @@ const SmartOptModal: React.FC<Props> = ({
             <Spacer y={1} />
             <Text color="helper">
                 <Link to="https://docs.porter.run/other/kubernetes-101" target="_blank">
-                    For more information about Kubernetes resource management visit our docs.
+                    For more information about Kubernetes resource management, visit our docs.
                 </Link>
             </Text>
 

+ 1 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/tabs/utils.ts

@@ -84,5 +84,4 @@ export const AWS_INSTANCE_LIMITS: InstanceTypes = {
 }
 
 
-export const UPPER_BOUND_SMART = .5
-export const RESOURCE_ALLOCATION_RAM = 1
+export const UPPER_BOUND_SMART = .5;

+ 5 - 106
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -1,8 +1,7 @@
-import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
 import AnimateHeight, { Height } from "react-animate-height";
 import styled from "styled-components";
 import _ from "lodash";
-import convert from "convert";
 
 import web from "assets/web.png";
 import worker from "assets/worker.png";
@@ -12,10 +11,6 @@ import Spacer from "components/porter/Spacer";
 import WebTabs from "./tabs/WebTabs";
 import WorkerTabs from "./tabs/WorkerTabs";
 import JobTabs from "./tabs/JobTabs";
-import { Context } from "shared/Context";
-import { AWS_INSTANCE_LIMITS } from "./tabs/utils";
-import api from "shared/api";
-import StatusFooter from "../../expanded-app/StatusFooter";
 import { ClientService } from "lib/porter-apps/services";
 import { UseFieldArrayUpdate } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
@@ -30,6 +25,8 @@ interface ServiceProps {
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services" | "app.predeploy">;
   remove: (index: number) => void;
   status?: PorterAppVersionStatus[];
+  maxCPU: number;
+  maxRAM: number;
 }
 
 const ServiceContainer: React.FC<ServiceProps> = ({
@@ -38,23 +35,11 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   update,
   remove,
   status,
+  maxCPU,
+  maxRAM,
 }) => {
   const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
 
-  const UPPER_BOUND = 0.75;
-
-  const [maxCPU, setMaxCPU] = useState(
-    AWS_INSTANCE_LIMITS["t3"]["medium"]["vCPU"] * UPPER_BOUND
-  ); //default is set to a t3 medium
-  const [maxRAM, setMaxRAM] = useState(
-    // round to 100
-    Math.round(
-      convert(AWS_INSTANCE_LIMITS["t3"]["medium"]["RAM"], "GiB").to("MB") *
-      UPPER_BOUND / 100
-    ) * 100
-  ); //default is set to a t3 medium
-  const context = useContext(Context);
-
   // onResize is called when the height of the service container changes
   // used to set the height of the AnimateHeight component on tab swtich
   const onResize = useCallback(
@@ -75,92 +60,6 @@ const ServiceContainer: React.FC<ServiceProps> = ({
     }
   }, [service.expanded]);
 
-  useEffect(() => {
-    const { currentCluster, currentProject } = context;
-    if (!currentCluster || !currentProject) {
-      return;
-    }
-    var instanceType = "";
-
-
-    // need to fix the below to not use chart
-    // if (service) {
-    //   //first check if there is a nodeSelector for the given application (Can be null)
-    //   if (
-    //     chart?.config?.[`${service.name.value}-${service.config.type}`]
-    //       ?.nodeSelector?.["beta.kubernetes.io/instance-type"]
-    //   ) {
-    //     instanceType =
-    //       chart?.config?.[`${service.name.value}-${service.config.type}`]
-    //         ?.nodeSelector?.["beta.kubernetes.io/instance-type"];
-    //     const [instanceClass, instanceSize] = instanceType.split(".");
-    //     const currentInstance =
-    //       AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
-    //     setMaxCPU(currentInstance.vCPU * UPPER_BOUND);
-    //     setMaxRAM(currentInstance.RAM * UPPER_BOUND);
-    //   }
-    // }
-    //Query the given nodes if no instance type is specified
-    if (instanceType == "") {
-      api
-        .getClusterNodes(
-          "<token>",
-          {},
-          {
-            cluster_id: currentCluster.id,
-            project_id: currentProject.id,
-          }
-        )
-        .then(({ data }) => {
-          if (data) {
-            let largestInstanceType = {
-              vCPUs: 2,
-              RAM: 4,
-            };
-
-            data.forEach((node: any) => {
-              if (node.labels["porter.run/workload-kind"] == "application") {
-                var instanceType: string =
-                  node.labels["beta.kubernetes.io/instance-type"];
-                const [instanceClass, instanceSize] = instanceType.split(".");
-                if (instanceClass && instanceSize) {
-                  if (
-                    AWS_INSTANCE_LIMITS[instanceClass] &&
-                    AWS_INSTANCE_LIMITS[instanceClass][instanceSize]
-                  ) {
-                    let currentInstance =
-                      AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
-                    largestInstanceType.vCPUs = currentInstance.vCPU;
-                    largestInstanceType.RAM = currentInstance.RAM;
-                  }
-                }
-              }
-            });
-
-            // if the instance type has more than 4 GB ram, we use 90% of the ram/cpu
-            // otherwise, we use 75%
-            if (largestInstanceType.RAM > 4) {
-              // round down to nearest 0.5 cores
-              setMaxCPU(Math.floor(largestInstanceType.vCPUs * 0.9 * 2) / 2);
-              setMaxRAM(
-                Math.round(
-                  convert(largestInstanceType.RAM, "GiB").to("MB") * 0.9 / 100
-                ) * 100
-              );
-            } else {
-              setMaxCPU(Math.floor(largestInstanceType.vCPUs * UPPER_BOUND * 2) / 2);
-              setMaxRAM(
-                Math.round(
-                  convert(largestInstanceType.RAM, "GiB").to("MB") * UPPER_BOUND / 100
-                ) * 100
-              );
-            }
-          }
-        })
-        .catch((error) => { });
-    }
-  }, []);
-
   const renderTabs = (service: ClientService) => {
     return match(service)
       .with({ config: { type: "web" } }, (svc) => (

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

@@ -48,6 +48,8 @@ type ServiceListProps = {
   existingServiceNames?: string[];
   fieldArrayName: "app.services" | "app.predeploy";
   serviceVersionStatus?: Record<string, PorterAppVersionStatus[]>;
+  maxCPU: number;
+  maxRAM: number;
 };
 
 const ServiceList: React.FC<ServiceListProps> = ({
@@ -57,6 +59,8 @@ const ServiceList: React.FC<ServiceListProps> = ({
   existingServiceNames = [],
   fieldArrayName,
   serviceVersionStatus,
+  maxCPU,
+  maxRAM,
 }) => {
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
@@ -178,6 +182,8 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 update={update}
                 remove={onRemove}
                 status={serviceVersionStatus?.[svc.name.value]}
+                maxCPU={maxCPU}
+                maxRAM={maxRAM}
               />
             ) : null;
           })}

+ 347 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/IntelligentSlider.tsx

@@ -0,0 +1,347 @@
+import React, { useMemo, useState } from 'react';
+import Slider, { Mark } from '@material-ui/core/Slider';
+import Tooltip from '@material-ui/core/Tooltip';
+import styled from 'styled-components';
+import { withStyles } from '@material-ui/core/styles';
+import Spacer from 'components/porter/Spacer';
+import NodeInfoModal from 'main/home/app-dashboard/new-app-flow/tabs/NodeInfoModal';
+
+const SMART_LIMIT_FRACTION = 0.5;
+
+type IntelligentSliderProps = {
+    label?: string;
+    unit?: string;
+    min: number;
+    max: number;
+    value: string;
+    setValue: (value: number) => void;
+    disabled?: boolean;
+    disabledTooltip?: string;
+    color?: string;
+    width?: string;
+    step?: number;
+    isSmartOptimizationOn: boolean;
+    decimalsToRoundTo?: number;
+};
+
+const ValueLabelComponent: React.FC<any> = (props) => {
+    const { children, value } = props;
+
+    return (
+        <StyledTooltip
+            placement="bottom"
+            title={value}
+            arrow
+        >
+            {children}
+        </StyledTooltip>
+    );
+}
+
+const IntelligentSlider: React.FC<IntelligentSliderProps> = ({
+    label,
+    unit,
+    min,
+    max,
+    value,
+    setValue,
+    disabled,
+    disabledTooltip,
+    color,
+    step,
+    width,
+    isSmartOptimizationOn,
+    decimalsToRoundTo = 0,
+}) => {
+    const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
+
+    const marks: Mark[] = useMemo(() => {
+        const marks: Mark[] = [
+            {
+                value: max,
+                label: max.toString(),
+            },
+            {
+                value: min,
+                label: min.toString(),
+            }
+        ];
+
+        if (isSmartOptimizationOn) {
+            const half = Number((min + (max - min) * 0.5).toFixed(decimalsToRoundTo));
+            const quarter = Number((min + (max - min) * 0.25).toFixed(decimalsToRoundTo));
+            const eighth = Number((min + (max - min) * 0.125).toFixed(decimalsToRoundTo));
+
+            marks.push(
+                {
+                    value: half,
+                    label: half.toString(),
+                },
+                {
+                    value: quarter,
+                    label: quarter.toString(),
+                },
+                {
+                    value: eighth,
+                    label: eighth.toString(),
+                },
+            );
+        }
+
+        return marks;
+    }, [isSmartOptimizationOn, min, max, decimalsToRoundTo]);
+
+    const displayOptimalText = useMemo(() => {
+        const half = Number((min + (max - min) * 0.5).toFixed(decimalsToRoundTo));
+        const quarter = Number((min + (max - min) * 0.25).toFixed(decimalsToRoundTo));
+        const eighth = Number((min + (max - min) * 0.125).toFixed(decimalsToRoundTo));
+
+        return isSmartOptimizationOn && (Number(value) === quarter || Number(value) === eighth || Number(value) === half);
+    }, [value, min, max, isSmartOptimizationOn]);
+
+    const smartLimit = useMemo(() => {
+        return Number((min + (max - min) * SMART_LIMIT_FRACTION).toFixed(decimalsToRoundTo));
+    }, [min, max]);
+
+    const isExceedingLimit = useMemo(() => {
+        return isSmartOptimizationOn && Number(value) > smartLimit;
+    }, [value, min, max, isSmartOptimizationOn]);
+
+    const getClosestMark = (value: string, marks: Mark[]) => {
+        return marks.reduce((prev, curr) => (
+            Math.abs(curr.value - Number(value)) < Math.abs(prev.value - Number(value)) ? curr : prev
+        )).value;
+    };
+
+    return (
+        <SliderContainer width={width}>
+            <LabelContainer>
+                {label && <Label>{label}</Label>}
+                <Value>{`${value} ${unit}`}</Value>
+                {displayOptimalText &&
+                    <>
+                        <Spacer inline x={1} /><Label>Recommended based on the available compute </Label>  <StyledIcon
+                            className="material-icons"
+                            onClick={() => {
+                                setShowNeedHelpModal(true)
+                            }}
+                        >
+                            help_outline
+                        </StyledIcon>
+                    </>
+                }
+                {showNeedHelpModal &&
+                    <NodeInfoModal
+                        setModalVisible={setShowNeedHelpModal}
+                    />
+                }
+                {isExceedingLimit &&
+                    <>
+                        <Spacer inline x={1} />
+                        <Label color="#FFBF00"> Value is not optimal for cost</Label>
+                    </>
+                }
+            </LabelContainer>
+
+            <DisabledTooltip title={disabled ? disabledTooltip || '' : ''} arrow>
+                <div style={{ position: 'relative' }}>
+                    <MaxedOutToolTip title={Number(value) === smartLimit && isSmartOptimizationOn ? "Using resources beyond this limit is not cost-optimal - to override, toggle off Smart Optimization" : ""} arrow>
+                        <div style={{ position: 'relative' }}>
+                            <StyledSlider
+                                ValueLabelComponent={ValueLabelComponent}
+                                aria-label="input slider"
+                                isExceedingLimit={isExceedingLimit}
+                                min={min}
+                                max={max}
+                                value={(Number(value))}
+                                onChange={(_, newValue) => {
+                                    if (!Array.isArray(newValue)) {
+                                        if (isSmartOptimizationOn) {
+                                            if (newValue > smartLimit) {
+                                                return; // can't go beyond the limit
+                                            }
+                                            const closestMark = getClosestMark(newValue.toString(), marks);
+                                            setValue(closestMark);
+                                        } else {
+                                            setValue(newValue);
+                                        }
+                                    }
+                                }}
+                                classes={{
+                                    track: isExceedingLimit ? 'exceeds-limit' : '',
+                                    rail: isExceedingLimit ? 'exceeds-limit' : ''
+                                }}
+                                valueLabelDisplay={smartLimit && Number(value) > smartLimit ? "off" : "auto"}
+                                disabled={disabled}
+                                marks={marks}
+                                step={(step ? step : 1)}
+                                style={{
+                                    color: disabled ? "gray" : color,
+                                }}
+                            />
+                        </div>
+                    </MaxedOutToolTip>
+                    {disabled && (
+                        <div
+                            style={{
+                                position: 'absolute',
+                                top: 0,
+                                left: 0,
+                                right: 0,
+                                bottom: 0,
+                                cursor: 'not-allowed',
+                                zIndex: 1
+                            }}
+                        />
+                    )}
+                </div>
+            </DisabledTooltip>
+
+        </SliderContainer >
+    );
+};
+
+
+export default IntelligentSlider;
+
+const SliderContainer = styled.div<{ width?: string }>`
+  width: ${({ width }) => width || '90%'};
+  margin: 1px 0;
+`;
+
+const Label = styled.div<{ color?: string }>`
+  font-size: 13px;
+  margin-right: 5px;
+  margin-bottom: 10px;
+  color: ${props => props.color ? props.color : '#aaaabb'};
+`;
+
+const Value = styled.div<{ color?: string }>`
+  font-size: 13px;
+  margin-bottom: 10px;
+  color: #ffff;
+`;
+
+const DisabledTooltip = withStyles(theme => ({
+    tooltip: {
+        backgroundColor: '#333',
+        color: '#fff',
+        padding: '8px',
+        borderRadius: '4px',
+        fontSize: '14px',
+        textAlign: 'center',
+        whiteSpace: 'pre-wrap',
+        wordWrap: 'break-word',
+        maxWidth: '200px',
+        width: '200px',
+        [theme.breakpoints.up('sm')]: {
+            margin: '0 14px',
+        },
+    },
+    arrow: {
+        color: '#333',
+    },
+}))(Tooltip);
+
+const MaxedOutToolTip = withStyles(theme => ({
+    tooltip: {
+        backgroundColor: '#333',
+        color: '#fff',
+        padding: '5px',
+        borderRadius: '2px',
+        fontSize: '12px',
+        textAlign: 'center',
+        whiteSpace: 'pre-wrap',
+        wordWrap: 'break-word',
+        maxWidth: '200px',
+        width: '200px',
+        [theme.breakpoints.up('sm')]: {
+            margin: '0 2px',
+        },
+    },
+}))(Tooltip);
+
+const StyledSlider = withStyles({
+    root: {
+        height: '8px', //height of the track
+    },
+    mark: {
+        backgroundColor: '#fff',  // mark color
+        height: 4, // size of the mark
+        width: 1, // size of the mark
+        borderRadius: '50%',
+        marginTop: 6,
+        marginLeft: -1,
+    },
+    markActive: {
+        backgroundColor: '#fff',
+    },
+    markLabel: {
+        color: '#6e717d',
+        fontSize: '12px',
+        marginRight: 5,
+        '&[data-mark-value="Recommended"]': { // targeting the Recommended label
+            transform: 'translateY(-100%)', // move it upwards
+            marginBottom: '15px', // adjust the margin to position it
+        },
+    },
+    markLabelActive: {
+        color: '#6e717d',
+        marginRight: 5,
+    },
+    thumb: {
+        height: 16, // Size of the thumb
+        width: 16, // Size of the thumb
+        backgroundColor: '#fff',
+        border: '2px solid currentColor',
+        '&:focus, &:hover, &$active': {
+            boxShadow: 'inherit',
+        },
+        '&$disabled': { // Targeting the thumb when the slider is disabled
+            height: 16,
+            width: 16,
+        },
+    },
+    track: (props) => ({
+        height: 8,
+        borderRadius: 4,
+        backgroundColor: props.isExceedingLimit ? '#FFBF00' : '',  // setting color conditionally
+    }),
+    rail: (props) => ({
+        height: 8,
+        borderRadius: 4,
+        backgroundColor: props.isExceedingLimit ? '#FFBF00' : '',  // setting color conditionally
+    }),
+    valueLabel: {
+        top: -22,
+        '& *': {
+            background: 'transparent',
+            border: 'none', // remove the default border
+        },
+    }
+    ,
+    disabled: {},
+})(Slider);
+
+
+const StyledTooltip = withStyles({
+    tooltip: {
+        fontSize: 12,
+        padding: "5px 10px",
+
+    }
+})(Tooltip);
+
+const LabelContainer = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StyledIcon = styled.i`
+  cursor: pointer;
+  font-size: 16px; 
+  margin-bottom : 10px;
+  &:hover {
+    color: #666;  
+  }
+`;

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

@@ -1,13 +1,18 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
 import Spacer from "components/porter/Spacer";
 import { ClientService } from "lib/porter-apps/services";
 import { Controller, useFormContext } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
-import InputSlider from "components/porter/InputSlider";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Checkbox from "components/porter/Checkbox";
 import Text from "components/porter/Text";
 import { match } from "ts-pattern";
+import styled from "styled-components";
+import { Switch } from "@material-ui/core";
+import SmartOptModal from "main/home/app-dashboard/new-app-flow/tabs/SmartOptModal";
+import IntelligentSlider from "./IntelligentSlider";
+import InputSlider from "components/porter/InputSlider";
+import { closestMultiplier, lowestClosestResourceMultipler } from "lib/hooks/useClusterResourceLimits";
 
 type ResourcesProps = {
   index: number;
@@ -24,15 +29,74 @@ const Resources: React.FC<ResourcesProps> = ({
   service,
   isPredeploy = false,
 }) => {
-  const { control, register, watch } = useFormContext<PorterAppFormData>();
+  const { control, register, watch, setValue } = useFormContext<PorterAppFormData>();
+  const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
 
   const autoscalingEnabled = watch(
     `app.services.${index}.config.autoscaling.enabled`
   );
 
+  const smartOpt = watch(
+    `app.services.${index}.smartOptimization`
+  );
+
+  const memory = watch(
+    `app.services.${index}.ramMegabytes`
+  );
+  const cpu = watch(
+    `app.services.${index}.cpuCores`
+  );
+
   return (
     <>
       <Spacer y={1} />
+      <Controller
+        name={isPredeploy ? `app.predeploy.${index}.smartOptimization` : `app.services.${index}.smartOptimization`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <SmartOptHeader>
+            <StyledIcon
+              className="material-icons"
+              onClick={() => {
+                setShowNeedHelpModal(true)
+              }}
+            >
+              help_outline
+            </StyledIcon>
+            <Text>Smart Optimization</Text>
+            <Switch
+              size="small"
+              color="primary"
+              disabled={memory.readOnly || cpu.readOnly || service.smartOptimization?.readOnly}
+              checked={value?.value}
+              onChange={
+                () => {
+                  if (!value?.value) {
+                    const lowestRAM = lowestClosestResourceMultipler(0, maxRAM, memory.value);
+                    const lowestCPU = lowestClosestResourceMultipler(0, maxCPU, cpu.value);
+                    const lowestFraction = Math.min(lowestRAM, lowestCPU);
+                    setValue(`app.services.${index}.cpuCores`, {
+                      readOnly: false,
+                      value: Number((maxCPU * lowestFraction).toFixed(2))
+                    });
+                    setValue(`app.services.${index}.ramMegabytes`, {
+                      readOnly: false,
+                      value: maxRAM * lowestFraction
+                    });
+                  }
+                  onChange({
+                    ...value,
+                    value: !value?.value,
+                  });
+                }
+              }
+              inputProps={{ 'aria-label': 'controlled' }}
+            />
+          </SmartOptHeader>)} />
+      {showNeedHelpModal &&
+        <SmartOptModal
+          setModalVisible={setShowNeedHelpModal}
+        />}
       <Controller
         name={
           isPredeploy
@@ -41,14 +105,21 @@ const Resources: React.FC<ResourcesProps> = ({
         }
         control={control}
         render={({ field: { value, onChange } }) => (
-          <InputSlider
+          <IntelligentSlider
             label="CPUs: "
             unit="Cores"
             min={0}
             max={maxCPU}
-            color={"#3a48ca"}
+            color={"#3f51b5"}
             value={value.value.toString()}
             setValue={(e) => {
+              if (smartOpt?.value) {
+                setValue(
+                  `app.services.${index}.ramMegabytes`, {
+                  readOnly: false,
+                  value: closestMultiplier(0, maxCPU, value.value) * maxRAM
+                });
+              }
               onChange({
                 ...value,
                 value: e,
@@ -59,6 +130,8 @@ const Resources: React.FC<ResourcesProps> = ({
             disabledTooltip={
               "You may only edit this field in your porter.yaml."
             }
+            isSmartOptimizationOn={smartOpt?.value ?? false}
+            decimalsToRoundTo={2}
           />
         )}
       />
@@ -71,14 +144,20 @@ const Resources: React.FC<ResourcesProps> = ({
         }
         control={control}
         render={({ field: { value, onChange } }) => (
-          <InputSlider
+          <IntelligentSlider
             label="RAM: "
             unit="MB"
             min={0}
             max={maxRAM}
-            color={"#3a48ca"}
-            value={value.value.toString()}
+            color={"#3f51b5"}
+            value={(value.value).toString()}
             setValue={(e) => {
+              if (smartOpt?.value) {
+                setValue(`app.services.${index}.cpuCores`, {
+                  readOnly: false,
+                  value: Number((closestMultiplier(0, maxRAM, value.value) * maxCPU).toFixed(2))
+                })
+              }
               onChange({
                 ...value,
                 value: e,
@@ -89,6 +168,7 @@ const Resources: React.FC<ResourcesProps> = ({
             disabledTooltip={
               "You may only edit this field in your porter.yaml."
             }
+            isSmartOptimizationOn={smartOpt?.value ?? false}
           />
         )}
       />
@@ -102,14 +182,12 @@ const Resources: React.FC<ResourcesProps> = ({
               type="text"
               label="Instances"
               placeholder="ex: 1"
-              disabled={
-                service.instances.readOnly ?? config.autoscaling?.enabled
-              }
+              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 replicas."
+                  : "Disable autoscaling to specify instances."
               }
               {...register(`app.services.${index}.instances.value`)}
             />
@@ -241,3 +319,18 @@ const Resources: React.FC<ResourcesProps> = ({
 };
 
 export default Resources;
+
+const StyledIcon = styled.i`
+  cursor: pointer;
+  font-size: 16px; 
+  margin-right : 5px;
+  &:hover {
+    color: #666;  
+  }
+`;
+
+const SmartOptHeader = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+`

+ 20 - 18
internal/porter_app/v2/yaml.go

@@ -90,18 +90,19 @@ type Build struct {
 
 // Service represents a single service in a porter app
 type Service struct {
-	Run             string       `yaml:"run"`
-	Type            string       `yaml:"type" validate:"required, oneof=web worker job"`
-	Instances       int          `yaml:"instances"`
-	CpuCores        float32      `yaml:"cpuCores"`
-	RamMegabytes    int          `yaml:"ramMegabytes"`
-	Port            int          `yaml:"port"`
-	Autoscaling     *AutoScaling `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
-	Domains         []Domains    `yaml:"domains" validate:"excluded_unless=Type web"`
-	HealthCheck     *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
-	AllowConcurrent bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
-	Cron            string       `yaml:"cron" validate:"excluded_unless=Type job"`
-	Private         *bool        `yaml:"private" validate:"excluded_unless=Type web"`
+	Run               string       `yaml:"run"`
+	Type              string       `yaml:"type" validate:"required, oneof=web worker job"`
+	Instances         int          `yaml:"instances"`
+	CpuCores          float32      `yaml:"cpuCores"`
+	RamMegabytes      int          `yaml:"ramMegabytes"`
+	SmartOptimization *bool        `yaml:"smartOptimization"`
+	Port              int          `yaml:"port"`
+	Autoscaling       *AutoScaling `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
+	Domains           []Domains    `yaml:"domains" validate:"excluded_unless=Type web"`
+	HealthCheck       *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
+	AllowConcurrent   bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
+	Cron              string       `yaml:"cron" validate:"excluded_unless=Type job"`
+	Private           *bool        `yaml:"private" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -220,12 +221,13 @@ func protoEnumFromType(name string, service Service) porterv1.ServiceType {
 
 func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
 	serviceProto := &porterv1.Service{
-		Run:          service.Run,
-		Type:         serviceType,
-		Instances:    int32(service.Instances),
-		CpuCores:     service.CpuCores,
-		RamMegabytes: int32(service.RamMegabytes),
-		Port:         int32(service.Port),
+		Run:               service.Run,
+		Type:              serviceType,
+		Instances:         int32(service.Instances),
+		CpuCores:          service.CpuCores,
+		RamMegabytes:      int32(service.RamMegabytes),
+		Port:              int32(service.Port),
+		SmartOptimization: service.SmartOptimization,
 	}
 
 	switch serviceType {