Forráskód Böngészése

Cluster advanced settings (#4290)

Feroze Mohideen 2 éve
szülő
commit
cbbf60e287

+ 173 - 89
dashboard/src/lib/clusters/index.ts

@@ -5,12 +5,15 @@ import {
   AWSClusterNetwork,
   Cluster,
   EKS,
+  EKSLogging,
   EKSNodeGroup,
   EnumCloudProvider,
   GKE,
   GKENetwork,
   GKENodePool,
   GKENodePoolType,
+  LoadBalancer,
+  LoadBalancerType,
   NodeGroupType,
   NodePoolType,
   type Contract,
@@ -19,8 +22,11 @@ import { match } from "ts-pattern";
 
 import {
   type AKSClientClusterConfig,
+  type AWSRegion,
+  type AzureRegion,
   type ClientClusterContract,
   type EKSClientClusterConfig,
+  type GCPRegion,
   type GKEClientClusterConfig,
 } from "./types";
 
@@ -41,7 +47,7 @@ export function updateExistingClusterContract(
       if (cluster.kindValues.case !== "eksKind") {
         throw new Error("Invalid kind value for EKS");
       }
-      cluster.kindValues.value = updateEKSKindValues(
+      cluster.kindValues.value = clientEKSConfigToProto(
         config,
         cluster.kindValues.value
       );
@@ -50,7 +56,7 @@ export function updateExistingClusterContract(
       if (cluster.kindValues.case !== "gkeKind") {
         throw new Error("Invalid kind value for GKE");
       }
-      cluster.kindValues.value = updateGKEKindValues(
+      cluster.kindValues.value = clientGKEConfigToProto(
         config,
         cluster.kindValues.value
       );
@@ -59,7 +65,7 @@ export function updateExistingClusterContract(
       if (cluster.kindValues.case !== "aksKind") {
         throw new Error("Invalid kind value for AKS");
       }
-      cluster.kindValues.value = updateAKSKindValues(
+      cluster.kindValues.value = clientAKSConfigToProto(
         config,
         cluster.kindValues.value
       );
@@ -68,7 +74,7 @@ export function updateExistingClusterContract(
   return cluster;
 }
 
-function updateEKSKindValues(
+function clientEKSConfigToProto(
   clientConfig: EKSClientClusterConfig,
   existingConfig: EKS
 ): EKS {
@@ -90,15 +96,44 @@ function updateEKSKindValues(
           .otherwise(() => NodeGroupType.UNSPECIFIED),
       });
     }),
-    cidrRange: clientConfig.cidrRange, // this should be removed once we no longer use the deprecated value
     network: new AWSClusterNetwork({
       ...(existingConfig?.network ?? {}),
       vpcCidr: clientConfig.cidrRange,
     }),
+    loadBalancer: new LoadBalancer({
+      loadBalancerType: match(clientConfig.loadBalancer.type)
+        .with("NLB", () => LoadBalancerType.NLB)
+        .with("ALB", () => LoadBalancerType.ALB)
+        .otherwise(() => LoadBalancerType.UNSPECIFIED),
+      wildcardDomain: clientConfig.loadBalancer.wildcardDomain,
+      allowlistIpRanges: clientConfig.loadBalancer.allowlistIpRanges,
+      enableWafv2: clientConfig.loadBalancer.isWafV2Enabled,
+      wafv2Arn: clientConfig.loadBalancer.wafV2Arn,
+      additionalCertificateArns: clientConfig.loadBalancer.certificateArns.map(
+        (certArn) => certArn.arn
+      ),
+      tags: Object.fromEntries(
+        clientConfig.loadBalancer.awsTags
+          .filter((tag) => tag.key.length > 0 && tag.value.length > 0)
+          .map((tag) => [tag.key, tag.value])
+      ),
+    }),
+    logging: new EKSLogging({
+      ...(existingConfig?.logging ?? {}),
+      enableApiServerLogs: clientConfig.logging.isApiServerLogsEnabled,
+      enableAuditLogs: clientConfig.logging.isAuditLogsEnabled,
+      enableAuthenticatorLogs: clientConfig.logging.isAuthenticatorLogsEnabled,
+      enableControllerManagerLogs:
+        clientConfig.logging.isControllerManagerLogsEnabled,
+      enableSchedulerLogs: clientConfig.logging.isSchedulerLogsEnabled,
+    }),
+    enableEcrScanning: clientConfig.isEcrScanningEnabled,
+    enableGuardDuty: clientConfig.isGuardDutyEnabled,
+    enableKmsEncryption: clientConfig.isKmsEncryptionEnabled,
   });
 }
 
-function updateGKEKindValues(
+function clientGKEConfigToProto(
   clientConfig: GKEClientClusterConfig,
   existingConfig: GKE
 ): GKE {
@@ -133,7 +168,7 @@ function updateGKEKindValues(
   });
 }
 
-function updateAKSKindValues(
+function clientAKSConfigToProto(
   clientConfig: AKSClientClusterConfig,
   existingConfig: AKS
 ): AKS {
@@ -181,89 +216,138 @@ export function clientClusterContractFromProto(
         .otherwise(() => "Local" as const),
       cloudProviderCredentialsId: contractCluster.cloudProviderCredentialsId,
       config: match(contractCluster.kindValues)
-        .with({ case: "eksKind" }, ({ value }) => ({
-          kind: "EKS" as const,
-          clusterName: value.clusterName,
-          clusterVersion: value.clusterVersion,
-          region: value.region,
-          nodeGroups: value.nodeGroups.map((ng) => {
-            return {
-              instanceType: ng.instanceType,
-              minInstances: ng.minInstances,
-              maxInstances: ng.maxInstances,
-              nodeGroupType: match(ng.nodeGroupType)
-                .with(NodeGroupType.UNSPECIFIED, () => "UNKNOWN" as const)
-                .with(NodeGroupType.SYSTEM, () => "SYSTEM" as const)
-                .with(NodeGroupType.MONITORING, () => "MONITORING" as const)
-                .with(NodeGroupType.APPLICATION, () => "APPLICATION" as const)
-                .with(NodeGroupType.CUSTOM, () => "CUSTOM" as const)
-                .otherwise(() => "UNKNOWN" as const),
-            };
-          }),
-          cidrRange: value.network?.vpcCidr ?? value.cidrRange ?? "", // network will always be provided in one of those fields
-        }))
-        .with({ case: "gkeKind" }, ({ value }) => ({
-          kind: "GKE" as const,
-          clusterName: value.clusterName,
-          clusterVersion: value.clusterVersion,
-          region: value.region,
-          nodeGroups: value.nodePools.map((ng) => {
-            return {
-              instanceType: ng.instanceType,
-              minInstances: ng.minInstances,
-              maxInstances: ng.maxInstances,
-              nodeGroupType: match(ng.nodePoolType)
-                .with(
-                  GKENodePoolType.GKE_NODE_POOL_TYPE_UNSPECIFIED,
-                  () => "UNKNOWN" as const
-                )
-                .with(
-                  GKENodePoolType.GKE_NODE_POOL_TYPE_SYSTEM,
-                  () => "SYSTEM" as const
-                )
-                .with(
-                  GKENodePoolType.GKE_NODE_POOL_TYPE_MONITORING,
-                  () => "MONITORING" as const
-                )
-                .with(
-                  GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION,
-                  () => "APPLICATION" as const
-                )
-                .with(
-                  GKENodePoolType.GKE_NODE_POOL_TYPE_CUSTOM,
-                  () => "CUSTOM" as const
-                )
-                .otherwise(() => "UNKNOWN" as const),
-            };
-          }),
-          cidrRange: value.network?.cidrRange ?? "", // network will always be provided
-        }))
-        .with({ case: "aksKind" }, ({ value }) => ({
-          kind: "AKS" as const,
-          clusterName: value.clusterName,
-          clusterVersion: value.clusterVersion,
-          region: value.location,
-          nodeGroups: value.nodePools.map((ng) => {
-            return {
-              instanceType: ng.instanceType,
-              minInstances: ng.minInstances,
-              maxInstances: ng.maxInstances,
-              nodeGroupType: match(ng.nodePoolType)
-                .with(NodePoolType.UNSPECIFIED, () => "UNKNOWN" as const)
-                .with(NodePoolType.SYSTEM, () => "SYSTEM" as const)
-                .with(NodePoolType.MONITORING, () => "MONITORING" as const)
-                .with(NodePoolType.APPLICATION, () => "APPLICATION" as const)
-                .with(NodePoolType.CUSTOM, () => "CUSTOM" as const)
-                .otherwise(() => "UNKNOWN" as const),
-            };
-          }),
-          skuTier: match(value.skuTier)
-            .with(AksSkuTier.FREE, () => "FREE" as const)
-            .with(AksSkuTier.STANDARD, () => "STANDARD" as const)
-            .otherwise(() => "UNKNOWN" as const),
-          cidrRange: value.cidrRange,
-        }))
+        .with({ case: "eksKind" }, ({ value }) =>
+          clientEKSConfigFromProto(value)
+        )
+        .with({ case: "gkeKind" }, ({ value }) =>
+          clientGKEConfigFromProto(value)
+        )
+        .with({ case: "aksKind" }, ({ value }) =>
+          clientAKSConfigFromProto(value)
+        )
         .exhaustive(),
     },
   };
 }
+
+const clientEKSConfigFromProto = (value: EKS): EKSClientClusterConfig => {
+  return {
+    kind: "EKS",
+    clusterName: value.clusterName,
+    region: value.region as AWSRegion, // remove type assertion here somehow
+    clusterVersion: value.clusterVersion,
+    nodeGroups: value.nodeGroups.map((ng) => {
+      return {
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodeGroupType: match(ng.nodeGroupType)
+          .with(NodeGroupType.UNSPECIFIED, () => "UNKNOWN" as const)
+          .with(NodeGroupType.SYSTEM, () => "SYSTEM" as const)
+          .with(NodeGroupType.MONITORING, () => "MONITORING" as const)
+          .with(NodeGroupType.APPLICATION, () => "APPLICATION" as const)
+          .with(NodeGroupType.CUSTOM, () => "CUSTOM" as const)
+          .otherwise(() => "UNKNOWN" as const),
+      };
+    }),
+    cidrRange: value.network?.vpcCidr ?? value.cidrRange ?? "", // network will always be provided in one of those fields
+    logging: {
+      isApiServerLogsEnabled: value.logging?.enableApiServerLogs ?? false,
+      isAuditLogsEnabled: value.logging?.enableAuditLogs ?? false,
+      isAuthenticatorLogsEnabled:
+        value.logging?.enableAuthenticatorLogs ?? false,
+      isControllerManagerLogsEnabled:
+        value.logging?.enableControllerManagerLogs ?? false,
+      isSchedulerLogsEnabled: value.logging?.enableSchedulerLogs ?? false,
+    },
+    loadBalancer: {
+      type: match(value.loadBalancer?.loadBalancerType)
+        .with(LoadBalancerType.NLB, () => "NLB" as const)
+        .with(LoadBalancerType.ALB, () => "ALB" as const)
+        .otherwise(() => "UNKNOWN" as const),
+      wildcardDomain: value.loadBalancer?.wildcardDomain ?? "",
+      allowlistIpRanges: value.loadBalancer?.allowlistIpRanges ?? "",
+      certificateArns: (
+        value.loadBalancer?.additionalCertificateArns ?? []
+      ).map((arn) => ({ arn })),
+      awsTags: Object.entries(value.loadBalancer?.tags ?? {}).map((tag) => {
+        return {
+          key: tag[0],
+          value: tag[1],
+        };
+      }),
+      isWafV2Enabled: value.loadBalancer?.enableWafv2 ?? false,
+      wafV2Arn: value.loadBalancer?.wafv2Arn ?? "",
+    },
+    isEcrScanningEnabled: value.enableEcrScanning,
+    isGuardDutyEnabled: value.enableGuardDuty,
+    isKmsEncryptionEnabled: value.enableKmsEncryption,
+  };
+};
+
+const clientGKEConfigFromProto = (value: GKE): GKEClientClusterConfig => {
+  return {
+    kind: "GKE",
+    clusterName: value.clusterName,
+    region: value.region as GCPRegion, // remove type assertion here somehow
+    clusterVersion: value.clusterVersion,
+    nodeGroups: value.nodePools.map((ng) => {
+      return {
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodeGroupType: match(ng.nodePoolType)
+          .with(
+            GKENodePoolType.GKE_NODE_POOL_TYPE_UNSPECIFIED,
+            () => "UNKNOWN" as const
+          )
+          .with(
+            GKENodePoolType.GKE_NODE_POOL_TYPE_SYSTEM,
+            () => "SYSTEM" as const
+          )
+          .with(
+            GKENodePoolType.GKE_NODE_POOL_TYPE_MONITORING,
+            () => "MONITORING" as const
+          )
+          .with(
+            GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION,
+            () => "APPLICATION" as const
+          )
+          .with(
+            GKENodePoolType.GKE_NODE_POOL_TYPE_CUSTOM,
+            () => "CUSTOM" as const
+          )
+          .otherwise(() => "UNKNOWN" as const),
+      };
+    }),
+    cidrRange: value.network?.cidrRange ?? "", // network will always be provided
+  };
+};
+
+const clientAKSConfigFromProto = (value: AKS): AKSClientClusterConfig => {
+  return {
+    kind: "AKS",
+    clusterName: value.clusterName,
+    region: value.location as AzureRegion, // remove type assertion here somehow
+    clusterVersion: value.clusterVersion,
+    nodeGroups: value.nodePools.map((ng) => {
+      return {
+        instanceType: ng.instanceType,
+        minInstances: ng.minInstances,
+        maxInstances: ng.maxInstances,
+        nodeGroupType: match(ng.nodePoolType)
+          .with(NodePoolType.UNSPECIFIED, () => "UNKNOWN" as const)
+          .with(NodePoolType.SYSTEM, () => "SYSTEM" as const)
+          .with(NodePoolType.MONITORING, () => "MONITORING" as const)
+          .with(NodePoolType.APPLICATION, () => "APPLICATION" as const)
+          .with(NodePoolType.CUSTOM, () => "CUSTOM" as const)
+          .otherwise(() => "UNKNOWN" as const),
+      };
+    }),
+    skuTier: match(value.skuTier)
+      .with(AksSkuTier.FREE, () => "FREE" as const)
+      .with(AksSkuTier.STANDARD, () => "STANDARD" as const)
+      .otherwise(() => "UNKNOWN" as const),
+    cidrRange: value.cidrRange,
+  };
+};

+ 49 - 3
dashboard/src/lib/clusters/types.ts

@@ -55,7 +55,7 @@ const awsRegionValidator = z.enum([
   "me-south-1",
   "sa-east-1",
 ]);
-type AWSRegion = z.infer<typeof awsRegionValidator>;
+export type AWSRegion = z.infer<typeof awsRegionValidator>;
 const gcpRegionValidator = z.enum([
   "us-east1",
   "us-east4",
@@ -71,7 +71,7 @@ const gcpRegionValidator = z.enum([
   "us-west3",
   "us-west4",
 ]);
-type GCPRegion = z.infer<typeof gcpRegionValidator>;
+export type GCPRegion = z.infer<typeof gcpRegionValidator>;
 const azureRegionValidator = z.enum([
   "australiaeast",
   "brazilsouth",
@@ -94,7 +94,7 @@ const azureRegionValidator = z.enum([
   "westus2",
   "westus3",
 ]);
-type AzureRegion = z.infer<typeof azureRegionValidator>;
+export type AzureRegion = z.infer<typeof azureRegionValidator>;
 export type ClientRegion = {
   name: AWSRegion | GCPRegion | AzureRegion;
   displayName: string;
@@ -399,6 +399,52 @@ const eksConfigValidator = z.object({
   region: awsRegionValidator,
   nodeGroups: eksNodeGroupValidator.array(),
   cidrRange: cidrRangeValidator,
+  logging: z
+    .object({
+      isApiServerLogsEnabled: z.boolean(),
+      isAuditLogsEnabled: z.boolean(),
+      isAuthenticatorLogsEnabled: z.boolean(),
+      isControllerManagerLogsEnabled: z.boolean(),
+      isSchedulerLogsEnabled: z.boolean(),
+    })
+    .default({
+      isApiServerLogsEnabled: false,
+      isAuditLogsEnabled: false,
+      isAuthenticatorLogsEnabled: false,
+      isControllerManagerLogsEnabled: false,
+      isSchedulerLogsEnabled: false,
+    }),
+  loadBalancer: z
+    .object({
+      type: z.enum(["UNKNOWN", "ALB", "NLB"]),
+      wildcardDomain: z.string(),
+      allowlistIpRanges: z.string(),
+      certificateArns: z
+        .object({
+          arn: z.string(),
+        })
+        .array(),
+      awsTags: z
+        .object({
+          key: z.string(),
+          value: z.string(),
+        })
+        .array(),
+      isWafV2Enabled: z.boolean(),
+      wafV2Arn: z.string(),
+    })
+    .default({
+      type: "UNKNOWN",
+      wildcardDomain: "",
+      allowlistIpRanges: "",
+      certificateArns: [],
+      awsTags: [],
+      isWafV2Enabled: false,
+      wafV2Arn: "",
+    }),
+  isEcrScanningEnabled: z.boolean().default(false),
+  isGuardDutyEnabled: z.boolean().default(false),
+  isKmsEncryptionEnabled: z.boolean().default(false),
 });
 const gkeConfigValidator = z.object({
   kind: z.literal("GKE"),

+ 15 - 4
dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx

@@ -1,4 +1,4 @@
-import React, { createContext, useMemo, useState } from "react";
+import React, { createContext, useMemo, useRef, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { type Contract } from "@porter-dev/api-contracts";
 import { useQueryClient } from "@tanstack/react-query";
@@ -25,9 +25,10 @@ export type UpdateClusterButtonProps = {
 };
 
 type ClusterFormContextType = {
-  setCurrentContract: (contract: Contract) => void;
+  isAdvancedSettingsEnabled: boolean;
   showFailedPreflightChecksModal: boolean;
   updateClusterButtonProps: UpdateClusterButtonProps;
+  setCurrentContract: (contract: Contract) => void;
 };
 
 const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
@@ -44,12 +45,14 @@ export const useClusterFormContext = (): ClusterFormContextType => {
 
 type ClusterFormContextProviderProps = {
   projectId?: number;
+  isAdvancedSettingsEnabled?: boolean;
   redirectOnSubmit?: boolean;
   children: JSX.Element;
 };
 
 const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
   projectId,
+  isAdvancedSettingsEnabled = false,
   redirectOnSubmit,
   children,
 }) => {
@@ -64,6 +67,8 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
   const [showFailedPreflightChecksModal, setShowFailedPreflightChecksModal] =
     useState<boolean>(false);
 
+  const scrollToTopRef = useRef<HTMLDivElement | null>(null);
+
   const { updateCluster, isHandlingPreflightChecks, isCreatingContract } =
     useUpdateCluster({ projectId });
 
@@ -103,7 +108,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
       props.loadingText = "Provisioning cluster...";
     }
     if (updateClusterResponse?.createContractResponse) {
-      props.status = "success";
+      props.status = "";
     }
 
     return props;
@@ -134,6 +139,11 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
           history.push(
             `/infrastructure/${response.createContractResponse.contract_revision.cluster_id}`
           );
+        } else if (scrollToTopRef.current) {
+          scrollToTopRef.current.scrollIntoView({
+            behavior: "smooth",
+            block: "end",
+          });
         }
       }
     } catch (err) {
@@ -152,9 +162,10 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
         setCurrentContract,
         showFailedPreflightChecksModal,
         updateClusterButtonProps,
+        isAdvancedSettingsEnabled,
       }}
     >
-      <Wrapper>
+      <Wrapper ref={scrollToTopRef}>
         <FormProvider {...clusterForm}>
           <form onSubmit={onSubmit}>{children}</form>
         </FormProvider>

+ 5 - 3
dashboard/src/main/home/infrastructure-dashboard/ClusterSaveButton.tsx

@@ -2,19 +2,21 @@ import React from "react";
 
 import Button from "components/porter/Button";
 
-import { useClusterContext } from "./ClusterContextProvider";
 import { useClusterFormContext } from "./ClusterFormContextProvider";
 
 type Props = {
   height?: string;
   disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
+  isClusterUpdating?: boolean;
+  children: React.ReactNode;
 };
 const ClusterSaveButton: React.FC<Props> = ({
   height,
   disabledTooltipPosition,
+  isClusterUpdating,
+  children,
 }) => {
   const { updateClusterButtonProps } = useClusterFormContext();
-  const { isClusterUpdating } = useClusterContext();
 
   return (
     <Button
@@ -28,7 +30,7 @@ const ClusterSaveButton: React.FC<Props> = ({
       height={height}
       disabledTooltipPosition={disabledTooltipPosition}
     >
-      Update
+      {children}
     </Button>
   );
 };

+ 30 - 21
dashboard/src/main/home/infrastructure-dashboard/ClusterTabs.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useMemo } from "react";
+import React, { useEffect, useMemo } from "react";
 import { Contract } from "@porter-dev/api-contracts";
 import AnimateHeight from "react-animate-height";
 import { useFormContext } from "react-hook-form";
@@ -11,28 +11,24 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { type ClientClusterContract } from "lib/clusters/types";
 
-import { Context } from "shared/Context";
+import { valueExists } from "shared/util";
 
 import { useClusterContext } from "./ClusterContextProvider";
 import { useClusterFormContext } from "./ClusterFormContextProvider";
 import ClusterProvisioningIndicator from "./ClusterProvisioningIndicator";
 import ClusterSaveButton from "./ClusterSaveButton";
+import AdvancedSettingsTab from "./tabs/AdvancedSettingsTab";
 import ClusterOverview from "./tabs/overview/ClusterOverview";
 import Settings from "./tabs/Settings";
 
 const validTabs = ["overview", "settings", "advanced"] as const;
 const DEFAULT_TAB = "overview" as const;
 type ValidTab = (typeof validTabs)[number];
-const tabs = [
-  { label: "Overview", value: "overview" },
-  { label: "Settings", value: "settings" },
-];
 
 type Props = {
   tabParam?: string;
 };
 const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
-  const { currentProject } = useContext(Context);
   const history = useHistory();
 
   const { cluster, isClusterUpdating } = useClusterContext();
@@ -42,7 +38,8 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
     formState: { isDirty },
   } = useFormContext<ClientClusterContract>();
 
-  const { setCurrentContract } = useClusterFormContext();
+  const { setCurrentContract, isAdvancedSettingsEnabled } =
+    useClusterFormContext();
 
   useEffect(() => {
     reset(cluster.contract.config);
@@ -53,17 +50,26 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
     );
   }, [cluster]);
 
-  useEffect(() => {
-    if (
-      currentProject?.advanced_infra_enabled &&
-      !tabs.some((x) => x.value === "advanced")
-    ) {
-      tabs.splice(1, 0, {
-        label: "Advanced",
-        value: "advanced",
-      });
-    }
-  }, [currentProject]);
+  const tabs = useMemo(() => {
+    const tabs = [
+      {
+        label: "Overview",
+        value: "overview" as ValidTab,
+      },
+      isAdvancedSettingsEnabled && cluster.cloud_provider.name === "AWS"
+        ? {
+            label: "Advanced",
+            value: "advanced" as ValidTab,
+          }
+        : undefined,
+      {
+        label: "Settings",
+        value: "settings" as ValidTab,
+      },
+    ].filter(valueExists);
+
+    return tabs;
+  }, [isAdvancedSettingsEnabled]);
 
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
@@ -89,7 +95,10 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
               <ClusterSaveButton
                 height={"10px"}
                 disabledTooltipPosition={"bottom"}
-              />
+                isClusterUpdating={isClusterUpdating}
+              >
+                Update
+              </ClusterSaveButton>
             </>
           }
         >
@@ -109,7 +118,7 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
       {match(currentTab)
         .with("overview", () => <ClusterOverview />)
         .with("settings", () => <Settings />)
-        .with("advanced", () => <div>Advanced settings</div>)
+        .with("advanced", () => <AdvancedSettingsTab />)
         .otherwise(() => null)}
     </DashboardWrapper>
   );

+ 4 - 1
dashboard/src/main/home/infrastructure-dashboard/ClusterView.tsx

@@ -39,7 +39,10 @@ const ClusterView: React.FC<Props> = ({ match }) => {
   }, [match]);
   return (
     <ClusterContextProvider clusterId={params.clusterId}>
-      <ClusterFormContextProvider projectId={currentProject?.id}>
+      <ClusterFormContextProvider
+        projectId={currentProject?.id}
+        isAdvancedSettingsEnabled={currentProject?.advanced_infra_enabled}
+      >
         <StyledExpandedCluster>
           <Back to="/infrastructure" />
           <ClusterHeader />

+ 1 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx

@@ -52,6 +52,7 @@ const CreateClusterForm: React.FC = () => {
     <ClusterFormContextProvider
       projectId={currentProject?.id}
       redirectOnSubmit={true}
+      isAdvancedSettingsEnabled={currentProject?.advanced_infra_enabled}
     >
       <CreateClusterFormContainer>
         {match(selectedCloudProvider)

+ 28 - 13
dashboard/src/main/home/infrastructure-dashboard/forms/aws/ConfigureEKSCluster.tsx

@@ -2,7 +2,6 @@ import React, { useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
 import Back from "components/porter/Back";
-import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Image from "components/porter/Image";
@@ -13,7 +12,10 @@ import VerticalSteps from "components/porter/VerticalSteps";
 import { CloudProviderAWS } from "lib/clusters/constants";
 import { type ClientClusterContract } from "lib/clusters/types";
 
+import { valueExists } from "shared/util";
+
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ClusterSaveButton from "../../ClusterSaveButton";
 import NodeGroups from "../../shared/NodeGroups";
 
 type Props = {
@@ -21,7 +23,7 @@ type Props = {
 };
 
 const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
-  const [currentStep, _setCurrentStep] = useState<number>(4);
+  const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
 
   const {
     control,
@@ -29,7 +31,7 @@ const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
     formState: { errors },
   } = useFormContext<ClientClusterContract>();
 
-  const { updateClusterButtonProps } = useClusterFormContext();
+  const { isAdvancedSettingsEnabled } = useClusterFormContext();
 
   return (
     <div>
@@ -86,6 +88,23 @@ const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
               )}
             />
           </>,
+          isAdvancedSettingsEnabled ? (
+            <>
+              <Text size={16}>CIDR Range</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Specify the CIDR range for your cluster.
+              </Text>
+              <Spacer y={0.7} />
+              <ControlledInput
+                placeholder="ex: 10.78.0.0/16"
+                type="text"
+                width="300px"
+                error={errors.cluster?.config?.cidrRange?.message}
+                {...register("cluster.config.cidrRange")}
+              />
+            </>
+          ) : null,
           <>
             <Text size={16}>Application node group</Text>
             <Spacer y={0.5} />
@@ -102,16 +121,12 @@ const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
             <Spacer y={1} />
             <NodeGroups availableMachineTypes={CloudProviderAWS.machineTypes} />
           </>,
-          <Button
-            key={3}
-            type="submit"
-            status={updateClusterButtonProps.status}
-            disabled={updateClusterButtonProps.isDisabled}
-            loadingText={updateClusterButtonProps.loadingText}
-          >
-            Create resources
-          </Button>,
-        ]}
+          <>
+            <Text size={16}>Provision cluster</Text>
+            <Spacer y={0.5} />
+            <ClusterSaveButton>Submit</ClusterSaveButton>
+          </>,
+        ].filter(valueExists)}
       />
     </div>
   );

+ 39 - 28
dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx

@@ -1,7 +1,6 @@
 import React, { useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
-import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Select from "components/porter/Select";
@@ -11,7 +10,10 @@ import VerticalSteps from "components/porter/VerticalSteps";
 import { CloudProviderAzure } from "lib/clusters/constants";
 import { type ClientClusterContract } from "lib/clusters/types";
 
+import { valueExists } from "shared/util";
+
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ClusterSaveButton from "../../ClusterSaveButton";
 import NodeGroups from "../../shared/NodeGroups";
 import { BackButton, Img } from "../CreateClusterForm";
 
@@ -20,7 +22,7 @@ type Props = {
 };
 
 const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
-  const [currentStep, _setCurrentStep] = useState<number>(100);
+  const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
 
   const {
     control,
@@ -29,9 +31,9 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
     watch,
   } = useFormContext<ClientClusterContract>();
 
-  const region = watch("cluster.config.region");
+  const { isAdvancedSettingsEnabled } = useClusterFormContext();
 
-  const { updateClusterButtonProps } = useClusterFormContext();
+  const region = watch("cluster.config.region");
 
   return (
     <div>
@@ -45,7 +47,7 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
         <Text size={16}>Configure AKS Cluster</Text>
       </Container>
       <Spacer y={1} />
-      <Text>Specify settings for your AKS cluster.</Text>
+      <Text>Specify settings for your AKS infratructure.</Text>
       <Spacer y={1} />
       <VerticalSteps
         currentStep={currentStep}
@@ -53,6 +55,10 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
           <>
             <Text size={16}>Cluster name</Text>
             <Spacer y={0.5} />
+            <Text color="helper">
+              Lowercase letters, numbers, and &quot;-&quot; only.
+            </Text>
+            <Spacer y={0.7} />
             <ControlledInput
               placeholder="ex: my-cluster"
               type="text"
@@ -62,8 +68,12 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
             />
           </>,
           <>
-            <Text size={16}>Cluster region</Text>
+            <Text size={16}>Region</Text>
             <Spacer y={0.5} />
+            <Text color="helper">
+              Select the region where you want to run your cluster.
+            </Text>
+            <Spacer y={0.7} />
             <Controller
               name={`cluster.config.region`}
               control={control}
@@ -106,20 +116,28 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
               />
             </Container>
           </>,
+          isAdvancedSettingsEnabled ? (
+            <>
+              <Text size={16}>CIDR Range</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Specify the CIDR range for your cluster.
+              </Text>
+              <Spacer y={0.7} />
+              <ControlledInput
+                placeholder="ex: 10.78.0.0/16"
+                type="text"
+                width="300px"
+                error={errors.cluster?.config?.cidrRange?.message}
+                {...register("cluster.config.cidrRange")}
+              />
+            </>
+          ) : null,
           <>
-            <Text size={16}>CIDR Range</Text>
+            <Text size={16}>Application node group </Text>
             <Spacer y={0.5} />
-            <ControlledInput
-              placeholder="ex: 10.78.0.0/16"
-              type="text"
-              width="300px"
-              error={errors.cluster?.config?.cidrRange?.message}
-              {...register("cluster.config.cidrRange")}
-            />
-          </>,
-          <>
-            <Text size={16}>
-              Application node group{" "}
+            <Text color="helper">
+              Configure your application infrastructure.{" "}
               <a
                 href="https://docs.porter.run/other/kubernetes-101"
                 target="_blank"
@@ -128,7 +146,7 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
                 &nbsp;(?)
               </a>
             </Text>
-            <Spacer y={0.5} />
+            <Spacer y={1} />
             <NodeGroups
               availableMachineTypes={CloudProviderAzure.machineTypes.filter(
                 (mt) => mt.supportedRegions.includes(region)
@@ -138,16 +156,9 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
           <>
             <Text size={16}>Provision cluster</Text>
             <Spacer y={0.5} />
-            <Button
-              type="submit"
-              status={updateClusterButtonProps.status}
-              disabled={updateClusterButtonProps.isDisabled}
-              loadingText={updateClusterButtonProps.loadingText}
-            >
-              Submit
-            </Button>
+            <ClusterSaveButton>Submit</ClusterSaveButton>
           </>,
-        ]}
+        ].filter(valueExists)}
       />
     </div>
   );

+ 46 - 26
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/ConfigureGKECluster.tsx

@@ -1,7 +1,6 @@
 import React, { useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
-import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Select from "components/porter/Select";
@@ -11,7 +10,10 @@ import VerticalSteps from "components/porter/VerticalSteps";
 import { CloudProviderGCP } from "lib/clusters/constants";
 import { type ClientClusterContract } from "lib/clusters/types";
 
+import { valueExists } from "shared/util";
+
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
+import ClusterSaveButton from "../../ClusterSaveButton";
 import NodeGroups from "../../shared/NodeGroups";
 import { BackButton, Img } from "../CreateClusterForm";
 
@@ -20,7 +22,7 @@ type Props = {
 };
 
 const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
-  const [currentStep, _setCurrentStep] = useState<number>(4);
+  const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
 
   const {
     control,
@@ -28,7 +30,7 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
     formState: { errors },
   } = useFormContext<ClientClusterContract>();
 
-  const { updateClusterButtonProps } = useClusterFormContext();
+  const { isAdvancedSettingsEnabled } = useClusterFormContext();
 
   return (
     <div>
@@ -39,10 +41,10 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
         </BackButton>
         <Spacer x={1} inline />
         <Img src={CloudProviderGCP.icon} />
-        <Text size={16}>Configure EKS Cluster</Text>
+        <Text size={16}>Configure GKE Cluster</Text>
       </Container>
       <Spacer y={1} />
-      <Text>Specify settings for your EKS cluster.</Text>
+      <Text>Specify settings for your GKE infrastructure.</Text>
       <Spacer y={1} />
       <VerticalSteps
         currentStep={currentStep}
@@ -50,6 +52,10 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
           <>
             <Text size={16}>Cluster name</Text>
             <Spacer y={0.5} />
+            <Text color="helper">
+              Lowercase letters, numbers, and &quot;-&quot; only.
+            </Text>
+            <Spacer y={0.7} />
             <ControlledInput
               placeholder="ex: my-cluster"
               type="text"
@@ -59,8 +65,12 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
             />
           </>,
           <>
-            <Text size={16}>Cluster region</Text>
+            <Text size={16}>Region</Text>
             <Spacer y={0.5} />
+            <Text color="helper">
+              Select the region where you want to run your cluster.
+            </Text>
+            <Spacer y={0.7} />
             <Controller
               name={`cluster.config.region`}
               control={control}
@@ -83,17 +93,23 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
               }}
             />
           </>,
-          <>
-            <Text size={16}>CIDR Range</Text>
-            <Spacer y={0.5} />
-            <ControlledInput
-              placeholder="ex: 10.78.0.0/16"
-              type="text"
-              width="300px"
-              error={errors.cluster?.config?.cidrRange?.message}
-              {...register("cluster.config.cidrRange")}
-            />
-          </>,
+          isAdvancedSettingsEnabled ? (
+            <>
+              <Text size={16}>CIDR Range</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Specify the CIDR range for your cluster.
+              </Text>
+              <Spacer y={0.7} />
+              <ControlledInput
+                placeholder="ex: 10.78.0.0/16"
+                type="text"
+                width="300px"
+                error={errors.cluster?.config?.cidrRange?.message}
+                {...register("cluster.config.cidrRange")}
+              />
+            </>
+          ) : null,
           <>
             <Text size={16}>
               Application node group{" "}
@@ -106,21 +122,25 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
               </a>
             </Text>
             <Spacer y={0.5} />
+            <Text color="helper">
+              Configure your application infrastructure.{" "}
+              <a
+                href="https://docs.porter.run/other/kubernetes-101"
+                target="_blank"
+                rel="noreferrer"
+              >
+                &nbsp;(?)
+              </a>
+            </Text>
+            <Spacer y={1} />
             <NodeGroups availableMachineTypes={CloudProviderGCP.machineTypes} />
           </>,
           <>
             <Text size={16}>Provision cluster</Text>
             <Spacer y={0.5} />
-            <Button
-              type="submit"
-              status={updateClusterButtonProps.status}
-              disabled={updateClusterButtonProps.isDisabled}
-              loadingText={updateClusterButtonProps.loadingText}
-            >
-              Submit
-            </Button>
+            <ClusterSaveButton>Submit</ClusterSaveButton>
           </>,
-        ]}
+        ].filter(valueExists)}
       />
     </div>
   );

+ 18 - 0
dashboard/src/main/home/infrastructure-dashboard/shared/advanced/AdvancedSettings.tsx

@@ -0,0 +1,18 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+import { match } from "ts-pattern";
+
+import { type ClientClusterContract } from "lib/clusters/types";
+
+import EKSClusterAdvancedSettings from "./EKSClusterAdvancedSettings";
+
+const AdvancedSettings: React.FC = () => {
+  const { watch } = useFormContext<ClientClusterContract>();
+  const cloudProvider = watch("cluster.cloudProvider");
+
+  return match(cloudProvider)
+    .with("AWS", () => <EKSClusterAdvancedSettings />)
+    .otherwise(() => null);
+};
+
+export default AdvancedSettings;

+ 402 - 0
dashboard/src/main/home/infrastructure-dashboard/shared/advanced/EKSClusterAdvancedSettings.tsx

@@ -0,0 +1,402 @@
+import React from "react";
+import AnimateHeight from "react-animate-height";
+import { Controller, useFieldArray, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientClusterContract } from "lib/clusters/types";
+
+const EKSClusterAdvancedSettings: React.FC = () => {
+  const { control, watch, register } = useFormContext<ClientClusterContract>();
+  const loadBalancerType = watch("cluster.config.loadBalancer.type", "UNKNOWN");
+  const {
+    append: appendCertArn,
+    remove: removeCertArn,
+    fields: certificateArns,
+  } = useFieldArray({
+    control,
+    name: "cluster.config.loadBalancer.certificateArns",
+  });
+  const {
+    remove: removeTag,
+    append: appendTag,
+    fields: tags,
+  } = useFieldArray({
+    control,
+    name: `cluster.config.loadBalancer.awsTags`,
+  });
+  const isWafV2Enabled = watch(
+    "cluster.config.loadBalancer.isWafV2Enabled",
+    false
+  );
+
+  return (
+    <div>
+      <Text size={16}>Compliance</Text>
+      <Spacer y={0.5} />
+      <SettingsGroupContainer>
+        <SettingsGroupContainer>
+          <Text size={14}>ECR scanning</Text>
+          <Controller
+            name={`cluster.config.isEcrScanningEnabled`}
+            control={control}
+            render={({ field: { value, onChange } }) => (
+              <Checkbox
+                checked={value}
+                toggleChecked={() => {
+                  onChange(!value);
+                }}
+              >
+                <Text color="helper">ECR scanning enabled</Text>
+              </Checkbox>
+            )}
+          />
+        </SettingsGroupContainer>
+        <SettingsGroupContainer>
+          <Text size={14}>AWS GuardDuty</Text>
+          <Text color="helper">
+            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.
+          </Text>
+          <Controller
+            name={`cluster.config.isGuardDutyEnabled`}
+            control={control}
+            render={({ field: { value, onChange } }) => (
+              <Checkbox
+                checked={value}
+                toggleChecked={() => {
+                  onChange(!value);
+                }}
+              >
+                <Text color="helper">
+                  AWS GuardDuty agent installed on cluster
+                </Text>
+              </Checkbox>
+            )}
+          />
+        </SettingsGroupContainer>
+        <SettingsGroupContainer>
+          <Text size={14}>KMS Encryption</Text>
+          <Controller
+            name={`cluster.config.isKmsEncryptionEnabled`}
+            control={control}
+            render={({ field: { value, onChange } }) => (
+              <Checkbox
+                checked={value}
+                toggleChecked={() => {
+                  onChange(!value);
+                }}
+              >
+                <Text color="helper">KMS encryption enabled</Text>
+              </Checkbox>
+            )}
+          />
+        </SettingsGroupContainer>
+      </SettingsGroupContainer>
+      <Spacer y={1} />
+      <Text size={16}>AWS CloudWatch logging</Text>
+      <Spacer y={0.5} />
+      <Text color={"helper"}>
+        Configure which logs to send to AWS CloudWatch.
+      </Text>
+      <Spacer y={0.5} />
+      <SettingsGroupContainer>
+        <Controller
+          name={`cluster.config.logging.isApiServerLogsEnabled`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Checkbox
+              checked={value}
+              toggleChecked={() => {
+                onChange(!value);
+              }}
+            >
+              <Text color="helper">API Server logs</Text>
+            </Checkbox>
+          )}
+        />
+        <Controller
+          name={`cluster.config.logging.isAuditLogsEnabled`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Checkbox
+              checked={value}
+              toggleChecked={() => {
+                onChange(!value);
+              }}
+            >
+              <Text color="helper">Audit logs</Text>
+            </Checkbox>
+          )}
+        />
+        <Controller
+          name={`cluster.config.logging.isAuthenticatorLogsEnabled`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Checkbox
+              checked={value}
+              toggleChecked={() => {
+                onChange(!value);
+              }}
+            >
+              <Text color="helper">Authenticator logs</Text>
+            </Checkbox>
+          )}
+        />
+        <Controller
+          name={`cluster.config.logging.isControllerManagerLogsEnabled`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Checkbox
+              checked={value}
+              toggleChecked={() => {
+                onChange(!value);
+              }}
+            >
+              <Text color="helper">Controller manager logs</Text>
+            </Checkbox>
+          )}
+        />
+        <Controller
+          name={`cluster.config.logging.isSchedulerLogsEnabled`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Checkbox
+              checked={value}
+              toggleChecked={() => {
+                onChange(!value);
+              }}
+            >
+              <Text color="helper">Scheduler logs</Text>
+            </Checkbox>
+          )}
+        />
+      </SettingsGroupContainer>
+      <Spacer y={1} />
+      <Text size={16}>Load balancer</Text>
+      <Spacer y={0.5} />
+      <SettingsGroupContainer>
+        <Text size={14}>Type</Text>
+        <Controller
+          name={`cluster.config.loadBalancer.type`}
+          control={control}
+          render={({ field: { value, onChange } }) => (
+            <Container style={{ width: "300px" }}>
+              <Select
+                options={["NLB", "ALB"].map((value) => ({
+                  value,
+                  label: value,
+                }))}
+                setValue={(selected: string) => {
+                  onChange(selected);
+                }}
+                value={value}
+              />
+            </Container>
+          )}
+        />
+        <AnimateHeight
+          duration={500}
+          height={loadBalancerType === "ALB" ? "auto" : 0}
+        >
+          <SettingsGroupContainer>
+            <SettingsGroupContainer>
+              <Text size={14}>Wildcard domain</Text>
+              <Text color="helper">
+                The provided domain should have a wildcard subdomain pointed to
+                the LoadBalancer address. Using testing.porter.run will create a
+                certificate for testing.porter.run with a SAN
+                *.testing.porter.run.
+              </Text>
+              <ControlledInput
+                type="text"
+                placeholder="user-2.porter.run"
+                width="300px"
+                {...register("cluster.config.loadBalancer.wildcardDomain")}
+              />
+            </SettingsGroupContainer>
+            <SettingsGroupContainer>
+              <Text size={14}>IP Allow List</Text>
+              <Text color="helper">
+                Each range should be a CIDR, including netmask such as
+                10.1.2.3/21. To use multiple values, they should be
+                comma-separated with no spaces.
+              </Text>
+              <ControlledInput
+                type="text"
+                placeholder="160.72.72.58/32,160.72.72.59/32"
+                width="300px"
+                {...register("cluster.config.loadBalancer.allowlistIpRanges")}
+              />
+            </SettingsGroupContainer>
+            <SettingsGroupContainer>
+              <Text size={14}>Certificate ARNs</Text>
+              {certificateArns.length !== 0 && (
+                <>
+                  {certificateArns.map((certArn, i) => {
+                    return (
+                      <div key={certArn.id}>
+                        <CertificateArnContainer>
+                          <ControlledInput
+                            type="text"
+                            placeholder="arn:aws:acm:REGION:ACCOUNT_ID:certificate/ACM_ID"
+                            width="275px"
+                            {...register(
+                              `cluster.config.loadBalancer.certificateArns.${i}.arn`
+                            )}
+                          />
+                          <DeleteButton
+                            onClick={() => {
+                              removeCertArn(i);
+                            }}
+                          >
+                            <i className="material-icons">cancel</i>
+                          </DeleteButton>
+                        </CertificateArnContainer>
+                        <Spacer y={0.25} />
+                      </div>
+                    );
+                  })}
+                </>
+              )}
+              <Button
+                onClick={() => {
+                  appendCertArn({
+                    arn: "",
+                  });
+                }}
+              >
+                + Add
+              </Button>
+            </SettingsGroupContainer>
+            <SettingsGroupContainer>
+              <Text size={14}>AWS Tags</Text>
+              {tags.length !== 0 && (
+                <>
+                  {tags.map((tag, i) => {
+                    return (
+                      <div key={tag.id}>
+                        <CertificateArnContainer>
+                          <ControlledInput
+                            type="text"
+                            placeholder="key"
+                            width="275px"
+                            {...register(
+                              `cluster.config.loadBalancer.awsTags.${i}.key`
+                            )}
+                          />
+                          <ControlledInput
+                            type="text"
+                            placeholder="value"
+                            width="275px"
+                            {...register(
+                              `cluster.config.loadBalancer.awsTags.${i}.value`
+                            )}
+                          />
+                          <DeleteButton
+                            onClick={() => {
+                              removeTag(i);
+                            }}
+                          >
+                            <i className="material-icons">cancel</i>
+                          </DeleteButton>
+                        </CertificateArnContainer>
+                        <Spacer y={0.25} />
+                      </div>
+                    );
+                  })}
+                </>
+              )}
+              <Button
+                onClick={() => {
+                  appendTag({
+                    key: "",
+                    value: "",
+                  });
+                }}
+              >
+                + Add
+              </Button>
+            </SettingsGroupContainer>
+            <SettingsGroupContainer>
+              <Text size={14}>WAFv2</Text>
+              <Controller
+                name={`cluster.config.loadBalancer.isWafV2Enabled`}
+                control={control}
+                render={({ field: { value, onChange } }) => (
+                  <Checkbox
+                    checked={value}
+                    toggleChecked={() => {
+                      onChange(!value);
+                    }}
+                  >
+                    <Text color="helper">WAFv2 enabled</Text>
+                  </Checkbox>
+                )}
+              />
+              {isWafV2Enabled && (
+                <SettingsGroupContainer>
+                  <Text size={14}>WAFv2 ARN</Text>
+                  <Text color="helper">
+                    Only Regional WAFv2 is supported. To find your ARN, navigate
+                    to the WAF console, click the Gear icon in the top right,
+                    and toggle on &quot;ARN&quot;.
+                  </Text>
+                  <ControlledInput
+                    type="text"
+                    placeholder="arn:aws:wafv2:REGION:ACCOUNT_ID:regional/webacl/ACL_NAME/RULE_ID"
+                    width="300px"
+                    {...register("cluster.config.loadBalancer.wafV2Arn")}
+                  />
+                </SettingsGroupContainer>
+              )}
+            </SettingsGroupContainer>
+          </SettingsGroupContainer>
+        </AnimateHeight>
+      </SettingsGroupContainer>
+    </div>
+  );
+};
+
+export default EKSClusterAdvancedSettings;
+
+const SettingsGroupContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+`;
+
+const CertificateArnContainer = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 5px;
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;

+ 24 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/AdvancedSettingsTab.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+
+import Spacer from "components/porter/Spacer";
+
+import { useClusterContext } from "../ClusterContextProvider";
+import ClusterSaveButton from "../ClusterSaveButton";
+import AdvancedSettings from "../shared/advanced/AdvancedSettings";
+
+const AdvancedSettingsTab: React.FC = () => {
+  const { isClusterUpdating } = useClusterContext();
+
+  return (
+    <div>
+      <AdvancedSettings />
+      <Spacer y={1} />
+      <ClusterSaveButton isClusterUpdating={isClusterUpdating}>
+        Update
+      </ClusterSaveButton>
+      <Spacer y={1} />
+    </div>
+  );
+};
+
+export default AdvancedSettingsTab;

+ 4 - 2
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/ClusterOverview.tsx

@@ -10,7 +10,7 @@ import EKSClusterOverview from "./EKSClusterOverview";
 import GKEClusterOverview from "./GKEClusterOverview";
 
 const ClusterOverview: React.FC = () => {
-  const { cluster } = useClusterContext();
+  const { cluster, isClusterUpdating } = useClusterContext();
 
   return (
     <>
@@ -26,7 +26,9 @@ const ClusterOverview: React.FC = () => {
         })
         .exhaustive()}
       <Spacer y={1} />
-      <ClusterSaveButton />
+      <ClusterSaveButton isClusterUpdating={isClusterUpdating}>
+        Update
+      </ClusterSaveButton>
       <Spacer y={1} />
     </>
   );