Sfoglia il codice sorgente

updated service form fields (#3396)

ianedwards 2 anni fa
parent
commit
0f20e96456
19 ha cambiato i file con 1672 aggiunte e 53 eliminazioni
  1. 18 7
      dashboard/package-lock.json
  2. 2 1
      dashboard/package.json
  3. 35 0
      dashboard/src/lib/hooks/useResizeObserver.ts
  4. 4 0
      dashboard/src/lib/porter-apps/index.ts
  5. 43 8
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  6. 6 7
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx
  7. 356 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx
  8. 316 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx
  9. 105 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/CustomDomains.tsx
  10. 81 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Health.tsx
  11. 99 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/JobTabs.tsx
  12. 71 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx
  13. 112 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Networking.tsx
  14. 233 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx
  15. 59 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/WebTabs.tsx
  16. 51 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/WorkerTabs.tsx
  17. 81 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/utils.ts
  18. 0 25
      package-lock.json
  19. 0 5
      package.json

+ 18 - 7
dashboard/package-lock.json

@@ -34,6 +34,7 @@
         "chroma-js": "^2.4.2",
         "clipboard": "^2.0.8",
         "color": "^4.2.3",
+        "convert": "^4.13.1",
         "core-js": "^3.16.1",
         "cron-parser": "^4.3.0",
         "cron-validator": "^1.3.1",
@@ -53,7 +54,7 @@
         "random-word-slugs": "^0.1.6",
         "react": "^18.0.0",
         "react-ace": "^8.1.0",
-        "react-animate-height": "^3.1.1",
+        "react-animate-height": "^3.2.2",
         "react-color": "^2.19.3",
         "react-datepicker": "^4.8.0",
         "react-diff-viewer": "^3.1.1",
@@ -5436,6 +5437,11 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/convert": {
+      "version": "4.13.1",
+      "resolved": "https://registry.npmjs.org/convert/-/convert-4.13.1.tgz",
+      "integrity": "sha512-gr79Kvys+6BtZi0Um3GsUKml1D+q7jvbHyPlHkcpV7982lHa8Ml4bWEvjeBjo/KSjYxxixHreZBHgkPXbMxdDg=="
+    },
     "node_modules/convert-source-map": {
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -10836,9 +10842,9 @@
       }
     },
     "node_modules/react-animate-height": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.1.1.tgz",
-      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ==",
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.2.2.tgz",
+      "integrity": "sha512-uUOS+RhYVgyJEWcuAJgelVwhcJ2chsMk7HZCpu+wtjSlFAGSFsHU0r4lMTt47HQ1RdQfI5MmFRt43yHTP9lfmQ==",
       "engines": {
         "node": ">= 12.0.0"
       },
@@ -19294,6 +19300,11 @@
       "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
       "dev": true
     },
+    "convert": {
+      "version": "4.13.1",
+      "resolved": "https://registry.npmjs.org/convert/-/convert-4.13.1.tgz",
+      "integrity": "sha512-gr79Kvys+6BtZi0Um3GsUKml1D+q7jvbHyPlHkcpV7982lHa8Ml4bWEvjeBjo/KSjYxxixHreZBHgkPXbMxdDg=="
+    },
     "convert-source-map": {
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -23583,9 +23594,9 @@
       }
     },
     "react-animate-height": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.1.1.tgz",
-      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ=="
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.2.2.tgz",
+      "integrity": "sha512-uUOS+RhYVgyJEWcuAJgelVwhcJ2chsMk7HZCpu+wtjSlFAGSFsHU0r4lMTt47HQ1RdQfI5MmFRt43yHTP9lfmQ=="
     },
     "react-beautiful-dnd": {
       "version": "13.1.1",

+ 2 - 1
dashboard/package.json

@@ -29,6 +29,7 @@
     "chroma-js": "^2.4.2",
     "clipboard": "^2.0.8",
     "color": "^4.2.3",
+    "convert": "^4.13.1",
     "core-js": "^3.16.1",
     "cron-parser": "^4.3.0",
     "cron-validator": "^1.3.1",
@@ -48,7 +49,7 @@
     "random-word-slugs": "^0.1.6",
     "react": "^18.0.0",
     "react-ace": "^8.1.0",
-    "react-animate-height": "^3.1.1",
+    "react-animate-height": "^3.2.2",
     "react-color": "^2.19.3",
     "react-datepicker": "^4.8.0",
     "react-diff-viewer": "^3.1.1",

+ 35 - 0
dashboard/src/lib/hooks/useResizeObserver.ts

@@ -0,0 +1,35 @@
+import { useLayoutEffect, useRef } from "react";
+
+/*
+ *
+ * useResizeObserver takes in a callback function and returns a ref
+ * that can be attached to a DOM element. The callback function will
+ * be called whenever the DOM element is resized.
+ *
+ */
+function useResizeObserver<T extends HTMLElement>(
+  callback: (target: T) => void
+) {
+  const ref = useRef<T>(null);
+
+  useLayoutEffect(() => {
+    const element = ref?.current;
+
+    if (!element) {
+      return;
+    }
+
+    const observer = new ResizeObserver(() => {
+      callback(element);
+    });
+
+    observer.observe(element);
+    return () => {
+      observer.disconnect();
+    };
+  }, [callback, ref]);
+
+  return ref;
+}
+
+export default useResizeObserver;

+ 4 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -33,6 +33,10 @@ export const sourceValidator = z.discriminatedUnion("type", [
   z.object({
     type: z.literal("docker-registry"),
     image_repo_uri: z.string(),
+    // add branch and repo as undefined to allow for easy checks on changes to the source type
+    // (i.e. we want to remove the services if any source fields change)
+    git_branch: z.undefined(),
+    git_repo_name: z.undefined(),
   }),
 ]);
 export type SourceOptions = z.infer<typeof sourceValidator>;

+ 43 - 8
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect } from "react";
+import React, { useContext, useEffect, useMemo } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import web from "assets/web.png";
 import AnimateHeight from "react-animate-height";
@@ -19,6 +19,8 @@ import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
 import RepoSettings from "./RepoSettings";
 import ImageSettings from "./ImageSettings";
+import Container from "components/porter/Container";
+import ServiceList from "../validate-apply/services-settings/ServiceList";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -26,7 +28,7 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
   const { currentProject } = useContext(Context);
   const [step, setStep] = React.useState(0);
 
-  const methods = useForm<PorterAppFormData>({
+  const porterAppFormMethods = useForm<PorterAppFormData>({
     reValidateMode: "onSubmit",
     defaultValues: {
       app: {
@@ -50,23 +52,46 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
     register,
     control,
     watch,
+    setValue,
     formState: { isSubmitting },
-  } = methods;
+  } = porterAppFormMethods;
 
   const name = watch("app.name");
   const source = watch("source");
   const build = watch("app.build");
-  const services = watch("app.services") ?? [];
+  const image = watch("app.image");
 
   useEffect(() => {
+    // set step to 1 if name is filled out
     if (name) {
       setStep((prev) => Math.max(prev, 1));
     }
 
-    if (source?.type) {
-      setStep((prev) => Math.max(prev, 2));
+    // set step to 2 if source is filled out
+    if (source?.type && source.type === "github") {
+      if (source.git_repo_name && source.git_branch) {
+        setStep((prev) => Math.max(prev, 3));
+      }
     }
-  }, [name, source?.type]);
+
+    // set step to 3 if source is filled out
+    if (source?.type && source.type === "docker-registry") {
+      if (image && image.tag) {
+        setStep((prev) => Math.max(prev, 3));
+      }
+    }
+  }, [
+    name,
+    source?.type,
+    source?.git_repo_name,
+    source?.git_branch,
+    image?.tag,
+  ]);
+
+  // reset services when source changes
+  useEffect(() => {
+    setValue("app.services", []);
+  }, [source?.type, source?.git_repo_name, source?.git_branch, image?.tag]);
 
   if (!currentProject) {
     return null;
@@ -84,7 +109,7 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
             disableLineBreak
           />
           <DarkMatter />
-          <FormProvider {...methods}>
+          <FormProvider {...porterAppFormMethods}>
             <VerticalSteps
               currentStep={step}
               steps={[
@@ -143,6 +168,16 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
                     ) : null}
                   </AnimateHeight>
                 </>,
+                <>
+                  <Container row>
+                    <Text size={16}>Application services</Text>
+                  </Container>
+                  <Spacer y={0.5} />
+                  <ServiceList
+                    defaultExpanded={true}
+                    addNewText={"Add a new service"}
+                  />
+                </>,
                 <>
                   <Button
                     status={isSubmitting && "loading"}

+ 6 - 7
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -18,7 +18,11 @@ import Button from "components/porter/Button";
 import BuildpackList from "./BuildpackList";
 import BuildpackConfigurationModal from "./BuildpackConfigurationModal";
 import { useFieldArray, useFormContext } from "react-hook-form";
-import { BuildOptions, PorterAppFormData, SourceOptions } from "lib/porter-apps";
+import {
+  BuildOptions,
+  PorterAppFormData,
+  SourceOptions,
+} from "lib/porter-apps";
 
 type Props = {
   projectId: number;
@@ -70,8 +74,6 @@ const BuildpackSettings: React.FC<Props> = ({
         }
       );
 
-      console.log("detectBuildPackRes", detectBuildPackRes);
-
       const detectedBuildpacks = z
         .array(detectedBuildpackSchema)
         .parseAsync(detectBuildPackRes.data);
@@ -83,9 +85,6 @@ const BuildpackSettings: React.FC<Props> = ({
     }
   );
 
-  console.log("data", data);
-  console.log("status", status);
-
   const errorMessage = useMemo(
     () =>
       status === "error"
@@ -244,4 +243,4 @@ const fadeIn = keyframes`
 
 const BuildpackConfigurationContainer = styled.div`
   animation: ${fadeIn} 0.75s;
-`;
+`;

+ 356 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -0,0 +1,356 @@
+import React, { useCallback, useContext, 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";
+import job from "assets/job.png";
+
+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 { UseFieldArrayRemove, UseFieldArrayUpdate } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+import { match } from "ts-pattern";
+import useResizeObserver from "lib/hooks/useResizeObserver";
+
+interface ServiceProps {
+  index: number;
+  service: ClientService;
+  chart?: any;
+  isPredeploy?: boolean;
+  update: UseFieldArrayUpdate<PorterAppFormData, "app.services">;
+  remove: UseFieldArrayRemove;
+}
+
+const ServiceContainer: React.FC<ServiceProps> = ({
+  index,
+  service,
+  chart,
+  isPredeploy,
+  update,
+  remove,
+}) => {
+  const [height, setHeight] = useState<Height>("auto");
+
+  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(
+    Math.round(
+      convert(AWS_INSTANCE_LIMITS["t3"]["medium"]["RAM"], "GiB").to("MB") *
+        UPPER_BOUND
+    )
+  ); //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(
+    (elt: HTMLDivElement) => {
+      if (elt.clientHeight === 0) {
+        return;
+      }
+
+      setHeight(elt.clientHeight ?? "auto");
+    },
+    [setHeight]
+  );
+  const ref = useResizeObserver(onResize);
+
+  useEffect(() => {
+    if (!service.expanded) {
+      setHeight(0);
+    }
+  }, [service.expanded]);
+
+  useEffect(() => {
+    const { currentCluster, currentProject } = context;
+    if (!currentCluster || !currentProject) {
+      return;
+    }
+    var instanceType = "";
+
+    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: 4294,
+            };
+
+            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;
+                  }
+                }
+              }
+            });
+
+            setMaxCPU(Math.fround(largestInstanceType.vCPUs * UPPER_BOUND));
+            setMaxRAM(
+              Math.round(
+                convert(largestInstanceType.RAM, "GiB").to("MB") * UPPER_BOUND
+              )
+            );
+          }
+        })
+        .catch((error) => {});
+    }
+  }, []);
+
+  const renderTabs = (service: ClientService) => {
+    return match(service)
+      .with({ config: { type: "web" } }, (svc) => (
+        <WebTabs index={index} service={svc} maxCPU={maxCPU} maxRAM={maxRAM} />
+      ))
+      .with({ config: { type: "worker" } }, (svc) => (
+        <WorkerTabs
+          index={index}
+          service={svc}
+          maxCPU={maxCPU}
+          maxRAM={maxRAM}
+        />
+      ))
+      .with({ config: { type: "job" } }, (svc) => (
+        <JobTabs
+          index={index}
+          service={svc}
+          maxCPU={maxCPU}
+          maxRAM={maxRAM}
+          isPredeploy={isPredeploy}
+        />
+      ))
+      .exhaustive();
+  };
+
+  const renderIcon = (service: ClientService) => {
+    switch (service.config.type) {
+      case "web":
+        return <Icon src={web} />;
+      case "worker":
+        return <Icon src={worker} />;
+      case "job":
+        return <Icon src={job} />;
+    }
+  };
+
+  const getHasBuiltImage = () => {
+    if (!chart?.chart?.values) {
+      return false;
+    }
+    return !_.isEmpty((Object.values(chart.chart.values)[0] as any)?.global);
+  };
+
+  return (
+    <>
+      <ServiceHeader
+        showExpanded={service.expanded}
+        onClick={() => {
+          update(index, {
+            ...service,
+            expanded: !service.expanded,
+          });
+        }}
+        chart={chart}
+        bordersRounded={!getHasBuiltImage() && !service.expanded}
+      >
+        <ServiceTitle>
+          <ActionButton>
+            <span className="material-icons dropdown">arrow_drop_down</span>
+          </ActionButton>
+          {renderIcon(service)}
+          {service.name.value.trim().length > 0
+            ? service.name.value
+            : "New Service"}
+        </ServiceTitle>
+        {service.canDelete && (
+          <ActionButton
+            onClick={(e) => {
+              e.stopPropagation();
+              remove(index);
+            }}
+          >
+            <span className="material-icons">delete</span>
+          </ActionButton>
+        )}
+      </ServiceHeader>
+      <AnimateHeight
+        height={height}
+        contentRef={ref}
+        contentClassName="auto-content"
+        duration={300}
+      >
+        <StyledSourceBox
+          showExpanded={service.expanded}
+          chart={chart}
+          hasFooter={chart && service && getHasBuiltImage()}
+        >
+          {renderTabs(service)}
+        </StyledSourceBox>
+      </AnimateHeight>
+      {chart &&
+        service &&
+        // Check if has built image
+        getHasBuiltImage() && (
+          <StatusFooter
+            setExpandedJob={() => {}}
+            chart={chart}
+            service={service}
+          />
+        )}
+      <Spacer y={0.5} />
+    </>
+  );
+};
+
+export default ServiceContainer;
+
+const ServiceTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourceBox = styled.div<{
+  chart: any;
+  showExpanded?: boolean;
+  hasFooter?: boolean;
+}>`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 25px 30px;
+  position: relative;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0;
+  border-bottom-left-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+  border-bottom-right-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    color: white;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+  margin-right: 5px;
+`;
+
+const ServiceHeader = styled.div<{
+  chart: any;
+  showExpanded?: boolean;
+  bordersRounded?: boolean;
+}>`
+  flex-direction: row;
+  display: flex;
+  height: 60px;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  border-bottom-left-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+  border-bottom-right-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+
+  .dropdown {
+    font-size: 30px;
+    cursor: pointer;
+    border-radius: 20px;
+    margin-left: -10px;
+    transform: ${(props: { showExpanded?: boolean; chart: any }) =>
+      props.showExpanded ? "" : "rotate(-90deg)"};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 15px;
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

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

@@ -0,0 +1,316 @@
+import React, { useState } from "react";
+import ServiceContainer from "./ServiceContainer";
+import styled from "styled-components";
+import Spacer from "components/porter/Spacer";
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Select from "components/porter/Select";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+import job from "assets/job.png";
+import { z } from "zod";
+import { ClientPorterApp, PorterAppFormData } from "lib/porter-apps";
+import { ClientService } from "lib/porter-apps/services";
+import {
+  Controller,
+  useFieldArray,
+  useForm,
+  useFormContext,
+} from "react-hook-form";
+import { ControlledInput } from "components/porter/ControlledInput";
+import { match } from "ts-pattern";
+
+const addServiceFormValidator = z.object({
+  name: z
+    .string()
+    .min(1)
+    .max(30)
+    .regex(/^[a-z0-9-]+$/, {
+      message: 'Lowercase letters, numbers, and " - " only.',
+    }),
+  type: z.enum(["web", "worker", "job"]),
+});
+type AddServiceFormValues = z.infer<typeof addServiceFormValidator>;
+
+type ServiceListProps = {
+  addNewText: string;
+  defaultExpanded?: boolean;
+  limitOne?: boolean;
+  prePopulateService?: ClientService;
+};
+
+const ServiceList: React.FC<ServiceListProps> = ({
+  addNewText,
+  limitOne = false,
+  prePopulateService,
+}) => {
+  // top level app form
+  const { control: appControl } = useFormContext<PorterAppFormData>();
+
+  // add service modal form
+  const {
+    register,
+    watch,
+    control,
+    reset,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<AddServiceFormValues>({
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      name: "",
+      type: "web",
+    },
+  });
+  const { append, remove, update, fields: services } = useFieldArray({
+    control: appControl,
+    name: "app.services",
+  });
+
+  const serviceType = watch("type");
+  const serviceName = watch("name");
+
+  const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
+    false
+  );
+
+  const isServiceNameDuplicate = (name: string) => {
+    return services.some((s) => s.name.value === name);
+  };
+
+  const maybeRenderAddServicesButton = () => {
+    if (limitOne && services.length > 0) {
+      return null;
+    }
+    return (
+      <>
+        <AddServiceButton
+          onClick={() => {
+            if (!prePopulateService) {
+              setShowAddServiceModal(true);
+              return;
+            }
+
+            append(prePopulateService);
+          }}
+        >
+          <i className="material-icons add-icon">add_icon</i>
+          {addNewText}
+        </AddServiceButton>
+        <Spacer y={0.5} />
+      </>
+    );
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    const config: ClientService["config"] = match(data.type)
+      .with("web", () => ({
+        type: "web" as const,
+        domains: [],
+        autoscaling: {
+          enabled: {
+            readOnly: false,
+            value: false,
+          },
+        },
+        healthCheck: {
+          enabled: {
+            readOnly: false,
+            value: false,
+          },
+        },
+      }))
+      .with("worker", () => ({
+        type: "worker" as const,
+        autoscaling: {
+          enabled: {
+            readOnly: false,
+            value: false,
+          },
+        },
+      }))
+      .with("job", () => ({
+        type: "job" as const,
+        allowConcurrent: {
+          readOnly: false,
+          value: true,
+        },
+        cron: {
+          readOnly: false,
+          value: "",
+        },
+      }))
+      .exhaustive();
+
+    append({
+      expanded: true,
+      canDelete: true,
+      name: {
+        readOnly: false,
+        value: data.name,
+      },
+      run: {
+        readOnly: false,
+        value: "",
+      },
+      instances: {
+        readOnly: false,
+        value: 1,
+      },
+      cpuCores: {
+        readOnly: false,
+        value: 0.1,
+      },
+      ramMegabytes: {
+        readOnly: false,
+        value: 256,
+      },
+      port: {
+        readOnly: false,
+        value: 3000,
+      },
+      config,
+    });
+
+    reset();
+    setShowAddServiceModal(false);
+  });
+
+  return (
+    <>
+      {services.length > 0 && (
+        <ServicesContainer>
+          {services.map((service, idx) => {
+            return (
+              <ServiceContainer
+                index={idx}
+                key={service.id}
+                service={service}
+                update={update}
+                remove={remove}
+              />
+            );
+          })}
+        </ServicesContainer>
+      )}
+      {maybeRenderAddServicesButton()}
+      {showAddServiceModal && (
+        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
+          <form onSubmit={onSubmit}>
+            <Text size={16}>{addNewText}</Text>
+            <Spacer y={1} />
+            <Text color="helper">Select a service type:</Text>
+            <Spacer y={0.5} />
+            <Container row>
+              <ServiceIcon>
+                {serviceType === "web" && <img src={web} />}
+                {serviceType === "worker" && <img src={worker} />}
+                {serviceType === "job" && <img src={job} />}
+              </ServiceIcon>
+              <Controller
+                name="type"
+                control={control}
+                render={({ field: { onChange } }) => (
+                  <Select
+                    value={serviceType}
+                    width="100%"
+                    setValue={(value: string) => onChange(value)}
+                    options={[
+                      { label: "Web", value: "web" },
+                      { label: "Worker", value: "worker" },
+                      { label: "Cron Job", value: "job" },
+                    ]}
+                  />
+                )}
+              />
+            </Container>
+            <Spacer y={1} />
+            <Text color="helper">Name this service:</Text>
+            <Spacer y={0.5} />
+            <ControlledInput
+              type="text"
+              placeholder="ex: my-service"
+              width="100%"
+              error={errors.name?.message}
+              {...register("name")}
+            />
+            <Spacer y={1} />
+            <Button
+              type="submit"
+              disabled={
+                isServiceNameDuplicate(serviceName) || serviceName?.length > 61
+              }
+            >
+              <I className="material-icons">add</I> Add service
+            </Button>
+          </form>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default ServiceList;
+
+const ServiceIcon = styled.div`
+  border: 1px solid #494b4f;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 35px;
+  width: 35px;
+  min-width: 35px;
+  margin-right: 10px;
+  overflow: hidden;
+  border-radius: 5px;
+  > img {
+    height: 18px;
+    animation: floatIn 0.5s 0s;
+    @keyframes floatIn {
+      from {
+        opacity: 0;
+        transform: translateY(7px);
+      }
+      to {
+        opacity: 1;
+        transform: translateY(0px);
+      }
+    }
+  }
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;
+
+const ServicesContainer = styled.div``;
+
+const AddServiceButton = styled.div`
+  color: #aaaabb;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  .add-icon {
+    width: 30px;
+    font-size: 20px;
+  }
+`;

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

@@ -0,0 +1,105 @@
+import React from "react";
+import Button from "components/porter/Button";
+import styled from "styled-components";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+import { ClientDomains } from "lib/porter-apps/values";
+import { ControlledInput } from "components/porter/ControlledInput";
+
+interface Props {
+  index: number;
+  customDomains: ClientDomains;
+}
+
+const CustomDomains: React.FC<Props> = ({ index, customDomains }) => {
+  const { control, register } = useFormContext<PorterAppFormData>();
+  const { remove, append, fields } = useFieldArray({
+    control,
+    name: `app.services.${index}.config.domains`,
+  });
+
+  return (
+    <CustomDomainsContainer>
+      {fields.length !== 0 && (
+        <>
+          {fields.map((customDomain, i) => {
+            return (
+              <div key={customDomain.id}>
+                <AnnotationContainer>
+                  <ControlledInput
+                    type="text"
+                    placeholder="ex: my-app.my-domain.com"
+                    disabled={customDomain.name.readOnly}
+                    width="275px"
+                    disabledTooltip={
+                      "You may only edit this field in your porter.yaml."
+                    }
+                    {...register(
+                      `app.services.${index}.config.domains.${i}.name.value`
+                    )}
+                  />
+                  <DeleteButton
+                    onClick={() => {
+                      //remove customDomain at the index
+                      remove(i);
+                    }}
+                  >
+                    <i className="material-icons">cancel</i>
+                  </DeleteButton>
+                </AnnotationContainer>
+                <Spacer y={0.25} />
+              </div>
+            );
+          })}
+          <Spacer y={0.5} />
+        </>
+      )}
+      <Button
+        onClick={() => {
+          append({
+            name: {
+              readOnly: false,
+              value: "",
+            },
+          });
+        }}
+      >
+        + Add Custom Domain
+      </Button>
+    </CustomDomainsContainer>
+  );
+};
+
+export default CustomDomains;
+
+const CustomDomainsContainer = styled.div``;
+
+const AnnotationContainer = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 5px;
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;

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

@@ -0,0 +1,81 @@
+import Checkbox from "components/porter/Checkbox";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import { ClientService } from "lib/porter-apps/services";
+import React from "react";
+import AnimateHeight from "react-animate-height";
+import { Controller, useFormContext } from "react-hook-form";
+
+type HealthProps = {
+  index: number;
+  service: ClientService & {
+    config: {
+      type: "web";
+    };
+  };
+};
+
+const Health: React.FC<HealthProps> = ({ index, service }) => {
+  const { register, control, watch } = useFormContext<PorterAppFormData>();
+
+  const healthCheckEnabled = watch(
+    `app.services.${index}.config.healthCheck.enabled`
+  );
+
+  return (
+    <>
+      <Spacer y={1} />
+      <Text color="helper">
+        <>
+          <span>Health checks</span>
+          <a
+            href="https://docs.porter.run/enterprise/deploying-applications/zero-downtime-deployments#health-checks"
+            target="_blank"
+          >
+            &nbsp;(?)
+          </a>
+        </>
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`app.services.${index}.config.healthCheck.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 Liveness Probe</Text>
+          </Checkbox>
+        )}
+      />
+      {healthCheckEnabled.value && (
+        <>
+          <Spacer y={0.5} />
+          <ControlledInput
+            type="text"
+            label="Health Check Endpoint "
+            placeholder="ex: /healthz"
+            {...register(
+              `app.services.${index}.config.healthCheck.httpPath.value`
+            )}
+          />
+          <Spacer y={0.5} />
+        </>
+      )}
+    </>
+  );
+};
+
+export default Health;

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

@@ -0,0 +1,99 @@
+import React from "react";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import Checkbox from "components/porter/Checkbox";
+import { Height } from "react-animate-height";
+
+import { ClientService } from "lib/porter-apps/services";
+import { match } from "ts-pattern";
+import MainTab from "./Main";
+import Resources from "./Resources";
+import { Controller, useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+
+interface Props {
+  index: number;
+  service: ClientService & {
+    config: {
+      type: "job";
+    };
+  };
+  chart?: any;
+  maxRAM: number;
+  maxCPU: number;
+  isPredeploy?: boolean;
+}
+
+const JobTabs: React.FC<Props> = ({
+  index,
+  service,
+  maxRAM,
+  maxCPU,
+  isPredeploy,
+}) => {
+  const { control } = useFormContext<PorterAppFormData>();
+  const [currentTab, setCurrentTab] = React.useState<
+    "main" | "resources" | "advanced"
+  >("main");
+
+  const tabs = isPredeploy
+    ? [
+        { label: "Main", value: "main" as const },
+        { label: "Resources", value: "resources" as const },
+      ]
+    : [
+        { label: "Main", value: "main" as const },
+        { label: "Resources", value: "resources" as const },
+        { label: "Advanced", value: "advanced" as const },
+      ];
+
+  return (
+    <>
+      <TabSelector
+        options={tabs}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {match(currentTab)
+        .with("main", () => <MainTab index={index} service={service} />)
+        .with("resources", () => (
+          <Resources
+            index={index}
+            maxCPU={maxCPU}
+            maxRAM={maxRAM}
+            service={service}
+          />
+        ))
+        .with("advanced", () => (
+          <>
+            <Spacer y={1} />
+            <Controller
+              name={`app.services.${index}.config.allowConcurrent`}
+              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">Allow jobs to execute concurrently</Text>
+                </Checkbox>
+              )}
+            />
+          </>
+        ))
+        .exhaustive()}
+    </>
+  );
+};
+
+export default JobTabs;

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

@@ -0,0 +1,71 @@
+import React, { useCallback } from "react";
+import cronstrue from "cronstrue";
+import { useFormContext } from "react-hook-form";
+
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import { PorterAppFormData } from "lib/porter-apps";
+import { ClientService } from "lib/porter-apps/services";
+import Text from "components/porter/Text";
+import Link from "components/porter/Link";
+
+type MainTabProps = {
+  index: number;
+  service: ClientService;
+};
+
+const MainTab: React.FC<MainTabProps> = ({ index, service }) => {
+  const { register } = useFormContext<PorterAppFormData>();
+
+  const getScheduleDescription = useCallback((cron: string) => {
+    try {
+      return (
+        <Text color="helper">This job runs: {cronstrue.toString(cron)}</Text>
+      );
+    } catch (err) {
+      return (
+        <Text color="helper">
+          Invalid cron schedule.{" "}
+          <Link to={"https://crontab.cronhub.io/"} hasunderline target="_blank">
+            Need help?
+          </Link>
+        </Text>
+      );
+    }
+  }, []);
+
+  return (
+    <>
+      <Spacer y={1} />
+      <ControlledInput
+        type="text"
+        label="Start command"
+        placeholder="ex: sh start.sh"
+        width="300px"
+        disabled={service.run.readOnly}
+        disabledTooltip={"You may only edit this field in your porter.yaml."}
+        {...register(`app.services.${index}.run.value`)}
+      />
+      {service.config.type === "job" && (
+        <>
+          <Spacer y={1} />
+          <ControlledInput
+            type="text"
+            label="Cron schedule"
+            placeholder="ex: */5 * * * *"
+            width="300px"
+            disabled={service.config.cron.readOnly}
+            disabledTooltip={
+              "You may only edit this field in your porter.yaml."
+            }
+            {...register(`app.services.${index}.config.cron.value`)}
+          />
+          <Spacer y={0.5} />
+          {getScheduleDescription(service.config.cron.value)}
+        </>
+      )}
+    </>
+  );
+};
+
+export default MainTab;

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

@@ -0,0 +1,112 @@
+import React from "react";
+import { ControlledInput } from "components/porter/ControlledInput";
+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 Checkbox from "components/porter/Checkbox";
+import Text from "components/porter/Text";
+import AnimateHeight from "react-animate-height";
+import CustomDomains from "./CustomDomains";
+
+type NetworkingProps = {
+  index: number;
+  service: ClientService & {
+    config: {
+      type: "web";
+    };
+  };
+};
+
+const prefixSubdomain = (subdomain: string) => {
+  if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
+    return subdomain;
+  }
+  return "https://" + subdomain;
+};
+
+const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
+  const { register, control, watch } = useFormContext<PorterAppFormData>();
+
+  const ingressEnabled = watch(`app.services.${index}.config.ingressEnabled`);
+
+  const getApplicationURLText = () => {
+    if (service.config.domains.length !== 0) {
+      return (
+        <Text>
+          {`Application URL${service.config.domains.length === 1 ? "" : "s"}: `}
+          {service.config.domains.map((d, i) => {
+            return (
+              <a href={prefixSubdomain(d.name.value)} target="_blank">
+                {d.name.value}
+                {i !== service.config.domains.length - 1 && ", "}
+              </a>
+            );
+          })}
+        </Text>
+      );
+    }
+
+    return (
+      <Text color="helper">
+        Application URL: Not generated yet. Porter will generate a URL for you
+        on next deploy.
+      </Text>
+    );
+  };
+
+  return (
+    <>
+      <Spacer y={1} />
+      <ControlledInput
+        label="Container port"
+        type="text"
+        placeholder="ex: 80"
+        disabled={service.port.readOnly}
+        width="300px"
+        disabledTooltip={"You may only edit this field in your porter.yaml."}
+        {...register(`app.services.${index}.port.value`)}
+      />
+      <Spacer y={1} />
+      <Controller
+        name={`app.services.${index}.config.ingressEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={Boolean(value)}
+            disabled={service.config.domains.some((d) => d.name.readOnly)}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+            disabledTooltip={
+              "You may only edit this field in your porter.yaml."
+            }
+          >
+            <Text color="helper">Expose to external traffic</Text>
+          </Checkbox>
+        )}
+      />
+      {ingressEnabled && (
+        <>
+          <Spacer y={0.5} />
+          {getApplicationURLText()}
+          <Spacer y={0.5} />
+          <Text color="helper">
+            Custom domains
+            <a
+              href="https://docs.porter.run/standard/deploying-applications/https-and-domains/custom-domains"
+              target="_blank"
+            >
+              &nbsp;(?)
+            </a>
+          </Text>
+          <Spacer y={0.5} />
+          <CustomDomains index={index} customDomains={service.config.domains} />
+          <Spacer y={0.5} />
+        </>
+      )}
+    </>
+  );
+};
+
+export default Networking;

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

@@ -0,0 +1,233 @@
+import React 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 AnimateHeight from "react-animate-height";
+import { match } from "ts-pattern";
+
+type ResourcesProps = {
+  index: number;
+  maxCPU: number;
+  maxRAM: number;
+  service: ClientService;
+};
+
+const Resources: React.FC<ResourcesProps> = ({
+  index,
+  maxCPU,
+  maxRAM,
+  service,
+}) => {
+  const { control, register, watch } = useFormContext<PorterAppFormData>();
+
+  const autoscalingEnabled = watch(
+    `app.services.${index}.config.autoscaling.enabled`
+  );
+
+  return (
+    <>
+      <Spacer y={1} />
+      <Controller
+        name={`app.services.${index}.cpuCores`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <InputSlider
+            label="CPUs: "
+            unit="Cores"
+            min={0}
+            max={maxCPU}
+            color={"#3a48ca"}
+            value={value.value.toString()}
+            setValue={(e) => {
+              onChange({
+                ...value,
+                value: e,
+              });
+            }}
+            step={0.1}
+            disabled={value.readOnly}
+            disabledTooltip={
+              "You may only edit this field in your porter.yaml."
+            }
+          />
+        )}
+      />
+      <Spacer y={1} />
+      <Controller
+        name={`app.services.${index}.ramMegabytes`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <InputSlider
+            label="RAM: "
+            unit="MB"
+            min={0}
+            max={maxRAM}
+            color={"#3a48ca"}
+            value={value.value.toString()}
+            setValue={(e) => {
+              onChange({
+                ...value,
+                value: e,
+              });
+            }}
+            step={10}
+            disabled={value.readOnly}
+            disabledTooltip={
+              "You may only edit this field in your porter.yaml."
+            }
+          />
+        )}
+      />
+      {match(service.config)
+        .with({ type: "job" }, () => null)
+        .otherwise((config) => (
+          <>
+            <Spacer y={1} />
+            <ControlledInput
+              type="text"
+              label="Instances"
+              placeholder="ex: 1"
+              disabled={
+                service.instances.readOnly ?? config.autoscaling?.enabled
+              }
+              width="300px"
+              disabledTooltip={
+                service.instances.readOnly
+                  ? "You may only edit this field in your porter.yaml."
+                  : "Disable autoscaling to specify replicas."
+              }
+              {...register(`app.services.${index}.instances.value`)}
+            />
+            <Spacer y={1} />
+            <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."
+                      }
+                    />
+                  )}
+                />
+              </>
+            )}
+          </>
+        ))}
+    </>
+  );
+};
+
+export default Resources;

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

@@ -0,0 +1,59 @@
+import React from "react";
+import TabSelector from "components/TabSelector";
+
+import { ClientService } from "lib/porter-apps/services";
+import { match } from "ts-pattern";
+import Networking from "./Networking";
+import MainTab from "./Main";
+import Resources from "./Resources";
+import Health from "./Health";
+
+interface Props {
+  index: number;
+  service: ClientService & {
+    config: {
+      type: "web";
+    };
+  };
+  chart?: any;
+  maxRAM: number;
+  maxCPU: number;
+}
+
+const WebTabs: React.FC<Props> = ({ index, service, maxRAM, maxCPU }) => {
+  const [currentTab, setCurrentTab] = React.useState<
+    "main" | "resources" | "networking" | "advanced"
+  >("main");
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: "Main", value: "main" },
+          { label: "Resources", value: "resources" },
+          { label: "Networking", value: "networking" },
+          { label: "Advanced", value: "advanced" },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {match(currentTab)
+        .with("main", () => <MainTab index={index} service={service} />)
+        .with("networking", () => (
+          <Networking index={index} service={service} />
+        ))
+        .with("resources", () => (
+          <Resources
+            index={index}
+            maxCPU={maxCPU}
+            maxRAM={maxRAM}
+            service={service}
+          />
+        ))
+        .with("advanced", () => <Health index={index} service={service} />)
+        .exhaustive()}
+    </>
+  );
+};
+
+export default WebTabs;

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

@@ -0,0 +1,51 @@
+import React from "react";
+import TabSelector from "components/TabSelector";
+
+import { ClientService } from "lib/porter-apps/services";
+import { match } from "ts-pattern";
+import MainTab from "./Main";
+import Resources from "./Resources";
+
+interface Props {
+  index: number;
+  service: ClientService & {
+    config: {
+      type: "worker";
+    };
+  };
+  chart?: any;
+  maxRAM: number;
+  maxCPU: number;
+}
+
+const WorkerTabs: React.FC<Props> = ({ index, service, maxCPU, maxRAM }) => {
+  const [currentTab, setCurrentTab] = React.useState<"main" | "resources">(
+    "main"
+  );
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: "Main", value: "main" },
+          { label: "Resources", value: "resources" },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {match(currentTab)
+        .with("main", () => <MainTab index={index} service={service} />)
+        .with("resources", () => (
+          <Resources
+            index={index}
+            maxCPU={maxCPU}
+            maxRAM={maxRAM}
+            service={service}
+          />
+        ))
+        .exhaustive()}
+    </>
+  );
+};
+
+export default WorkerTabs;

+ 81 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/utils.ts

@@ -0,0 +1,81 @@
+export const MIB_TO_GIB = 1024;
+export const MILI_TO_CORE = 1000;
+interface InstanceDetails {
+  vCPU: number;
+  RAM: number;
+}
+
+interface InstanceTypes {
+  [key: string]: {
+    [size: string]: InstanceDetails;
+  };
+}
+
+// use values from AWS as base constant, convert to MB
+export const AWS_INSTANCE_LIMITS: InstanceTypes = Object.freeze({
+  t3a: {
+    nano: { vCPU: 2, RAM: 0.5 },
+    micro: { vCPU: 2, RAM: 1 },
+    small: { vCPU: 2, RAM: 2 },
+    medium: { vCPU: 2, RAM: 4 },
+    large: { vCPU: 2, RAM: 8 },
+    xlarge: { vCPU: 4, RAM: 16 },
+    "2xlarge": { vCPU: 8, RAM: 32 },
+  },
+  t3: {
+    nano: { vCPU: 2, RAM: 0.5 },
+    micro: { vCPU: 2, RAM: 1 },
+    small: { vCPU: 2, RAM: 2 },
+    medium: { vCPU: 2, RAM: 4 },
+    large: { vCPU: 2, RAM: 8 },
+    xlarge: { vCPU: 4, RAM: 16 },
+    "2xlarge": { vCPU: 8, RAM: 32 },
+  },
+  t2: {
+    nano: { vCPU: 1, RAM: 0.5 },
+    micro: { vCPU: 1, RAM: 1 },
+    small: { vCPU: 1, RAM: 2 },
+    medium: { vCPU: 2, RAM: 4 },
+    large: { vCPU: 2, RAM: 8 },
+    xlarge: { vCPU: 4, RAM: 16 },
+    "2xlarge": { vCPU: 8, RAM: 32 },
+  },
+  c6i: {
+    large: { vCPU: 2, RAM: 4 },
+    xlarge: { vCPU: 4, RAM: 8 },
+    "2xlarge": { vCPU: 8, RAM: 16 },
+    "4xlarge": { vCPU: 16, RAM: 32 },
+    "8xlarge": { vCPU: 32, RAM: 64 },
+    "12xlarge": { vCPU: 48, RAM: 96 },
+  },
+  g4dn: {
+    xlarge: { vCPU: 4, RAM: 16 },
+    "2xlarge": { vCPU: 8, RAM: 32 },
+    "4xlarge": { vCPU: 16, RAM: 64 },
+    "8xlarge": { vCPU: 32, RAM: 128 },
+  },
+  r6a: {
+    large: { vCPU: 2, RAM: 16 },
+    xlarge: { vCPU: 4, RAM: 32 },
+    "2xlarge": { vCPU: 8, RAM: 64 },
+    "4xlarge": { vCPU: 16, RAM: 128 },
+    "8xlarge": { vCPU: 32, RAM: 256 },
+  },
+  c5: {
+    large: { vCPU: 2, RAM: 4 },
+    xlarge: { vCPU: 4, RAM: 8 },
+    "2xlarge": { vCPU: 8, RAM: 16 },
+    "4xlarge": { vCPU: 16, RAM: 32 },
+  },
+  m5: {
+    large: { vCPU: 2, RAM: 8 },
+    xlarge: { vCPU: 4, RAM: 16 },
+    "2xlarge": { vCPU: 8, RAM: 32 },
+    "4xlarge": { vCPU: 16, RAM: 64 },
+  },
+  x2gd: {
+    medium: { vCPU: 1, RAM: 16 },
+    large: { vCPU: 2, RAM: 32 },
+    xlarge: { vCPU: 4, RAM: 64 },
+  },
+});

+ 0 - 25
package-lock.json

@@ -1,25 +0,0 @@
-{
-  "name": "porter",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "dependencies": {
-        "@porter-dev/api-contracts": "^0.0.85"
-      }
-    },
-    "node_modules/@bufbuild/protobuf": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
-      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
-    },
-    "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.85",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
-      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
-      "dependencies": {
-        "@bufbuild/protobuf": "^1.1.0"
-      }
-    }
-  }
-}

+ 0 - 5
package.json

@@ -1,5 +0,0 @@
-{
-  "dependencies": {
-    "@porter-dev/api-contracts": "^0.0.85"
-  }
-}