Просмотр исходного кода

create deployment target in app creation (#4072)

d-g-town 2 лет назад
Родитель
Сommit
b04a0ebc0d

+ 10 - 3
api/server/handlers/deployment_target/create.go

@@ -32,7 +32,9 @@ func NewCreateDeploymentTargetHandler(
 
 // CreateDeploymentTargetRequest is the request object for the /deployment-targets POST endpoint
 type CreateDeploymentTargetRequest struct {
+	// Deprecated: use name instead
 	Selector string `json:"selector"`
+	Name     string `json:"name,omitempty"`
 	Preview  bool   `json:"preview"`
 }
 
@@ -61,17 +63,22 @@ func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	if request.Selector == "" {
+	if request.Selector == "" && request.Name == "" {
 		err := telemetry.Error(ctx, span, nil, "name is required")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
+	name := request.Name
+	if name == "" {
+		name = request.Selector
+	}
+
 	createReq := connect.NewRequest(&porterv1.CreateDeploymentTargetRequest{
 		ProjectId: int64(project.ID),
 		ClusterId: int64(cluster.ID),
-		Name:      request.Selector,
-		Namespace: request.Selector,
+		Name:      name,
+		Namespace: name,
 		IsPreview: request.Preview,
 	})
 

+ 229 - 0
dashboard/src/components/CreateDeploymentTargetModal.tsx

@@ -0,0 +1,229 @@
+import React, { useContext, useEffect, useState } from "react";
+import axios from "axios";
+import styled from "styled-components";
+import { z } from "zod";
+
+import target from "assets/target.svg";
+
+import { useDeploymentTargetList } from "../lib/hooks/useDeploymentTarget";
+import { RestrictedNamespaces } from "../main/home/add-on-dashboard/AddOnDashboard";
+import api from "../shared/api";
+import { Context } from "../shared/Context";
+import InputRow from "./form-components/InputRow";
+import Button from "./porter/Button";
+import Modal from "./porter/Modal";
+import Spacer from "./porter/Spacer";
+import Text from "./porter/Text";
+
+type Props = {
+  closeModal: () => void;
+  setDeploymentTargetID: (id: string) => void;
+};
+
+const CreateDeploymentTargetModal: React.FC<Props> = ({
+  closeModal,
+  setDeploymentTargetID,
+}) => {
+  const [creationError, setCreationError] = useState("");
+  const [deploymentTargetCreationStatus, setDeploymentTargetCreationStatus] =
+    useState<string>("");
+  const [isNameHighlight, setIsNameHighlight] = useState(false);
+  const [isNameValid, setIsNameValid] = useState(false);
+  const [deploymentTargetName, setDeploymentTargetName] = useState("");
+  const { deploymentTargetList, isDeploymentTargetListLoading } =
+    useDeploymentTargetList({ preview: false });
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const isRestrictedName = (name: string): boolean =>
+    RestrictedNamespaces.includes(name);
+
+  const hasInvalidCharacters = (name: string): boolean =>
+    name !== "" && !/^([a-z0-9]|-)+$/.test(name);
+
+  useEffect(() => {
+    validateName(deploymentTargetName);
+  }, [deploymentTargetName]);
+
+  const validateName = (name: string): void => {
+    setIsNameValid(false);
+
+    if (hasInvalidCharacters(name)) {
+      setCreationError("Only lowercase, numbers or dash (-) are allowed");
+      setIsNameHighlight(true);
+      return;
+    }
+
+    setIsNameHighlight(false);
+
+    if (isRestrictedName(name)) {
+      setCreationError("Name is a Porter-internal name");
+      return;
+    }
+
+    if (name.length > 60) {
+      setCreationError("Name must be 60 characters or fewer");
+      return;
+    }
+
+    const deploymentTargetExists = deploymentTargetList.find(
+      ({ name: deploymentTarget }) => {
+        return deploymentTarget === name;
+      }
+    );
+
+    if (deploymentTargetExists) {
+      setCreationError(
+        "Deployment target name already exists, choose another name"
+      );
+      return;
+    }
+
+    setIsNameValid(true);
+    setCreationError("");
+  };
+
+  const createDeploymentTarget = (): void => {
+    if (!currentProject) {
+      setCreationError("Could not find current project");
+      return;
+    }
+
+    if (!currentCluster) {
+      setCreationError("Could not find current cluster");
+      return;
+    }
+
+    setDeploymentTargetCreationStatus("loading");
+
+    api
+      .createDeploymentTarget(
+        "<token>",
+        {
+          name: deploymentTargetName,
+          preview: false,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        res.data.deployment_target_id &&
+          setDeploymentTargetID(res.data.deployment_target_id);
+        setDeploymentTargetCreationStatus("successful");
+        closeModal();
+      })
+      .catch((err) => {
+        let message = "Could not create";
+        if (axios.isAxiosError(err)) {
+          const parsed = z
+            .object({ error: z.string() })
+            .safeParse(err.response?.data);
+          if (parsed.success) {
+            message = `Deployment target creation failed: ${parsed.data.error}`;
+          }
+        }
+        setDeploymentTargetCreationStatus("error");
+        setCreationError(message);
+      });
+  };
+
+  return (
+    <>
+      <Modal closeModal={closeModal}>
+        <Subtitle>Deployment target name</Subtitle>
+        <Spacer y={1} />
+        <Text color={isNameHighlight ? "#FFCC00" : "helper"}>
+          Lowercase letters, numbers, and &quot;-&quot; only.
+        </Text>
+        <InputWrapper>
+          <DashboardIcon>
+            <img src={target} />
+          </DashboardIcon>
+          <InputRow
+            type="string"
+            value={deploymentTargetName}
+            setValue={(x: string | number) => {
+              if (typeof x === "string") {
+                setDeploymentTargetName(x);
+                setCreationError("");
+              }
+            }}
+            placeholder="ex: porter-workers"
+            width="480px"
+          />
+        </InputWrapper>
+        <Spacer y={0.5} />
+        <Button
+          onClick={createDeploymentTarget}
+          disabled={
+            isDeploymentTargetListLoading ||
+            deploymentTargetName === "" ||
+            deploymentTargetCreationStatus === "loading" ||
+            !isNameValid
+          }
+          status={creationError ? "error" : deploymentTargetCreationStatus}
+          errorText={creationError}
+          width="220px"
+        >
+          <I className="material-icons">add</I> Create deployment target
+        </Button>
+      </Modal>
+    </>
+  );
+};
+
+export default CreateDeploymentTargetModal;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;
+
+const DashboardIcon = styled.div`
+  border: 1px solid #ffffff55;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 37px;
+  width: 35px;
+  min-width: 35px;
+  margin-right: 10px;
+  margin-top: 8px;
+  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 InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 15px;
+  color: #55555;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;

+ 351 - 0
dashboard/src/components/porter/Selector.tsx

@@ -0,0 +1,351 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+
+import { useOutsideClick } from "../../lib/hooks/UseOutsideClick";
+
+export type SelectorPropsType<T> = {
+  activeValue: T;
+  refreshOptions?: () => void;
+  options: Array<{ value: T; label: string; key: string; icon?: JSX.Element }>;
+  createNew?: { openModal: (x: boolean) => void; label: string };
+  setActiveValue: (x: T) => void;
+  width: string;
+  height?: string;
+  disabled?: boolean;
+  dropdownLabel?: string;
+  dropdownWidth?: string;
+  dropdownMaxHeight?: string;
+  placeholder?: string;
+  scrollBuffer?: boolean;
+  isLoading?: boolean;
+  label?: string;
+};
+
+const Selector = <T,>({
+  activeValue,
+  refreshOptions,
+  options,
+  createNew,
+  setActiveValue,
+  width,
+  height,
+  disabled,
+  dropdownLabel,
+  dropdownWidth,
+  dropdownMaxHeight,
+  placeholder,
+  scrollBuffer,
+  isLoading,
+  label,
+}: SelectorPropsType<T>): JSX.Element => {
+  const [expanded, setExpanded] = useState(false);
+
+  const ref = useOutsideClick(() => {
+    setExpanded(false);
+  });
+
+  const renderOptionList = (): JSX.Element => {
+    return (
+      <>
+        {options.map(
+          (
+            option: {
+              value: T;
+              label: string;
+              key: string;
+              icon?: JSX.Element;
+            },
+            i: number
+          ) => {
+            return (
+              <Option
+                key={option.key}
+                height={height || ""}
+                selected={option.value === activeValue}
+                onClick={() => {
+                  handleOptionClick(option);
+                }}
+                lastItem={i === options.length - 1}
+              >
+                {option.icon && <Icon>{option.icon}</Icon>}
+                {option.label}
+              </Option>
+            );
+          }
+        )}
+      </>
+    );
+  };
+
+  const handleOptionClick = (option: { value: T; label: string }): void => {
+    setActiveValue(option.value);
+    setExpanded(false);
+  };
+
+  const renderDropdownLabel = (): JSX.Element | null => {
+    if (!dropdownLabel) {
+      return null;
+    }
+
+    return <DropdownLabel>{dropdownLabel}</DropdownLabel>;
+  };
+
+  const renderAddButton = (): JSX.Element | null => {
+    if (!createNew) {
+      return null;
+    }
+
+    return (
+      <NewOption
+        onClick={() => {
+          createNew.openModal(true);
+        }}
+      >
+        <Plus>+</Plus>
+        {createNew.label}
+      </NewOption>
+    );
+  };
+
+  const renderDropdown = (): JSX.Element => {
+    return (
+      <DropdownWrapper>
+        <Dropdown
+          dropdownWidth={dropdownWidth || width}
+          dropdownMaxHeight={dropdownMaxHeight || ""}
+          onClick={() => {
+            setExpanded(false);
+          }}
+        >
+          {renderDropdownLabel()}
+          {renderOptionList()}
+          {renderAddButton()}
+        </Dropdown>
+        {scrollBuffer && <ScrollBuffer />}
+      </DropdownWrapper>
+    );
+  };
+
+  const getLabel = (value: T): string | undefined => {
+    const tgt = options.find(
+      (element: { value: T; label: string }) => element.value === value
+    );
+    if (tgt) {
+      return tgt.label;
+    }
+  };
+
+  const renderIcon = (): JSX.Element | null => {
+    const icon = options.find((opt) => opt.icon && opt.value === activeValue);
+
+    if (!icon) {
+      return null;
+    }
+
+    return <Icon>{icon.icon}</Icon>;
+  };
+
+  return (
+    <StyledSelector width={width} ref={ref}>
+      {label && <Label>{label}</Label>}
+      <MainSelector
+        disabled={disabled}
+        onClick={() => {
+          if (!disabled) {
+            if (refreshOptions) {
+              refreshOptions();
+            }
+            setExpanded(!expanded);
+          }
+        }}
+        expanded={expanded}
+        width={width}
+        height={height}
+      >
+        {isLoading ? (
+          <Loading />
+        ) : (
+          <>
+            <Flex>
+              {renderIcon()}
+              <TextWrap>
+                {activeValue
+                  ? activeValue === ""
+                    ? "All"
+                    : getLabel(activeValue)
+                  : placeholder}
+              </TextWrap>
+            </Flex>
+            <i className="material-icons">arrow_drop_down</i>
+          </>
+        )}
+      </MainSelector>
+      {expanded && renderDropdown()}
+    </StyledSelector>
+  );
+};
+
+export default Selector;
+
+const Label = styled.div<{ color?: string }>`
+  font-size: 13px;
+  color: ${({ color = "#aaaabb" }) => color};
+  margin-bottom: 10px;
+`;
+
+const DropdownWrapper = styled.div`
+  position: absolute;
+  width: 100%;
+  right: 0;
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const ScrollBuffer = styled.div`
+  width: 100%;
+  height: 50px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 85%;
+`;
+
+const Icon = styled.div`
+  height: 20px;
+  width: 30px;
+  margin-left: -5px;
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+
+  > img {
+    height: 18px;
+    width: auto;
+  }
+`;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 18px;
+`;
+
+const TextWrap = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  z-index: 0;
+`;
+
+const DropdownLabel = styled.div`
+  font-size: 13px;
+  color: #ffffff44;
+  font-weight: 500;
+  margin: 10px 13px;
+`;
+
+const NewOption = styled.div`
+  display: flex;
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff00;
+  height: 37px;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+type OptionProps = {
+  selected: boolean;
+  lastItem: boolean;
+  height: string;
+};
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: OptionProps) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+  height: ${(props: OptionProps) => props.height || "37px"};
+  font-size: 13px;
+  align-items: center;
+  display: flex;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: OptionProps) => (props.selected ? "#ffffff11" : "")};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+`;
+
+const StyledSelector = styled.div<{ width: string }>`
+  position: relative;
+  width: ${(props) => props.width};
+`;
+
+const MainSelector = styled.div<{
+  disabled?: boolean;
+  expanded: boolean;
+  width: string;
+  height?: string;
+}>`
+  width: ${(props) => props.width};
+  height: ${(props) => (props.height ? props.height : "35px")};
+  border: 1px solid #ffffff55;
+  font-size: 13px;
+  padding: 5px 10px;
+  padding-left: 15px;
+  border-radius: 3px;
+  display: flex;
+  color: ${(props) => (props.disabled ? "#ffffff44" : "#ffffff")};
+  justify-content: space-between;
+  align-items: center;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  background: ${(props) => (props.expanded ? "#ffffff33" : props.theme.fg)};
+  :hover {
+    background: ${(props) =>
+      props.expanded
+        ? "#ffffff33"
+        : props.disabled
+        ? "#ffffff11"
+        : "#ffffff22"};
+  }
+
+  > i {
+    font-size: 20px;
+    transform: ${(props) => (props.expanded ? "rotate(180deg)" : "")};
+  }
+`;

+ 24 - 0
dashboard/src/lib/hooks/UseOutsideClick.ts

@@ -0,0 +1,24 @@
+import type React from "react";
+import { useEffect, useRef } from "react";
+
+export const useOutsideClick = (
+  callback: () => void
+): React.RefObject<HTMLDivElement> => {
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent): void => {
+      if (ref.current && !ref.current.contains(event.target as Node)) {
+        callback();
+      }
+    };
+
+    document.addEventListener("mousedown", handleClickOutside);
+
+    return () => {
+      document.removeEventListener("mousedown", handleClickOutside);
+    };
+  }, [callback]);
+
+  return ref;
+};

+ 3 - 1
dashboard/src/lib/hooks/useDeploymentTarget.ts

@@ -81,10 +81,11 @@ export function useDefaultDeploymentTarget(): {
 export function useDeploymentTargetList(input: { preview: boolean }): {
   deploymentTargetList: DeploymentTarget[];
   isDeploymentTargetListLoading: boolean;
+  refetchDeploymentTargetList: () => void;
 } {
   const { currentProject, currentCluster } = useContext(Context);
 
-  const { data = [], isLoading } = useQuery(
+  const { data = [], isLoading, refetch } = useQuery(
     [
       "listDeploymentTargets",
       currentProject?.id,
@@ -123,5 +124,6 @@ export function useDeploymentTargetList(input: { preview: boolean }): {
   return {
     deploymentTargetList: data,
     isDeploymentTargetListLoading: isLoading,
+    refetchDeploymentTargetList: refetch,
   };
 }

+ 2 - 2
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -40,7 +40,7 @@ import { useAuthState } from "main/auth/context";
 type Props = {
 };
 
-const namespaceBlacklist = [
+export const RestrictedNamespaces = [
   "ack-system",
   "cert-manager",
   "ingress-nginx",
@@ -72,7 +72,7 @@ const AddOnDashboard: React.FC<Props> = ({
   const filteredAddOns = useMemo(() => {
     const filtered = addOns.filter((app) => {
       return (
-        !namespaceBlacklist.includes(app.namespace) &&
+        !RestrictedNamespaces.includes(app.namespace) &&
         !templateBlacklist.includes(app.chart.metadata.name)
       );
     });

+ 32 - 6
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -15,7 +15,7 @@ import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Link from "components/porter/Link";
-import Select from "components/porter/Select";
+import Selector from "components/porter/Selector";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
@@ -45,6 +45,7 @@ import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 import web from "assets/web.png";
 
+import CreateDeploymentTargetModal from "components/CreateDeploymentTargetModal";
 import ImageSettings from "../image-settings/ImageSettings";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
 import SourceSelector from "../new-app-flow/SourceSelector";
@@ -204,8 +205,11 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   });
   const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
     useDefaultDeploymentTarget();
-  const { deploymentTargetList } = useDeploymentTargetList({ preview: false });
+  const { deploymentTargetList, refetchDeploymentTargetList } =
+    useDeploymentTargetList({ preview: false });
   const [deploymentTargetID, setDeploymentTargetID] = React.useState("");
+  const [showCreateDeploymentTargetModal, setShowCreateDeploymentTargetModal] =
+    React.useState(false);
   const { updateAppStep } = useAppAnalytics();
   const { validateApp } = useAppValidation({
     deploymentTargetID,
@@ -617,20 +621,27 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     {currentProject?.managed_deployment_targets_enabled && (
                       <>
                         <Spacer y={1} />
-                        <Select
-                          value={deploymentTargetID}
+                        <Selector<string>
+                          activeValue={deploymentTargetID}
                           width="300px"
                           options={deploymentTargetList.map(
                             (target: DeploymentTarget) => {
                               return {
                                 value: target.id,
                                 label: target.name,
+                                key: target.id,
                               };
                             }
                           )}
-                          setValue={(value) => {
+                          setActiveValue={(value: string) => {
                             setDeploymentTargetID(value);
                           }}
+                          createNew={{
+                            openModal: () => {
+                              setShowCreateDeploymentTargetModal(true);
+                            },
+                            label: "Create new deployment target",
+                          }}
                           label={"Deployment Target"}
                         />
                       </>
@@ -838,7 +849,22 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }
           deploymentError={deployError}
           porterYamlPath={source.porter_yaml_path}
-          redirectPath={currentProject.managed_deployment_targets_enabled ? `/apps/${name.value}?target=${deploymentTargetID}` : `/apps/${name.value}`}
+          redirectPath={
+            currentProject.managed_deployment_targets_enabled
+              ? `/apps/${name.value}?target=${deploymentTargetID}`
+              : `/apps/${name.value}`
+          }
+        />
+      )}
+      {showCreateDeploymentTargetModal && (
+        <CreateDeploymentTargetModal
+          closeModal={() => {
+            setShowCreateDeploymentTargetModal(false);
+          }}
+          setDeploymentTargetID={(x: string) => {
+            setDeploymentTargetID(x);
+            refetchDeploymentTargetList();
+          }}
         />
       )}
     </CenterWrapper>

+ 14 - 0
dashboard/src/shared/api.tsx

@@ -1194,6 +1194,19 @@ const listDeploymentTargets = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`;
 });
 
+const createDeploymentTarget = baseApi<
+    {
+        name: string;
+        preview: boolean;
+    },
+    {
+        project_id: number;
+        cluster_id: number;
+    }
+>("POST", ({ project_id, cluster_id }) => {
+    return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`;
+});
+
 const getDeploymentTarget = baseApi<
   {},
   {
@@ -3414,6 +3427,7 @@ export default {
   listAppRevisions,
   getLatestAppRevisions,
   listDeploymentTargets,
+  createDeploymentTarget,
   getDeploymentTarget,
   getAppTemplate,
   getGitlabProcfileContents,