Ver Fonte

Delete cluster and be redirected to Onboarding state (#3412)

sdess09 há 2 anos atrás
pai
commit
506c5e9915

+ 69 - 0
api/server/handlers/project_integration/preflight_check.go

@@ -0,0 +1,69 @@
+package project_integration
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreatePreflightCheckHandler Create Preflight Checks
+type CreatePreflightCheckHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCreatePreflightCheckHandler Create Preflight Checks
+func NewCreatePreflightCheckHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreatePreflightCheckHandler {
+	return &CreatePreflightCheckHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "preflight-checks")
+	defer span.End()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	cloudValues := &porterv1.PreflightCheckRequest{}
+	err := helpers.UnmarshalContractObjectFromReader(r.Body, cloudValues)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error unmarshalling preflight check data")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+		return
+	}
+
+	input := porterv1.PreflightCheckRequest{
+		ProjectId:                  int64(project.ID),
+		CloudProvider:              cloudValues.CloudProvider,
+		CloudProviderCredentialsId: cloudValues.CloudProviderCredentialsId,
+	}
+
+	if cloudValues.PreflightValues != nil {
+		if cloudValues.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP {
+			input.PreflightValues = cloudValues.PreflightValues
+		}
+	}
+
+	checkResp, err := p.Config().ClusterControlPlaneClient.PreflightCheck(ctx, connect.NewRequest(&input))
+	if err != nil {
+		e := fmt.Errorf("Pre-provision check failed: %w", err)
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+		return
+	}
+
+	p.WriteResult(w, r, checkResp)
+}

+ 28 - 0
api/server/router/project_integration.go

@@ -248,6 +248,34 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/integrations/preflightcheck -> project_integration.NewCreatePreflightCheckHandler
+	preflightCheckEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/preflightcheck",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	preflightCheckHandler := project_integration.NewCreatePreflightCheckHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: preflightCheckEndpoint,
+		Handler:  preflightCheckHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/integrations/azure -> project_integration.NewListAzureHandler
 	listAzureEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 141 - 77
dashboard/src/components/GCPCredentialsForm.tsx

@@ -12,6 +12,9 @@ import Text from "components/porter/Text";
 import Button from "components/porter/Button";
 import Spacer from "./porter/Spacer";
 import Container from "./porter/Container";
+import PreflightChecks from "./PreflightChecks";
+import { EnumCloudProvider, GKENetwork, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
+
 
 
 type Props = {
@@ -27,11 +30,19 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
   const [isLoading, setIsLoading] = useState(false);
   const [errorMessage, setErrorMessage] = useState("");
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
+  const [gcpCloudProviderCredentialID, setGCPCloudProviderCredentialId] = useState<string>("")
+  const [preFlightData, setPreflightData] = useState(null)
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
 
   useEffect(() => {
     setDetected(undefined);
   }, []);
 
+  useEffect(() => {
+
+    gcpIntegration()
+
+  }, [detected])
   interface FailureState {
     condition: boolean;
     errorMessage: string;
@@ -48,7 +59,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     message: string;
   };
 
-  const saveCredentials = async () => {
+  const gcpIntegration = async () => {
     failureStates.forEach((failureState) => {
       if (failureState.condition) {
         setErrorMessage(failureState.errorMessage);
@@ -70,16 +81,47 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
         setErrorMessage("Unable to store cluster credentials. Please try again later. If the problem persists, contact support@porter.run")
         return;
       }
-      const gcpCloudProviderCredentialID = gcpIntegrationResponse.data.cloud_provider_credentials_id;
-      proceed(gcpCloudProviderCredentialID)
+      setGCPCloudProviderCredentialId(gcpIntegrationResponse.data.cloud_provider_credentials_id)
+      setIsLoading(false)
+
+      if (gcpIntegrationResponse?.data?.cloud_provider_credentials_id) {
+        setIsLoading(true);
+        var data = new PreflightCheckRequest({
+          projectId: BigInt(currentProject.id),
+          cloudProvider: EnumCloudProvider.GCP,
+          cloudProviderCredentialsId: gcpIntegrationResponse.data.cloud_provider_credentials_id
+
+        })
+        const preflightDataResp = await api.preflightCheck(
+          "<token>", data,
+          {
+            id: currentProject.id,
+          }
+        )
+        setPreflightData(preflightDataResp?.data?.Msg);
+        setIsLoading(false)
+
+      }
+    }
+    catch (err) {
+      setIsLoading(false)
 
-    } catch (err) {
       if (err.response?.data?.error) {
         setErrorMessage(err.response?.data?.error.replace("unknown: ", ""));
       } else {
         setErrorMessage("Something went wrong, please try again later.");
       }
-    };
+    }
+
+  }
+
+
+  const saveCredentials = async () => {
+
+    if (gcpCloudProviderCredentialID) {
+      proceed(gcpCloudProviderCredentialID)
+    }
+
   }
 
   const handleLoadJSON = (serviceAccountJSONFile: string) => {
@@ -104,13 +146,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     setIsContinueEnabled(true);
   }
 
-  if (isLoading) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
+
 
   return (
     <>
@@ -133,23 +169,45 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
         isRequired={true}
       />
 
-      {detected && serviceAccountKey && (
-        <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
-          {detected.detected ? (
-            <I className="material-icons">check</I>
-          ) : (
-            <I className="material-icons">error</I>
-          )}
-          <Text color={detected.detected ? "#8590ff" : "#fcba03"}>
-            {detected.message}
-          </Text>
-        </AppearingDiv>
+      {detected && serviceAccountKey && (<>
+
+
+        <>
+          <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
+            {detected.detected ? (
+              <>
+                <I className="material-icons">check</I>
+              </>
+            ) : (
+              <I className="material-icons">error</I>
+            )}
+
+            <Text color={detected.detected ? "#8590ff" : "#fcba03"}>
+              {detected.message}
+            </Text>
+          </AppearingDiv>
+          <Spacer y={1} />
+          {isLoading ?
+            <>
+              <Placeholder>
+                <Loading />
+              </Placeholder>
+
+            </>
+            :
+
+            preFlightData ?
+              (<PreflightChecks preflightData={preFlightData} setPreflightFailed={setPreflightFailed} />)
+              : (<Text>  Error could not perfrom preflight checks on your account please check your credentails or contact Porter Support at support@porter.run</Text>)
+
+          }
+        </>
+      </>
       )}
 
       <Spacer y={0.5} />
-
       <Button
-        disabled={!isContinueEnabled}
+        disabled={!isContinueEnabled || preflightFailed || isLoading}
         onClick={saveCredentials}
       >Continue</Button>
 
@@ -161,72 +219,78 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
 export default GCPCredentialsForm;
 
 const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: pointer;
-  font-size: 13px;
-  height: 35px;
-  padding: 5px 13px;
-  padding-right: 15px;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  width: ${(props: { width: string }) => props.width};
-  color: white;
-  background: #ffffff11;
-
-  :hover {
-    background: #ffffff22;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      cursor: pointer;
+      font-size: 13px;
+      height: 35px;
+      padding: 5px 13px;
+      padding-right: 15px;
+      border: 1px solid #ffffff55;
+      border-radius: 100px;
+      width: ${(props: { width: string }) => props.width};
+      color: white;
+      background: #ffffff11;
+
+      :hover {
+        background: #ffffff22;
   }
 
   > i {
-    color: white;
-    font-size: 16px;
-    margin-right: 6px;
-    margin-left: -2px;
+        color: white;
+      font-size: 16px;
+      margin-right: 6px;
+      margin-left: -2px;
   }
-`;
+      `;
 
 const HelperButton = styled.div`
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  margin-left: 10px;
-  justify-content: center;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      margin-left: 10px;
+      justify-content: center;
   > i {
-    color: #aaaabb;
-    width: 24px;
-    height: 24px;
-    font-size: 20px;
-    border-radius: 20px;
+        color: #aaaabb;
+      width: 24px;
+      height: 24px;
+      font-size: 20px;
+      border-radius: 20px;
   }
-`;
+      `;
 
 const Img = styled.img`
-  height: 18px;
-  margin-right: 15px;
-`;
+      height: 18px;
+      margin-right: 15px;
+      `;
 
 const AppearingDiv = styled.div<{ color?: string }>`
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-  display: flex;
-  align-items: center;
-  color: ${(props) => props.color || "#ffffff44"};
-  margin-left: 10px;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
+        animation: floatIn 0.5s;
+        animation-fill-mode: forwards;
+        display: flex;
+        align-items: center;
+        color: ${(props) => props.color || "#ffffff44"};
+        margin-left: 10px;
+        @keyframes floatIn {
+          from {
+          opacity: 0;
+        transform: translateY(20px);
     }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
+        to {
+          opacity: 1;
+        transform: translateY(0px);
     }
   }
-`;
+        `;
 
 const I = styled.i`
-  font-size: 18px;
-  margin-right: 5px;
-`;
+        font-size: 18px;
+        margin-right: 5px;
+        `;
+
+const StatusIcon = styled.img`
+        top: 20px;
+        right: 20px;
+        height: 18px;
+        `;

+ 247 - 62
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -19,7 +19,9 @@ import {
   GKE,
   GKENetwork,
   GKENodePool,
-  GKENodePoolType
+  GKENodePoolType,
+  GKEPreflightValues,
+  PreflightCheckRequest
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
@@ -28,6 +30,13 @@ import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Text from "./porter/Text";
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import Loading from "components/Loading";
+import Placeholder from "./Placeholder";
+import Fieldset from "./porter/Fieldset";
+import ExpandableSection from "./porter/ExpandableSection";
+import PreflightChecks from "./PreflightChecks";
 
 const locationOptions = [
   { value: "us-east1", label: "us-east1" },
@@ -71,6 +80,11 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
+  const [detected, setDetected] = useState<Detected | undefined>(undefined);
+  const [preflightData, setPreflightData] = useState({})
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(false)
+  const [isLoading, setIsLoading] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
 
   const markStepStarted = async (step: string) => {
     try {
@@ -128,6 +142,54 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
     return "";
   }
+  const renderAdvancedSettings = () => {
+    return (
+      <>
+        {
+          < Heading >
+            <ExpandHeader
+              onClick={() => setIsExpanded(!isExpanded)}
+              isExpanded={isExpanded}
+            >
+              <i className="material-icons">arrow_drop_down</i>
+              Advanced settings
+            </ExpandHeader>
+          </Heading >
+        }
+        {
+          isExpanded && (
+            <>
+              <InputRow
+                width="350px"
+                type="string"
+                disabled={isReadOnly}
+                value={clusterNetworking.cidrRange}
+                setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
+                label="VPC CIDR range"
+                placeholder="ex: 10.78.0.0/16"
+              />
+              <Spacer y={0.25} />
+              <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+            </>
+          )
+        }
+      </>
+    );
+  };
+
+  const statusPreflight = (): string => {
+
+
+    if (!clusterNetworking.cidrRange) {
+      return "VPC CIDR range is required";
+    }
+    if (!VALID_CIDR_RANGE_PATTERN.test(clusterNetworking.cidrRange)) {
+      return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
+    }
+
+    return "";
+  }
+
   const createCluster = async () => {
     const err = validateInputs();
     if (err !== "") {
@@ -182,56 +244,63 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
       }),
     });
 
-    if (props.clusterId) {
-      data["cluster"]["clusterId"] = props.clusterId;
-    }
+    if (preflightData) {
+      if (props.clusterId) {
+        data["cluster"]["clusterId"] = props.clusterId;
+      }
 
-    try {
-      setIsReadOnly(true);
-      setErrorMessage("");
-      setErrorDetails("")
+      try {
+        setIsReadOnly(true);
+        setErrorMessage("");
+        setErrorDetails("")
 
-      if (!props.clusterId) {
-        markStepStarted("provisioning-started");
-      }
+        if (!props.clusterId) {
+          markStepStarted("provisioning-started");
+        }
 
-      const res = await api.createContract("<token>", data, {
-        project_id: currentProject.id,
-      });
+        const res = await api.createContract("<token>", data, {
+          project_id: currentProject.id,
+        });
 
-      setErrorMessage("");
-      setErrorDetails("");
-
-      // Only refresh and set clusters on initial create
-      setShouldRefreshClusters(true);
-      api
-        .getClusters("<token>", {}, { id: currentProject.id })
-        .then(({ data }) => {
-          data.forEach((cluster: ClusterType) => {
-            if (cluster.id === res.data.contract_revision?.cluster_id) {
-              // setHasFinishedOnboarding(true);
-              setCurrentCluster(cluster);
-              OFState.actions.goTo("clean_up");
-              pushFiltered(props, "/cluster-dashboard", ["project_id"], {
-                cluster: cluster.name,
-              });
-            }
+        setErrorMessage("");
+        setErrorDetails("");
+
+        // Only refresh and set clusters on initial create
+        setShouldRefreshClusters(true);
+        api
+          .getClusters("<token>", {}, { id: currentProject.id })
+          .then(({ data }) => {
+            data.forEach((cluster: ClusterType) => {
+              if (cluster.id === res.data.contract_revision?.cluster_id) {
+                // setHasFinishedOnboarding(true);
+                setCurrentCluster(cluster);
+                OFState.actions.goTo("clean_up");
+                pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              }
+            });
+          })
+          .catch((err) => {
+            setErrorMessage("Error fetching clusters");
+            setErrorDetails(err)
           });
-        })
-        .catch((err) => {
-          setErrorMessage("Error fetching clusters");
-          setErrorDetails(err)
-        });
 
-    } catch (err) {
-      const errMessage = err.response.data.error.replace("unknown: ", "");
+      } catch (err) {
+        const errMessage = err.response.data.error.replace("unknown: ", "");
+        setIsClicked(false);
+        // TODO: handle different error conditions here from preflights
+        setErrorMessage(DEFAULT_ERROR_MESSAGE);
+        setErrorDetails(errMessage)
+      } finally {
+        setIsReadOnly(false);
+        setIsClicked(false);
+      }
+    } else {
       setIsClicked(false);
       // TODO: handle different error conditions here from preflights
       setErrorMessage(DEFAULT_ERROR_MESSAGE);
-      setErrorDetails(errMessage)
-    } finally {
-      setIsReadOnly(false);
-      setIsClicked(false);
+      setErrorDetails("Could not perform Preflight Checks ")
     }
   };
 
@@ -273,6 +342,44 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
     }
   }, [props.selectedClusterVersion]);
 
+  useEffect(() => {
+    if (statusPreflight() == "" && !props.clusterId) {
+      preflightChecks()
+    }
+
+  }, [props.selectedClusterVersion, clusterNetworking]);
+
+  const preflightChecks = async () => {
+    setIsLoading(true);
+
+
+    var data = new PreflightCheckRequest({
+      projectId: BigInt(currentProject.id),
+      cloudProvider: EnumCloudProvider.GCP,
+      cloudProviderCredentialsId: props.credentialId,
+      preflightValues: {
+        case: "gkePreflightValues",
+        value: new GKEPreflightValues({
+          network: new GKENetwork({
+            cidrRange: clusterNetworking.cidrRange || defaultClusterNetworking.cidrRange,
+            controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
+            podCidr: defaultClusterNetworking.podCidr,
+            serviceCidr: defaultClusterNetworking.serviceCidr,
+          })
+        })
+      }
+    });
+    const preflightDataResp = await api.preflightCheck(
+      "<token>", data,
+      {
+        id: currentProject.id,
+      }
+    )
+    setPreflightData(preflightDataResp?.data?.Msg);
+    setIsLoading(false)
+
+  }
+
   const renderForm = () => {
     // Render simplified form if initial create
     if (!props.clusterId) {
@@ -295,17 +402,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
             setActiveValue={setRegion}
             label="📍 GCP location"
           />
-          <InputRow
-            width="350px"
-            type="string"
-            disabled={isReadOnly}
-            value={clusterNetworking.cidrRange}
-            setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
-            label="VPC CIDR range"
-            placeholder="ex: 10.78.0.0/16"
-          />
-          <Spacer y={0.25} />
-          <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+          {renderAdvancedSettings()}
+
         </>
       );
     }
@@ -331,8 +429,32 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   return (
     <>
       <StyledForm>{renderForm()}</StyledForm>
+
+      {props.credentialId && (<>
+
+        {isLoading ?
+          <>
+            <Placeholder>
+              <Loading />
+            </Placeholder>
+            <Spacer y={1} />
+          </>
+          :
+          <>
+            {(!props.clusterId) &&
+              <>
+                <PreflightChecks preflightData={preflightData} setPreflightFailed={setPreflightFailed} />
+                <Spacer y={1} />
+              </>
+            }
+          </>
+        }
+
+      </>
+      )}
+
       <Button
-        disabled={isDisabled()}
+        disabled={isDisabled() || isLoading || preflightFailed}
         onClick={createCluster}
         status={getStatus()}
       >
@@ -346,14 +468,14 @@ export default withRouter(GCPProvisionerSettings);
 
 
 const StyledForm = styled.div`
-  position: relative;
-  padding: 30px 30px 25px;
-  border-radius: 5px;
-  background: ${({ theme }) => theme.fg};
-  border: 1px solid #494b4f;
-  font-size: 13px;
-  margin-bottom: 30px;
-`;
+      position: relative;
+      padding: 30px 30px 25px;
+      border-radius: 5px;
+      background: ${({ theme }) => theme.fg};
+      border: 1px solid #494b4f;
+      font-size: 13px;
+      margin-bottom: 30px;
+      `;
 
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
@@ -364,3 +486,66 @@ const errorMessageToModal = (errorMessage: string) => {
       return null;
   }
 };
+
+const AppearingDiv = styled.div<{ color?: string }>`
+        animation: floatIn 0.5s;
+        animation-fill-mode: forwards;
+        display: flex;
+        flex-direction: column;
+        color: ${(props) => props.color || "#ffffff44"};
+        margin-left: 10px;
+        @keyframes floatIn {
+          from {
+          opacity: 0;
+        transform: translateY(20px);
+    }
+        to {
+          opacity: 1;
+        transform: translateY(0px);
+    }
+  }
+        `;
+const StatusIcon = styled.img`
+        height: 14px;
+        `;
+
+const CheckItemContainer = styled.div`
+        display: flex;
+        flex-direction: column;
+        border: 1px solid ${props => props.theme.border};
+        border-radius: 5px;
+        font-size: 13px;
+        width: 100%;
+        margin-bottom: 10px;
+        padding-left: 10px;
+        cursor: ${props => (props.hasMessage ? 'pointer' : 'default')};
+        background: ${props => props.theme.clickable.bg};
+
+        `;
+
+const CheckItemTop = styled.div`
+        display: flex;
+        align-items: center;
+        padding: 10px;
+        background: ${props => props.theme.clickable.bg};
+        `;
+
+const ExpandIcon = styled.i<{ isExpanded: boolean }>`
+        margin-left: 8px;
+        color: #ffffff66;
+        font-size: 20px;
+        cursor: pointer;
+        border-radius: 20px;
+        transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+        `; const ExpandHeader = styled.div<{ isExpanded: boolean }>`
+        display: flex;
+        align-items: center;
+        cursor: pointer;
+        > i {
+          margin-right: 7px;
+          margin-left: -7px;
+          transform: ${(props) =>
+    props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+          transition: transform 0.1s ease;
+        }
+      `;

+ 139 - 0
dashboard/src/components/PreflightChecks.tsx

@@ -0,0 +1,139 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+import Spacer from "./porter/Spacer";
+
+import Text from "./porter/Text";
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import { PREFLIGHT_MESSAGE_CONST } from "shared/util";
+
+type Props = RouteComponentProps & {
+  preflightData: any
+  setPreflightFailed: (x: boolean) => void;
+};
+
+
+const PreflightChecks: React.FC<Props> = (props) => {
+  const [trackFailures, setFailures] = useState<boolean>(false)
+  const PreflightCheckItem = ({ check }) => {
+    const [isExpanded, setIsExpanded] = useState(false);
+    const hasMessage = !!check.value?.message;
+    if (hasMessage) {
+      setFailures(hasMessage)
+    }
+    const handleToggle = () => {
+      if (hasMessage) {
+        setIsExpanded(!isExpanded);
+      }
+    }
+    props.setPreflightFailed(trackFailures)
+    return (
+      <CheckItemContainer hasMessage={hasMessage} onClick={handleToggle}>
+        <CheckItemTop>
+          {hasMessage ? <StatusIcon src={failure} /> : <StatusIcon src={healthy} />}
+          <Spacer inline x={1} />
+          <Text style={{ marginLeft: '10px', flex: 1 }}>{PREFLIGHT_MESSAGE_CONST[check.key]}</Text>
+          {hasMessage && <ExpandIcon className="material-icons" isExpanded={isExpanded}>
+            arrow_drop_down
+          </ExpandIcon>}
+        </CheckItemTop>
+        {isExpanded && hasMessage && (
+          <div>
+            <ErrorMessageLabel>Error Message:</ErrorMessageLabel>
+            <ErrorMessageContent>{check.value.message}</ErrorMessageContent>
+            {check.value.metadata &&
+              Object.entries(check.value.metadata).map(([key, value]) => (
+                <div key={key}>
+                  <ErrorMessageLabel>{key}:</ErrorMessageLabel>
+                  <ErrorMessageContent>{value}</ErrorMessageContent>
+                </div>
+              ))}
+          </div>
+        )}
+      </CheckItemContainer>
+    );
+  };
+
+  return (
+    <div>
+      {props.preflightData && (
+        <AppearingDiv>
+          <Text> Preflight Checks </Text>
+          <Spacer y={.5} />
+          {Object.entries(props.preflightData.preflight_checks || {}).map(([key, value]) => (
+            <PreflightCheckItem key={key} check={{ key, value }} />
+          ))}
+        </AppearingDiv>
+      )}
+    </div>
+  );
+};
+
+
+export default withRouter(PreflightChecks);
+
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  flex-direction: column; 
+  color: ${(props) => props.color || "#ffffff44"};
+  margin-left: 10px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+const StatusIcon = styled.img`
+height: 14px;
+`;
+
+const CheckItemContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  border: 1px solid ${props => props.theme.border};
+  border-radius: 5px;
+  font-size: 13px;
+  width: 100%;
+  margin-bottom: 10px;
+  padding-left: 10px;
+  cursor: ${props => (props.hasMessage ? 'pointer' : 'default')};
+  background: ${props => props.theme.clickable.bg};
+
+`;
+
+const CheckItemTop = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background: ${props => props.theme.clickable.bg};
+`;
+
+const ExpandIcon = styled.i<{ isExpanded: boolean }>`
+  margin-left: 8px;
+  color: #ffffff66;
+  font-size: 20px;
+  cursor: pointer;
+  border-radius: 20px;
+  transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+`;
+const ErrorMessageLabel = styled.span`
+  font-weight: bold;
+  margin-left: 10px;
+`;
+const ErrorMessageContent = styled.div`
+  font-family: 'Courier New', Courier, monospace;
+  padding: 5px 10px;
+  border-radius: 4px;
+  margin-left: 10px;
+  user-select: text;
+  cursor: text
+`;

+ 56 - 56
dashboard/src/components/ProvisionerSettings.tsx

@@ -368,7 +368,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       // }
       setErrorMessage(undefined);
     } catch (err) {
-      const errMessage = err.response.data.error.replace("unknown: ", "");
+      const errMessage = err.response.data?.error.replace("unknown: ", "");
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
       if (errMessage.includes("elastic IP")) {
@@ -775,8 +775,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                         </ErrorInLine>
                       )}
 
-                    <Spacer y={1} />
-                    {/* <Checkbox
+                      <Spacer y={1} />
+                      {/* <Checkbox
               checked={accessS3Logs}
               disabled={isReadOnly}
               toggleChecked={() => {
@@ -844,61 +844,61 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                     </>
                   )}
                   <FlexCenter>
-                      <Checkbox
-                          checked={guardDutyEnabled}
-                          disabled={isReadOnly}
-                          toggleChecked={() => {
-                            setGuardDutyEnabled(!guardDutyEnabled);
-                          }}
-                          disabledTooltip={
-                            "Wait for provisioning to complete before editing this field."
-                          }
-                      >
-                        <Text color="helper">
-                          Install AWS GuardDuty agent on this cluster (see details to fully enable)
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
+                    <Checkbox
+                      checked={guardDutyEnabled}
+                      disabled={isReadOnly}
+                      toggleChecked={() => {
+                        setGuardDutyEnabled(!guardDutyEnabled);
+                      }}
+                      disabledTooltip={
+                        "Wait for provisioning to complete before editing this field."
+                      }
+                    >
+                      <Text color="helper">
+                        Install AWS GuardDuty agent on this cluster (see details to fully enable)
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
+                          "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
+                        }
+                        position="right"
+                      />
+                    </Checkbox>
                   </FlexCenter>
                   <Spacer y={1} />
                   <FlexCenter>
-                      <Checkbox
-                          checked={kmsEncryptionEnabled}
-                          disabled={isReadOnly || currentCluster != null}
-                          toggleChecked={() => {
-                            setKmsEncryptionEnabled(!kmsEncryptionEnabled);
-                          }}
-                          disabledTooltip={ kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
-                            "Encryption is only supported at cluster creation."
-                          }
-                      >
-                        <Text color="helper">
-                          Enable KMS encryption for this cluster
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
-                  </FlexCenter>
-                  {kmsEncryptionEnabled && (
-                      <ErrorInLine>
-                        <i className="material-icons">error</i>
-                        {
+                    <Checkbox
+                      checked={kmsEncryptionEnabled}
+                      disabled={isReadOnly || currentCluster != null}
+                      toggleChecked={() => {
+                        setKmsEncryptionEnabled(!kmsEncryptionEnabled);
+                      }}
+                      disabledTooltip={kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
+                        "Encryption is only supported at cluster creation."
+                      }
+                    >
+                      <Text color="helper">
+                        Enable KMS encryption for this cluster
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
                           "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
                         }
-                      </ErrorInLine>
+                        position="right"
+                      />
+                    </Checkbox>
+                  </FlexCenter>
+                  {kmsEncryptionEnabled && (
+                    <ErrorInLine>
+                      <i className="material-icons">error</i>
+                      {
+                        "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
+                      }
+                    </ErrorInLine>
                   )}
                   <Spacer y={1} />
                 </>
@@ -964,11 +964,11 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         // disabled={isDisabled()}
         disabled={
           user?.email === "admin@porter.run" ||
-          currentProject?.enable_reprovision
+            currentProject?.enable_reprovision
             ? false
             : currentCluster
-            ? true
-            : isDisabled()
+              ? true
+              : isDisabled()
         }
         onClick={createCluster}
         status={getStatus()}

+ 41 - 44
dashboard/src/shared/api.tsx

@@ -11,7 +11,7 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
-import { Contract } from "@porter-dev/api-contracts";
+import { Contract, EnumCloudProvider, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
 
 /**
  * Generic api call format
@@ -75,6 +75,13 @@ const getGitlabIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
 );
 
+
+const preflightCheck = baseApi<PreflightCheckRequest,
+  { id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/integrations/preflightcheck`;
+});
+
 const preflightCheckAWSUsage = baseApi<
   {
     target_arn: string;
@@ -272,9 +279,8 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
-    page || 1
-  }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
+    }`;
 });
 
 const createEnvironment = baseApi<
@@ -699,11 +705,9 @@ 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 detectGitlabBuildpack = baseApi<
@@ -734,11 +738,9 @@ 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<
@@ -754,11 +756,9 @@ 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 getPorterYamlContents = baseApi<
@@ -774,11 +774,9 @@ const getPorterYamlContents = 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)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -837,21 +835,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-      name: string;
-      type: "github";
-      git_repo_id: number;
-      git_branch: string;
-      git_repo_name: string;
-      porter_yaml_path: string;
-    }
+    name: string;
+    type: "github";
+    git_repo_id: number;
+    git_branch: string;
+    git_repo_name: string;
+    porter_yaml_path: string;
+  }
   | {
-      name: string;
-      type: "docker-registry";
-      image: {
-        repository: string;
-        tag: string;
-      };
-    },
+    name: string;
+    type: "docker-registry";
+    image: {
+      repository: string;
+      tag: string;
+    };
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -1762,11 +1760,9 @@ 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<
@@ -2823,7 +2819,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3047,6 +3043,7 @@ export default {
   addApplicationToEnvGroup,
   removeApplicationFromEnvGroup,
   provisionDatabase,
+  preflightCheck,
   preflightCheckAWSUsage,
   getDatabases,
   getPreviousLogsForContainer,

+ 6 - 0
dashboard/src/shared/util.ts

@@ -10,3 +10,9 @@ export const isJSON = (value: string): boolean => {
 export function valueExists<T>(value: T | null | undefined): value is T {
   return !!value;
 }
+
+
+export const PREFLIGHT_MESSAGE_CONST = {
+  "apiEnabled": "APIs enabled on service account",
+  "cidrAvailability": "CIDR availability"
+}