2
0
Эх сурвалжийг харах

Merge branch 'master' into fix-infra-error

Feroze Mohideen 2 жил өмнө
parent
commit
5166a4354a

+ 14 - 0
dashboard/package-lock.json

@@ -8,6 +8,7 @@
       "name": "dashboard",
       "version": "0.1.0",
       "dependencies": {
+        "@hookform/resolvers": "^3.3.0",
         "@ironplans/react": "^0.4.0",
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
@@ -2046,6 +2047,14 @@
       "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
       "dev": true
     },
+    "node_modules/@hookform/resolvers": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
+      "integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==",
+      "peerDependencies": {
+        "react-hook-form": "^7.0.0"
+      }
+    },
     "node_modules/@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
@@ -16680,6 +16689,11 @@
       "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
       "dev": true
     },
+    "@hookform/resolvers": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
+      "integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg=="
+    },
     "@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",

+ 1 - 0
dashboard/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@hookform/resolvers": "^3.3.0",
     "@ironplans/react": "^0.4.0",
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",

+ 4 - 0
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -121,6 +121,10 @@ export const usePorterYaml = (source: SourceOptions) => {
         clusterId: currentCluster.id,
       });
     }
+
+    if (!data && detectedServices) {
+      setDetectedServices(null);
+    }
   }, [data]);
 
   return detectedServices;

+ 39 - 21
dashboard/src/lib/porter-apps/index.ts

@@ -14,23 +14,29 @@ import { PorterApp, Service } from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
 
 // buildValidator is used to validate inputs for build setting fields
-export const buildValidator = z.object({
-  context: z.string().default("./"),
-  method: z.enum(["pack", "docker", "registry"]),
-  buildpacks: z.array(buildpackSchema),
-  builder: z.string(),
-  dockerfile: z.string(),
-});
+export const buildValidator = z.discriminatedUnion("method", [
+  z.object({
+    method: z.literal("pack"),
+    context: z.string().default("./"),
+    buildpacks: z.array(buildpackSchema).default([]),
+    builder: z.string(),
+  }),
+  z.object({
+    method: z.literal("docker"),
+    context: z.string().default("./"),
+    dockerfile: z.string().default("./Dockerfile"),
+  }),
+]);
 export type BuildOptions = z.infer<typeof buildValidator>;
 
 // sourceValidator is used to validate inputs for source setting fields
 export const sourceValidator = z.discriminatedUnion("type", [
   z.object({
     type: z.literal("github"),
-    git_repo_id: z.number(),
-    git_branch: z.string(),
-    git_repo_name: z.string(),
-    porter_yaml_path: z.string(),
+    git_repo_id: z.number().min(1),
+    git_branch: z.string().min(1),
+    git_repo_name: z.string().min(1),
+    porter_yaml_path: z.string().default("./porter.yaml"),
   }),
   z.object({
     type: z.literal("docker-registry"),
@@ -39,7 +45,7 @@ export const sourceValidator = z.discriminatedUnion("type", [
     git_branch: z.undefined(),
     git_repo_name: z.undefined(),
     image: z.object({
-      repository: z.string(),
+      repository: z.string().min(1),
       tag: z.string().default("latest"),
     }),
   }),
@@ -48,9 +54,9 @@ export type SourceOptions = z.infer<typeof sourceValidator>;
 
 // porterAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
 export const porterAppValidator = z.object({
-  name: z.string(),
+  name: z.string().min(1),
   services: serviceValidator.array(),
-  env: z.record(z.string(), z.string()),
+  env: z.record(z.string(), z.string()).default({}),
   build: buildValidator,
 });
 export type ClientPorterApp = z.infer<typeof porterAppValidator>;
@@ -104,6 +110,24 @@ export function defaultServicesWithOverrides({
   };
 }
 
+const clientBuildToProto = (build: BuildOptions) => {
+  return match(build)
+    .with({ method: "pack" }, (b) =>
+      Object.freeze({
+        context: b.context,
+        buildpacks: b.buildpacks.map((b) => b.buildpack),
+        builder: b.builder,
+      })
+    )
+    .with({ method: "docker" }, (b) =>
+      Object.freeze({
+        context: b.context,
+        dockerfile: b.dockerfile,
+      })
+    )
+    .exhaustive();
+};
+
 export function clientAppToProto(data: PorterAppFormData): PorterApp {
   const { app, source } = data;
 
@@ -124,13 +148,7 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           name: app.name,
           services,
           env: app.env,
-          build: {
-            context: app.build.context,
-            method: app.build.method,
-            buildpacks: app.build.buildpacks.map((b) => b.buildpack),
-            builder: app.build.builder,
-            dockerfile: app.build.dockerfile,
-          },
+          build: clientBuildToProto(app.build),
           ...(predeploy && {
             predeploy: serviceProto(serializeService(predeploy)),
           }),

+ 108 - 12
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -1,8 +1,9 @@
-import React, { useCallback, useContext, useEffect } from "react";
+import React, { useCallback, useContext, useEffect, useMemo } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import web from "assets/web.png";
 import AnimateHeight from "react-animate-height";
 import axios from "axios";
+import { zodResolver } from "@hookform/resolvers/zod";
 
 import styled from "styled-components";
 import { useForm, Controller, FormProvider } from "react-hook-form";
@@ -18,6 +19,7 @@ import {
   PorterAppFormData,
   SourceOptions,
   clientAppToProto,
+  porterAppFormValidator,
 } from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
@@ -40,6 +42,7 @@ import GithubActionModal from "../new-app-flow/GithubActionModal";
 import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import Error from "components/porter/Error";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { useQuery } from "@tanstack/react-query";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -59,7 +62,37 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const [isDeploying, setIsDeploying] = React.useState(false);
   const [deployError, setDeployError] = React.useState("");
 
+  const { data: porterApps = [] } = useQuery<string[]>(
+    ["getPorterApps", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject?.id || !currentCluster?.id) {
+        return Promise.resolve([]);
+      }
+
+      const res = await api.getPorterApps(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      const apps = await z
+        .object({
+          name: z.string(),
+        })
+        .array()
+        .parseAsync(res.data);
+      return apps.map((app) => app.name);
+    },
+    {
+      enabled: !!currentProject?.id && !!currentCluster?.id,
+    }
+  );
+
   const porterAppFormMethods = useForm<PorterAppFormData>({
+    resolver: zodResolver(porterAppFormValidator),
     reValidateMode: "onSubmit",
     defaultValues: {
       app: {
@@ -73,7 +106,6 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       },
       source: {
         git_repo_name: "",
-        git_repo_id: 0,
         git_branch: "",
         porter_yaml_path: "./porter.yaml",
       },
@@ -85,13 +117,15 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     watch,
     setValue,
     handleSubmit,
-    formState: { isSubmitting },
+    setError,
+    formState: { isSubmitting: isValidating, errors },
   } = porterAppFormMethods;
 
   const name = watch("app.name");
   const source = watch("source");
   const build = watch("app.build");
   const image = watch("source.image");
+  const services = watch("app.services");
   const servicesFromYaml = usePorterYaml(source);
   const deploymentTarget = useDefaultDeploymentTarget();
   const { updateAppStep } = useAppAnalytics(name);
@@ -217,7 +251,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         setIsDeploying(false);
       }
     },
-    [currentProject?.id, currentCluster?.id]
+    [currentProject?.id, currentCluster?.id, deploymentTarget]
   );
 
   useEffect(() => {
@@ -247,6 +281,43 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     image?.tag,
   ]);
 
+  // todo(ianedwards): it's a bit odd that the button error can be set to either a string or JSX,
+  // need to look into refactoring that where possible and then improve this error handling
+  const submitBtnStatus = useMemo(() => {
+    if (isValidating || isDeploying) {
+      return "loading";
+    }
+
+    if (deployError) {
+      return <Error message={deployError} />;
+    }
+
+    const errorKeys = Object.keys(errors);
+    if (errorKeys.length > 0) {
+      if (errorKeys.includes("app")) {
+        const appErrors = Object.keys(errors?.app ?? {});
+        if (appErrors.includes("build")) {
+          return (
+            <Error message={"Build settings are not properly configured."} />
+          );
+        }
+
+        if (appErrors.includes("services")) {
+          return (
+            <Error message={"Service settings are not properly configured."} />
+          );
+        }
+      }
+      return <Error message={"App could not be deployed as defined."} />;
+    }
+
+    return;
+  }, [isValidating, isDeploying, deployError, errors]);
+
+  const submitDisabled = useMemo(() => {
+    return !name || !source || services.length === 0;
+  }, [name, source, services?.length]);
+
   // reset services when source changes
   useEffect(() => {
     setValue("app.services", []);
@@ -254,6 +325,20 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       detected: false,
       count: 0,
     });
+
+    if (source?.type === "docker-registry") {
+      setValue("app.build", {
+        context: "./",
+        method: "pack",
+        builder: "",
+        buildpacks: [],
+      });
+      setValue("source", {
+        ...source,
+        git_repo_name: undefined,
+        git_branch: undefined,
+      });
+    }
   }, [source?.type, source?.git_repo_name, source?.git_branch, image?.tag]);
 
   useEffect(() => {
@@ -265,8 +350,24 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         count: services.length,
       });
     }
+
+    if (!servicesFromYaml && detectedServices.detected) {
+      setValue("app.services", []);
+      setDetectedServices({
+        detected: false,
+        count: 0,
+      });
+    }
   }, [servicesFromYaml, detectedServices.detected]);
 
+  useEffect(() => {
+    if (porterApps.includes(name)) {
+      setError("app.name", {
+        message: "An app with this name already exists",
+      });
+    }
+  }, [porterApps]);
+
   if (!currentProject || !currentCluster) {
     return null;
   }
@@ -298,6 +399,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <ControlledInput
                       placeholder="ex: academic-sophon"
                       type="text"
+                      error={errors.app?.name?.message}
                       {...register("app.name")}
                     />
                   </>,
@@ -410,16 +512,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                   ),
                   <Button
                     type="submit"
-                    status={
-                      isSubmitting || isDeploying ? (
-                        "loading"
-                      ) : deployError ? (
-                        <Error message={deployError} />
-                      ) : undefined
-                    }
+                    status={submitBtnStatus}
                     loadingText={"Deploying..."}
                     width={"120px"}
-                    disabled={!name || !source || isSubmitting || isDeploying}
+                    disabled={submitDisabled}
                   >
                     Deploy app
                   </Button>,

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

@@ -15,7 +15,6 @@ import {
   PorterAppFormData,
   SourceOptions,
 } from "lib/porter-apps";
-import { BuildMethod } from "../types/porterApp";
 import RepositorySelector from "../build-settings/RepositorySelector";
 import BranchSelector from "../build-settings/BranchSelector";
 import BuildpackSettings from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
@@ -34,6 +33,7 @@ const branchContentsSchema = z
   .array();
 
 type BranchContents = z.infer<typeof branchContentsSchema>;
+type BuildView = "docker" | "pack";
 
 const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
   const {
@@ -42,8 +42,8 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
     register,
     setValue,
   } = useFormContext<PorterAppFormData>();
-  const [buildView, setBuildView] = useState<BuildMethod>("buildpacks");
   const [showSettings, setShowSettings] = useState<boolean>(false);
+  const method = watch("app.build.method");
 
   const repoIsSet = useMemo(() => source.git_repo_name !== "", [
     source.git_repo_name,
@@ -83,7 +83,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
     const hasDockerfile = branchContents.some((item) =>
       item.path.includes("Dockerfile")
     );
-    setBuildView(hasDockerfile ? "docker" : "buildpacks");
+    setValue("app.build.method", hasDockerfile ? "docker" : "pack");
   }, [branchContents]);
 
   return (
@@ -208,7 +208,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
                   setShowSettings(!showSettings);
                 }}
               >
-                {buildView == "docker" ? (
+                {method == "docker" ? (
                   <AdvancedBuildTitle>
                     <i className="material-icons dropdown">arrow_drop_down</i>
                     Configure Dockerfile settings
@@ -224,18 +224,18 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
               <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
                 <StyledSourceBox>
                   <Select
-                    value={buildView}
+                    value={method}
                     width="300px"
                     options={[
                       { value: "docker", label: "Docker" },
-                      { value: "buildpacks", label: "Buildpacks" },
+                      { value: "pack", label: "Buildpacks" },
                     ]}
                     setValue={(option: string) =>
-                      setBuildView(option as BuildMethod)
+                      setValue("app.build.method", option as BuildView)
                     }
                     label="Build method"
                   />
-                  {buildView === "docker" ? (
+                  {method === "docker" ? (
                     <>
                       <Spacer y={0.5} />
                       <Text color="helper">

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

@@ -150,54 +150,53 @@ const ServiceList: React.FC<ServiceListProps> = ({
       {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")}
+          <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" },
+                  ]}
+                />
+              )}
             />
-            <Spacer y={1} />
-            <Button
-              type="submit"
-              disabled={
-                isServiceNameDuplicate(serviceName) || serviceName?.length > 61
-              }
-            >
-              <I className="material-icons">add</I> Add service
-            </Button>
-          </form>
+          </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="button"
+            onClick={onSubmit}
+            disabled={
+              isServiceNameDuplicate(serviceName) || serviceName?.length > 61
+            }
+          >
+            <I className="material-icons">add</I> Add service
+          </Button>
         </Modal>
       )}
     </>