Jelajahi Sumber

update provisioning flow to give user options after failure

Alexander Belanger 4 tahun lalu
induk
melakukan
679d5da58c

+ 382 - 0
dashboard/src/components/MultiSaveButton.tsx

@@ -0,0 +1,382 @@
+import React, { Component, useState } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+import MultiSelect from "./porter-form/field-components/MultiSelect";
+import Description from "./Description";
+
+type MultiSelectOption = {
+  text: string;
+  onClick: () => void;
+  description: string;
+};
+
+type Props = {
+  options: MultiSelectOption[];
+
+  disabled?: boolean;
+  status?: string | null;
+  color?: string;
+  rounded?: boolean;
+  helper?: string | null;
+  saveText?: string | null;
+
+  // Makes flush with corner if not within a modal
+  makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
+  // Provide the classname to modify styles from other components
+  className?: string;
+  successText?: string;
+};
+
+const MultiSaveButton: React.FC<Props> = (props) => {
+  const [currOption, setCurrOption] = useState<MultiSelectOption>(
+    props.options[0]
+  );
+
+  const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+
+  const renderStatus = () => {
+    if (props.status) {
+      if (props.status === "successful") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={true}>
+            <i className="material-icons">done</i>
+            <StatusTextWrapper>
+              {props?.successText || "Successfully updated"}
+            </StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else if (props.status === "loading") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else if (props.status === "error") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>Could not update</StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
+          </StatusWrapper>
+        );
+      }
+    } else if (props.helper) {
+      return (
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
+        </StatusWrapper>
+      );
+    }
+  };
+
+  const renderDropdown = () => {
+    if (isDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
+          <OptionWrapper
+            dropdownWidth="400px"
+            dropdownMaxHeight="300px"
+            onClick={() => setIsDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </OptionWrapper>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return props.options.map((option, i, originalArray) => {
+      return (
+        <Option
+          key={i}
+          selected={option.text === currOption.text}
+          onClick={() => setCurrOption(option)}
+          lastItem={i === originalArray.length - 1}
+        >
+          {option.text}
+          <OptionDescription margin="8px 0 0 0">
+            {option.description}
+          </OptionDescription>
+        </Option>
+      );
+    });
+  };
+
+  return (
+    <DropdownSelector>
+      <ButtonWrapper
+        makeFlush={props.makeFlush}
+        clearPosition={props.clearPosition}
+        className={props.className}
+      >
+        {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
+        <Button
+          rounded={props.rounded}
+          disabled={props.disabled}
+          onClick={currOption.onClick}
+          color={props.color || "#5561C0"}
+        >
+          {currOption.text}
+        </Button>
+        <DropdownButton
+          disabled={props.disabled}
+          color={props.color || "#5561C0"}
+          onClick={() => setIsDropdownExpanded(!isDropdownExpanded)}
+        >
+          <i className="material-icons expand-icon">
+            {isDropdownExpanded ? "expand_less" : "expand_more"}
+          </i>
+        </DropdownButton>
+        {props.statusPosition === "right" && <div>{renderStatus()}</div>}
+      </ButtonWrapper>
+      {renderDropdown()}
+    </DropdownSelector>
+  );
+};
+
+export default MultiSaveButton;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const StatusTextWrapper = styled.p`
+  display: -webkit-box;
+  line-clamp: 2;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 19px;
+  margin: 0;
+`;
+
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
+  max-width: 500px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
+  }
+
+  animation-fill-mode: forwards;
+
+  @keyframes statusFloatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const ButtonWrapper = styled.div`
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+      z-index: 99;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
+    if (!props.makeFlush) {
+      return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
+        bottom: 25px;
+        right: 27px;
+        left: 27px;
+      `;
+    }
+    return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
+      bottom: 5px;
+      right: 0;
+    `;
+  }}
+`;
+
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px 0 0 5px")};
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    margin-left: -5px;
+    justify-content: center;
+  }
+`;
+
+const DropdownSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const OptionWrapper = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  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: 0px 4px 10px 0px #00000088;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  cursor: pointer;
+  padding: 10px;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const DropdownButton = styled.div<{
+  disabled: boolean;
+  color: string;
+}>`
+  height: 35px;
+  border-radius: 0 5px 5px 0;
+  color: white;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  margin-left: 1px;
+  padding: 9px;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const OptionDescription = styled(Description)`
+  font-weight: 400;
+  line-height: 150%;
+`;

+ 127 - 13
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -21,9 +21,12 @@ import Description from "components/Description";
 import api from "shared/api";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
+import MultiSaveButton from "components/MultiSaveButton";
 
 type Props = {};
 
+type SaveButtonOptions = "retry" | "delete_all" | "back";
+
 const ProvisionResources: React.FC<Props> = () => {
   const snap = useSnapshot(OFState);
   const { step } = useParams<{ step: any }>();
@@ -32,6 +35,10 @@ const ProvisionResources: React.FC<Props> = () => {
     errored_infras: number[];
     description?: string;
   }>(null);
+  const [
+    failedSaveButtonOption,
+    setFailedSaveButtonOption,
+  ] = useState<SaveButtonOptions>("retry");
 
   const [isLoading, setIsLoading] = useState(false);
 
@@ -83,6 +90,93 @@ const ProvisionResources: React.FC<Props> = () => {
     });
   };
 
+  const deleteAllInfras = () => {
+    // since this is onboarding, we start deletion for all infras even if they're errored, and send
+    // the user back to the settings page.
+    api
+      .getInfra(
+        "<token>",
+        {},
+        {
+          project_id: project.id,
+        }
+      )
+      .then(({ data }) => {
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        // call API endpoint to retry all failed infras
+        const promises = Promise.all(
+          data?.map(async (erroredInfraID) => {
+            const res = await api.destroyInfra(
+              "<token>",
+              {},
+              {
+                project_id: project.id,
+                infra_id: erroredInfraID,
+              }
+            );
+            return res.data;
+          })
+        );
+
+        promises.then(() => {
+          // TODO: send the user back to the settings page
+          handleGoBack(
+            "Infrastructure successfully deleted: please configure settings and try again."
+          );
+        });
+      })
+      .catch((err) => {
+        console.error(err);
+        setIsLoading(false);
+      });
+  };
+
+  const getFailedSaveButton = () => {
+    switch (failedSaveButtonOption) {
+      case "retry":
+        return (
+          <SaveButton
+            text="Retry"
+            disabled={false}
+            onClick={retryFailedInfras}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        );
+      case "delete_all":
+        return (
+          <SaveButton
+            text="Delete All Infrastructure"
+            disabled={false}
+            onClick={deleteAllInfras}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        );
+      case "back":
+        return (
+          <SaveButton
+            text="Configure Settings"
+            disabled={false}
+            onClick={() => {
+              handleGoBack("");
+            }}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        );
+    }
+  };
+
   const renderSaveButton = () => {
     if (typeof infraStatus?.hasError !== "boolean") {
       return;
@@ -108,18 +202,30 @@ const ProvisionResources: React.FC<Props> = () => {
         <>
           <Br height="15px" />
           <ErrorStateContainer>
-            <StyledBack>
-              <StyledDescription
-                margin="0"
-                onClick={() => handleGoBack(infraStatus.description)}
-              >
-                {"< Back to Settings"}
-              </StyledDescription>
-            </StyledBack>
-            <SaveButton
-              text="Retry"
+            <MultiSaveButton
+              options={[
+                {
+                  text: "Retry Failed Resources",
+                  description:
+                    "Retry all failed resources. This continues provisioning from the last resource which errored out.",
+                  onClick: retryFailedInfras,
+                },
+                {
+                  text: "Re-Configure Settings",
+                  description:
+                    "Re-configure settings for the infrastructure. This continues provisioning from the last resource which errored out with different settings.",
+                  onClick: () => {
+                    handleGoBack("");
+                  },
+                },
+                {
+                  text: "Delete All Resources",
+                  description:
+                    "Delete all resources. This begins the delete process for all resources so that you can start from scratch.",
+                  onClick: deleteAllInfras,
+                },
+              ]}
               disabled={false}
-              onClick={retryFailedInfras}
               makeFlush={true}
               clearPosition={true}
               statusPosition="right"
@@ -131,6 +237,14 @@ const ProvisionResources: React.FC<Props> = () => {
     }
   };
 
+  const getDescription = () => {
+    if (infraStatus && infraStatus.hasError) {
+      return "Error while creating infrastructure. Please select an option below to continue.";
+    }
+
+    return "Note: Provisioning can take up to 15 minutes.";
+  };
+
   const getFilterOpts = (): string[] => {
     switch (provider) {
       case "aws":
@@ -168,10 +282,10 @@ const ProvisionResources: React.FC<Props> = () => {
               auto_expanded
               sortBy="id"
               set_max_width={true}
-              can_delete={true}
+              can_delete={false}
             />
             <Br />
-            <Helper>Note: Provisioning can take up to 15 minutes.</Helper>
+            <Helper>{getDescription()}</Helper>
             {renderSaveButton()}
           </>
         );

+ 124 - 20
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -13,6 +13,7 @@ import { useSnapshot } from "valtio";
 import Loading from "components/Loading";
 import Helper from "components/form-components/Helper";
 import { readableDate } from "shared/string_utils";
+import { Infrastructure } from "shared/types";
 
 const regionOptions = [
   { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
@@ -270,6 +271,61 @@ export const SettingsForm: React.FC<{
   const [clusterName, setClusterName] = useState(`${project.name}-cluster`);
   const [machineType, setMachineType] = useState("t2.medium");
   const [buttonStatus, setButtonStatus] = useState("");
+  const [currEKSInfra, setCurrEKSInfra] = useState<Infrastructure>();
+  const [currECRInfra, setCurrECRInfra] = useState<Infrastructure>();
+
+  useEffect(() => {
+    if (!project) {
+      return;
+    }
+
+    api
+      .getInfra<Infrastructure[]>("<token>", {}, { project_id: project.id })
+      .then(({ data }) => {
+        let sortFunc = (a: Infrastructure, b: Infrastructure) => {
+          return b.id < a.id ? -1 : b.id > a.id ? 1 : 0;
+        };
+
+        const matchedEKSInfras = data
+          .filter((infra) => infra.kind == "eks")
+          .sort(sortFunc);
+        const matchedECRInfras = data
+          .filter((infra) => infra.kind == "ecr")
+          .sort(sortFunc);
+
+        if (matchedEKSInfras.length > 0) {
+          // get the infra with latest operation details from the API
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedEKSInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrEKSInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+
+        if (matchedECRInfras.length > 0) {
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedECRInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrECRInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+      })
+      .catch((err) => {});
+  }, [project]);
 
   const validate = () => {
     if (!clusterName) {
@@ -292,7 +348,7 @@ export const SettingsForm: React.FC<{
     infras: { kind: string; status: string }[]
   ) => {
     return !!infras.find(
-      (i) => ["docr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
+      (i) => ["ecr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
     );
   };
 
@@ -300,39 +356,66 @@ export const SettingsForm: React.FC<{
     infras: { kind: string; status: string }[]
   ) => {
     return !!infras.find(
-      (i) => ["doks", "gks", "eks"].includes(i.kind) && i.status === "created"
+      (i) => ["eks", "gks", "eks"].includes(i.kind) && i.status === "created"
     );
   };
 
   const provisionECR = async (awsIntegrationId: number) => {
     console.log("Started provision ECR");
 
-    try {
-      return await api
-        .provisionInfra(
+    // See if there's an infra for EKS that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currECRInfra?.latest_operation?.type == "create" ||
+      currECRInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
           "<token>",
           {
-            kind: "ecr",
             values: {
               ecr_name: `${project.name}-registry`,
             },
             aws_integration_id: awsIntegrationId,
           },
-          { project_id: project.id }
-        )
-        .then((res) => res?.data);
-    } catch (error) {
-      catchError(error);
+          { project_id: project.id, infra_id: currECRInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        return await api
+          .provisionInfra(
+            "<token>",
+            {
+              kind: "ecr",
+              values: {
+                ecr_name: `${project.name}-registry`,
+              },
+              aws_integration_id: awsIntegrationId,
+            },
+            { project_id: project.id }
+          )
+          .then((res) => res?.data);
+      } catch (error) {
+        catchError(error);
+      }
     }
   };
 
   const provisionEKS = async (awsIntegrationId: number) => {
-    try {
-      return await api
-        .provisionInfra(
+    // See if there's an infra for EKS that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currEKSInfra?.latest_operation?.type == "create" ||
+      currEKSInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
           "<token>",
           {
-            kind: "eks",
             values: {
               cluster_name: clusterName,
               machine_type: machineType,
@@ -340,11 +423,32 @@ export const SettingsForm: React.FC<{
             },
             aws_integration_id: awsIntegrationId,
           },
-          { project_id: project.id }
-        )
-        .then((res) => res?.data);
-    } catch (error) {
-      catchError(error);
+          { project_id: project.id, infra_id: currEKSInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        return await api
+          .provisionInfra(
+            "<token>",
+            {
+              kind: "eks",
+              values: {
+                cluster_name: clusterName,
+                machine_type: machineType,
+                issuer_email: snap.StateHandler.user_email,
+              },
+              aws_integration_id: awsIntegrationId,
+            },
+            { project_id: project.id }
+          )
+          .then((res) => res?.data);
+      } catch (error) {
+        catchError(error);
+      }
     }
   };
 

+ 130 - 23
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

@@ -10,6 +10,7 @@ import styled from "styled-components";
 import { useSnapshot } from "valtio";
 import Loading from "components/Loading";
 import { readableDate } from "shared/string_utils";
+import { Infrastructure } from "shared/types";
 
 const tierOptions = [
   { value: "basic", label: "Basic" },
@@ -152,6 +153,61 @@ export const SettingsForm: React.FC<{
   const [tier, setTier] = useState("basic");
   const [region, setRegion] = useState("nyc1");
   const [clusterName, setClusterName] = useState(`${project.name}-cluster`);
+  const [currDOKSInfra, setCurrDOKSInfra] = useState<Infrastructure>();
+  const [currDOCRInfra, setCurrDOCRInfra] = useState<Infrastructure>();
+
+  useEffect(() => {
+    if (!project) {
+      return;
+    }
+
+    api
+      .getInfra<Infrastructure[]>("<token>", {}, { project_id: project.id })
+      .then(({ data }) => {
+        let sortFunc = (a: Infrastructure, b: Infrastructure) => {
+          return b.id < a.id ? -1 : b.id > a.id ? 1 : 0;
+        };
+
+        const matchedDOKSInfras = data
+          .filter((infra) => infra.kind == "doks")
+          .sort(sortFunc);
+        const matchedDOCRInfras = data
+          .filter((infra) => infra.kind == "docr")
+          .sort(sortFunc);
+
+        if (matchedDOKSInfras.length > 0) {
+          // get the infra with latest operation details from the API
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedDOKSInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrDOKSInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+
+        if (matchedDOCRInfras.length > 0) {
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedDOCRInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrDOCRInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+      })
+      .catch((err) => {});
+  }, [project]);
 
   const validate = () => {
     if (!clusterName) {
@@ -180,7 +236,7 @@ export const SettingsForm: React.FC<{
     infras: { kind: string; status: string }[]
   ) => {
     return !!infras.find(
-      (i) => ["docr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
+      (i) => ["docr", "docr", "ecr"].includes(i.kind) && i.status === "created"
     );
   };
 
@@ -194,25 +250,50 @@ export const SettingsForm: React.FC<{
 
   const provisionDOCR = async (integrationId: number, tier: string) => {
     console.log("Provisioning DOCR...");
-    try {
-      return await api
-        .provisionInfra(
+
+    // See if there's an infra for DOKS that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currDOCRInfra?.latest_operation?.type == "create" ||
+      currDOCRInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
           "<token>",
           {
-            kind: "docr",
             do_integration_id: integrationId,
             values: {
               docr_name: project.name,
               docr_subscription_tier: tier,
             },
           },
-          {
-            project_id: project.id,
-          }
-        )
-        .then((res) => res?.data);
-    } catch (error) {
-      catchError(error);
+          { project_id: project.id, infra_id: currDOCRInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        return await api
+          .provisionInfra(
+            "<token>",
+            {
+              kind: "docr",
+              do_integration_id: integrationId,
+              values: {
+                docr_name: project.name,
+                docr_subscription_tier: tier,
+              },
+            },
+            {
+              project_id: project.id,
+            }
+          )
+          .then((res) => res?.data);
+      } catch (error) {
+        catchError(error);
+      }
     }
   };
 
@@ -222,12 +303,17 @@ export const SettingsForm: React.FC<{
     clusterName: string
   ) => {
     console.log("Provisioning DOKS...");
-    try {
-      return await api
-        .provisionInfra(
+
+    // See if there's an infra for DOKS that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currDOKSInfra?.latest_operation?.type == "create" ||
+      currDOKSInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
           "<token>",
           {
-            kind: "doks",
             do_integration_id: integrationId,
             values: {
               cluster_name: clusterName,
@@ -235,13 +321,34 @@ export const SettingsForm: React.FC<{
               issuer_email: snap.StateHandler.user_email,
             },
           },
-          {
-            project_id: project.id,
-          }
-        )
-        .then((res) => res?.data);
-    } catch (error) {
-      catchError(error);
+          { project_id: project.id, infra_id: currDOKSInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        return await api
+          .provisionInfra(
+            "<token>",
+            {
+              kind: "doks",
+              do_integration_id: integrationId,
+              values: {
+                cluster_name: clusterName,
+                do_region: region,
+                issuer_email: snap.StateHandler.user_email,
+              },
+            },
+            {
+              project_id: project.id,
+            }
+          )
+          .then((res) => res?.data);
+      } catch (error) {
+        catchError(error);
+      }
     }
   };
 

+ 131 - 29
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -12,6 +12,7 @@ import {
 import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import { readableDate } from "shared/string_utils";
+import { Infrastructure } from "shared/types";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
 
@@ -254,8 +255,63 @@ export const SettingsForm: React.FC<{
   const [region, setRegion] = useState("us-east1");
   const [clusterName, setClusterName] = useState(`${project.name}-cluster`);
   const [buttonStatus, setButtonStatus] = useState("");
+  const [currGKEInfra, setCurrGKEInfra] = useState<Infrastructure>();
+  const [currGCRInfra, setCurrGCRInfra] = useState<Infrastructure>();
   const snap = useSnapshot(OFState);
 
+  useEffect(() => {
+    if (!project) {
+      return;
+    }
+
+    api
+      .getInfra<Infrastructure[]>("<token>", {}, { project_id: project.id })
+      .then(({ data }) => {
+        let sortFunc = (a: Infrastructure, b: Infrastructure) => {
+          return b.id < a.id ? -1 : b.id > a.id ? 1 : 0;
+        };
+
+        const matchedGKEInfras = data
+          .filter((infra) => infra.kind == "gke")
+          .sort(sortFunc);
+        const matchedGCRInfras = data
+          .filter((infra) => infra.kind == "gcr")
+          .sort(sortFunc);
+
+        if (matchedGKEInfras.length > 0) {
+          // get the infra with latest operation details from the API
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedGKEInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrGKEInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+
+        if (matchedGCRInfras.length > 0) {
+          api
+            .getInfraByID(
+              "<token>",
+              {},
+              { project_id: project.id, infra_id: matchedGCRInfras[0].id }
+            )
+            .then(({ data }) => {
+              setCurrGCRInfra(data);
+            })
+            .catch((err) => {
+              console.error(err);
+            });
+        }
+      })
+      .catch((err) => {});
+  }, [project]);
+
   const validate = () => {
     if (!clusterName) {
       return {
@@ -334,42 +390,88 @@ export const SettingsForm: React.FC<{
   const provisionGCR = async (id: number) => {
     console.log("Provisioning GCR");
 
-    try {
-      const res = await api.provisionInfra(
-        "<token>",
-        {
-          kind: "gcr",
-          gcp_integration_id: id,
-          values: {},
-        },
-        { project_id: project.id }
-      );
-      return res?.data;
-    } catch (error) {
-      return catchError(error);
+    // See if there's an infra for GKE that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currGCRInfra?.latest_operation?.type == "create" ||
+      currGCRInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
+          "<token>",
+          {
+            gcp_integration_id: id,
+            values: {},
+          },
+          { project_id: project.id, infra_id: currGCRInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        const res = await api.provisionInfra(
+          "<token>",
+          {
+            kind: "gcr",
+            gcp_integration_id: id,
+            values: {},
+          },
+          { project_id: project.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
     }
   };
 
   const provisionGKE = async (id: number) => {
     console.log("Provisioning GKE");
 
-    try {
-      const res = await api.provisionInfra(
-        "<token>",
-        {
-          kind: "gke",
-          gcp_integration_id: id,
-          values: {
-            gcp_region: region,
-            cluster_name: clusterName,
-            issuer_email: snap.StateHandler.user_email,
+    // See if there's an infra for GKE that is in an errored state and the last operation
+    // was an attempt at creation. If so, re-use that infra.
+    if (
+      currGKEInfra?.latest_operation?.type == "create" ||
+      currGKEInfra?.latest_operation?.type == "retry_create"
+    ) {
+      try {
+        const res = await api.retryCreateInfra(
+          "<token>",
+          {
+            gcp_integration_id: id,
+            values: {
+              gcp_region: region,
+              cluster_name: clusterName,
+              issuer_email: snap.StateHandler.user_email,
+            },
           },
-        },
-        { project_id: project.id }
-      );
-      return res?.data;
-    } catch (error) {
-      return catchError(error);
+          { project_id: project.id, infra_id: currGKEInfra.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
+    } else {
+      try {
+        const res = await api.provisionInfra(
+          "<token>",
+          {
+            kind: "gke",
+            gcp_integration_id: id,
+            values: {
+              gcp_region: region,
+              cluster_name: clusterName,
+              issuer_email: snap.StateHandler.user_email,
+            },
+          },
+          { project_id: project.id }
+        );
+        return res?.data;
+      } catch (error) {
+        return catchError(error);
+      }
     }
   };
 

+ 23 - 12
dashboard/src/shared/api.tsx

@@ -422,9 +422,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -440,9 +442,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -458,9 +462,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -690,6 +696,9 @@ const updateInfra = baseApi<
 
 const retryCreateInfra = baseApi<
   {
+    aws_integration_id?: number;
+    gcp_integration_id?: number;
+    do_integration_id?: number;
     values?: any;
   },
   {
@@ -1163,9 +1172,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<

+ 7 - 1
provisioner/integrations/redis_stream/global.go

@@ -162,7 +162,13 @@ func GlobalStreamListener(
 
 			switch fmt.Sprintf("%v", statusVal) {
 			case "created":
-				handleOperationCreated(config, client, infra, operation, workspaceID)
+				err := handleOperationCreated(config, client, infra, operation, workspaceID)
+
+				if err != nil {
+					config.Alerter.SendAlert(context.Background(), err, map[string]interface{}{
+						"workspace_id": workspaceID,
+					})
+				}
 			case "error":
 			case "destroyed":
 			}