Browse Source

display and auto populate cidr changes when provisioning (#4432)

ianedwards 2 năm trước cách đây
mục cha
commit
b8557c172b

+ 1 - 0
dashboard/src/assets/file-diff.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-diff"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M9 10h6"/><path d="M12 13V7"/><path d="M9 17h6"/></svg>

+ 8 - 0
dashboard/src/lib/clusters/types.ts

@@ -562,14 +562,22 @@ export const createContractResponseValidator = z.object({
     revision_id: z.string(),
   }),
 });
+
+type TPreflightCheckFixSuggestion = {
+  original: string;
+  suggested: string;
+};
+
 export type ClientPreflightCheck = {
   title: string;
+  name: PreflightCheckKey;
   status: "pending" | "success" | "failure";
   error?: {
     detail: string;
     metadata: Record<string, string> | undefined;
     resolution?: PreflightCheckResolution;
   };
+  suggestedChanges?: TPreflightCheckFixSuggestion;
 };
 type CreateContractResponse = z.infer<typeof createContractResponseValidator>;
 export type UpdateClusterResponse =

+ 90 - 0
dashboard/src/lib/hooks/useCluster.ts

@@ -420,6 +420,7 @@ export const useUpdateCluster = ({
                 return {
                   title: "Unknown preflight check",
                   status: "failure" as const,
+                  name: "UNKNOWN" as const,
                   error: {
                     detail:
                       "Your cloud provider returned an unknown error. Please reach out to Porter support.",
@@ -430,6 +431,7 @@ export const useUpdateCluster = ({
               return {
                 title: preflightCheckMatch.displayName,
                 status: "failure" as const,
+                name: preflightCheckMatch.name,
                 error: {
                   detail: e.error.message,
                   metadata: e.error.metadata,
@@ -571,6 +573,94 @@ export const useClusterNodeList = ({
   };
 };
 
+export const uniqueCidrMetadataValidator = z.object({
+  "overlapping-service-cidr": z.string(),
+  "overlapping-vpc-cidr": z.string(),
+  "suggested-service-cidr": z.string(),
+  "suggested-vpc-cidr": z.string(),
+});
+const unavailableCidrMetadataValidator = z.object({
+  "Conflicting CIDR range in this region": z.string(),
+});
+
+export const checksWithSuggestedChanges = ({
+  preflightChecks,
+}: {
+  preflightChecks: ClientPreflightCheck[];
+}): ClientPreflightCheck[] => {
+  return preflightChecks.map((c) =>
+    match(c.name)
+      .with("enforceCidrUniqueness", () => {
+        const parsed = uniqueCidrMetadataValidator.safeParse(
+          c.error?.metadata || {}
+        );
+        if (!parsed.success) {
+          return c;
+        }
+        const suggestion = {
+          original: `Service CIDR:\n${parsed.data["overlapping-service-cidr"]}\n\nVPC CIDR:\n${parsed.data["overlapping-vpc-cidr"]}`,
+          suggested: `Service CIDR:\n${parsed.data["suggested-service-cidr"]}\n\nVPC CIDR:\n${parsed.data["suggested-vpc-cidr"]}`,
+        };
+
+        return {
+          ...c,
+          suggestedChanges: suggestion,
+        };
+      })
+      .with("cidrAvailability", () => {
+        const cidrUniquenessCheck = preflightChecks.find(
+          (c) => c.name === "enforceCidrUniqueness"
+        );
+        if (!cidrUniquenessCheck) {
+          return c;
+        }
+
+        const uniqueCidrParsedMetadata = uniqueCidrMetadataValidator.safeParse(
+          cidrUniquenessCheck.error?.metadata || {}
+        );
+        const unavailableCidrParsedMetadata =
+          unavailableCidrMetadataValidator.safeParse(c.error?.metadata || {});
+        if (
+          !uniqueCidrParsedMetadata.success ||
+          !unavailableCidrParsedMetadata.success
+        ) {
+          return c;
+        }
+
+        const unavailableCidr =
+          unavailableCidrParsedMetadata.data[
+            "Conflicting CIDR range in this region"
+          ];
+        if (
+          unavailableCidr ===
+          uniqueCidrParsedMetadata.data["overlapping-service-cidr"]
+        ) {
+          return {
+            ...c,
+            suggestedChanges: {
+              original: `CIDR:\n${unavailableCidr}`,
+              suggested: `CIDR:\n${uniqueCidrParsedMetadata.data["suggested-service-cidr"]}`,
+            },
+          };
+        } else if (
+          unavailableCidr ===
+          uniqueCidrParsedMetadata.data["overlapping-vpc-cidr"]
+        ) {
+          return {
+            ...c,
+            suggestedChanges: {
+              original: `CIDR:\n${unavailableCidr}`,
+              suggested: `CIDR:\n${uniqueCidrParsedMetadata.data["suggested-vpc-cidr"]}`,
+            },
+          };
+        }
+
+        return c;
+      })
+      .otherwise(() => c)
+  );
+};
+
 const preflightCheckErrorReplacements = {
   RequestThrottled:
     "Your cloud provider is currently throttling API requests. Please try again in a few minutes.",

+ 43 - 2
dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx

@@ -5,14 +5,19 @@ import { useQueryClient } from "@tanstack/react-query";
 import { FormProvider, useForm } from "react-hook-form";
 import { useHistory } from "react-router";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
 import { Error as ErrorComponent } from "components/porter/Error";
 import {
   clusterContractValidator,
   type ClientClusterContract,
+  type ClientPreflightCheck,
   type UpdateClusterResponse,
 } from "lib/clusters/types";
-import { useUpdateCluster } from "lib/hooks/useCluster";
+import {
+  uniqueCidrMetadataValidator,
+  useUpdateCluster,
+} from "lib/hooks/useCluster";
 import { useClusterAnalytics } from "lib/hooks/useClusterAnalytics";
 import { useIntercom } from "lib/hooks/useIntercom";
 
@@ -34,6 +39,9 @@ type ClusterFormContextType = {
   updateClusterButtonProps: UpdateClusterButtonProps;
   setCurrentContract: (contract: Contract) => void;
   submitSkippingPreflightChecks: () => Promise<void>;
+  submitAndPatchCheckSuggestions: (args: {
+    preflightChecks: ClientPreflightCheck[];
+  }) => Promise<void>;
 };
 
 const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
@@ -89,6 +97,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
   });
   const {
     handleSubmit,
+    setValue,
     formState: { isSubmitting, errors },
   } = clusterForm;
 
@@ -112,7 +121,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
     }
     if (Object.keys(errors).length > 0) {
       // TODO: remove this and properly handle form validation errors
-      console.log("errors", errors);
+      // console.log("errors", errors);
     }
     if (isHandlingPreflightChecks) {
       props.loadingText = "Running preflight checks...";
@@ -221,6 +230,37 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
     await handleClusterUpdate(fullValuesWithDefaults, true);
   };
 
+  const submitAndPatchCheckSuggestions = async ({
+    preflightChecks,
+  }: {
+    preflightChecks: ClientPreflightCheck[];
+  }): Promise<void> => {
+    if (clusterForm.formState.isSubmitting) {
+      return;
+    }
+    if (!currentContract?.cluster) {
+      return;
+    }
+
+    preflightChecks.forEach((check) => {
+      match(check).with({ name: "enforceCidrUniqueness" }, () => {
+        const parsedMetadata = uniqueCidrMetadataValidator.parse(
+          check.error?.metadata
+        );
+        setValue(
+          "cluster.config.serviceCidrRange",
+          parsedMetadata["suggested-service-cidr"]
+        );
+        setValue(
+          "cluster.config.cidrRange",
+          parsedMetadata["suggested-vpc-cidr"]
+        );
+      });
+    });
+
+    void onSubmit();
+  };
+
   return (
     <ClusterFormContext.Provider
       value={{
@@ -230,6 +270,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
         isAdvancedSettingsEnabled,
         isMultiClusterEnabled,
         submitSkippingPreflightChecks,
+        submitAndPatchCheckSuggestions,
       }}
     >
       <Wrapper ref={scrollToTopRef}>

+ 101 - 38
dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx

@@ -1,12 +1,13 @@
-import React from "react";
+import React, { useMemo } from "react";
+import DiffViewer, { DiffMethod } from "react-diff-viewer";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
 import Loading from "components/Loading";
 import Button from "components/porter/Button";
-import Container from "components/porter/Container";
 import { Error as ErrorComponent } from "components/porter/Error";
 import Expandable from "components/porter/Expandable";
+import Icon from "components/porter/Icon";
 import Modal from "components/porter/Modal";
 import PorterOperatorComponent from "components/porter/PorterOperatorComponent";
 import ShowIntercomButton from "components/porter/ShowIntercomButton";
@@ -14,6 +15,9 @@ import Spacer from "components/porter/Spacer";
 import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
 import { type ClientPreflightCheck } from "lib/clusters/types";
+import { checksWithSuggestedChanges } from "lib/hooks/useCluster";
+
+import file_diff from "assets/file-diff.svg";
 
 import { useClusterFormContext } from "../ClusterFormContextProvider";
 import ResolutionStepsModalContents from "./help/preflight/ResolutionStepsModalContents";
@@ -33,12 +37,9 @@ export const CheckItem: React.FC<ItemProps> = ({
           .with("pending", () => (
             <Loading offset="0px" width="20px" height="20px" />
           ))
-          .otherwise((status) =>
-            match(status)
-              .with("success", () => <StatusDot status="available" />)
-              .with("failure", () => <StatusDot status="failing" />)
-              .exhaustive()
-          )}
+          .with("success", () => <StatusDot status="available" />)
+          .with("failure", () => <StatusDot status="failing" />)
+          .exhaustive()}
         <Spacer inline x={1} />
         <Text style={{ flex: 1 }}>{preflightCheck.title}</Text>
         {preflightCheck?.error?.metadata?.quotaName && (
@@ -57,22 +58,44 @@ export const CheckItem: React.FC<ItemProps> = ({
   return (
     <Expandable preExpanded={preExpanded} header={renderHeader()}>
       <div>
-        <ErrorComponent
-          message={preflightCheck.error.detail}
-          ctaText={
-            preflightCheck.error.resolution
-              ? "Troubleshooting steps"
-              : undefined
-          }
-          metadata={preflightCheck.error.metadata}
-          errorModalContents={
-            preflightCheck.error.resolution ? (
-              <ResolutionStepsModalContents
-                resolution={preflightCheck.error.resolution}
-              />
-            ) : undefined
-          }
-        />
+        {preflightCheck.suggestedChanges ? (
+          <RevisionDiffContainer>
+            <DiffViewer
+              leftTitle={"Current"}
+              rightTitle={"Suggested"}
+              oldValue={preflightCheck.suggestedChanges?.original}
+              newValue={preflightCheck.suggestedChanges?.suggested}
+              splitView={true}
+              hideLineNumbers
+              useDarkTheme
+              compareMethod={DiffMethod.TRIMMED_LINES}
+              styles={{
+                variables: {
+                  dark: {
+                    diffViewerTitleColor: "fff",
+                  },
+                },
+              }}
+            />
+          </RevisionDiffContainer>
+        ) : (
+          <ErrorComponent
+            message={preflightCheck.error?.detail || ""}
+            ctaText={
+              preflightCheck.error?.resolution
+                ? "Troubleshooting steps"
+                : undefined
+            }
+            metadata={preflightCheck.error?.metadata}
+            errorModalContents={
+              preflightCheck.error?.resolution ? (
+                <ResolutionStepsModalContents
+                  resolution={preflightCheck.error.resolution}
+                />
+              ) : undefined
+            }
+          />
+        )}
         <Spacer y={0.5} />
       </div>
     </Expandable>
@@ -87,7 +110,23 @@ const PreflightChecksModal: React.FC<Props> = ({
   onClose,
   preflightChecks,
 }) => {
-  const { submitSkippingPreflightChecks } = useClusterFormContext();
+  const { submitSkippingPreflightChecks, submitAndPatchCheckSuggestions } =
+    useClusterFormContext();
+
+  const checksWithSuggestions = useMemo(() => {
+    return checksWithSuggestedChanges({ preflightChecks });
+  }, [preflightChecks]);
+
+  const allHaveSuggestions = checksWithSuggestions.every(
+    (check) => !!check.suggestedChanges
+  );
+
+  const acceptChanges = async (): Promise<void> => {
+    void submitAndPatchCheckSuggestions({
+      preflightChecks: checksWithSuggestions,
+    });
+    onClose();
+  };
 
   return (
     <Modal width="600px" closeModal={onClose}>
@@ -99,9 +138,19 @@ const PreflightChecksModal: React.FC<Props> = ({
           and/or resources to provision with Porter. Please resolve the
           following issues or change your cluster configuration and try again.
         </Text>
+        <PorterOperatorComponent>
+          <Button
+            onClick={async () => {
+              await submitSkippingPreflightChecks();
+            }}
+            color="red"
+          >
+            Skip preflight checks
+          </Button>
+        </PorterOperatorComponent>
         <Spacer y={1} />
         <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
-          {preflightChecks.map((pfc, idx) => (
+          {checksWithSuggestions.map((pfc, idx) => (
             <CheckItem
               preflightCheck={pfc}
               key={pfc.title}
@@ -110,23 +159,25 @@ const PreflightChecksModal: React.FC<Props> = ({
           ))}
         </div>
         <Spacer y={1} />
-        <Container row spaced>
-          <ShowIntercomButton
-            message={"I need help resolving cluster preflight checks."}
-          >
-            Talk to support
-          </ShowIntercomButton>
-          <PorterOperatorComponent>
+        <ButtonContainer>
+          {allHaveSuggestions ? (
             <Button
               onClick={async () => {
-                await submitSkippingPreflightChecks();
+                await acceptChanges();
               }}
-              color="red"
             >
-              Skip preflight checks
+              <Icon src={file_diff} height={"15px"} />
+              <Spacer inline x={0.5} />
+              Accept & Retry
             </Button>
-          </PorterOperatorComponent>
-        </Container>
+          ) : (
+            <ShowIntercomButton
+              message={"I need help resolving cluster preflight checks."}
+            >
+              Talk to support
+            </ShowIntercomButton>
+          )}
+        </ButtonContainer>
       </AppearingDiv>
     </Modal>
   );
@@ -161,3 +212,15 @@ const CheckItemTop = styled.div`
   padding: 10px;
   background: ${(props) => props.theme.clickable.bg};
 `;
+
+const RevisionDiffContainer = styled.div`
+  max-height: 400px;
+  overflow-y: auto;
+  border-radius: 8px;
+`;
+
+const ButtonContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  column-gap: 0.5rem;
+`;