diff --git a/packages/manager/.changeset/pr-11322-upcoming-features-1732570083227.md b/packages/manager/.changeset/pr-11322-upcoming-features-1732570083227.md new file mode 100644 index 00000000000..e0d7fdc772e --- /dev/null +++ b/packages/manager/.changeset/pr-11322-upcoming-features-1732570083227.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Cluster Type section to Create Cluster flow for LKE-E ([#11322](https://github.com/linode/manager/pull/11322)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index c220c9e372b..9916ec007bd 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -39,6 +39,7 @@ import { dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; /** * Gets the label for an LKE plan as shown in creation plan table. @@ -826,3 +827,110 @@ describe('LKE Cluster Creation with ACL', () => { }); }); }); + +describe('LKE Cluster Creation with LKE-E', () => { + /** + * - Confirms LKE-E flow does not exist if account doesn't have the corresponding capability + * @todo LKE-E: Remove this test once LKE-E is fully rolled out + */ + it('does not show the LKE-E flow with the feature flag off', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: false, la: false }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/kubernetes/clusters'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/kubernetes/create'); + + cy.contains('Cluster Type').should('not.exist'); + }); + + describe('shows the LKE-E flow with the feature flag on', () => { + beforeEach(() => { + // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }).as('getFeatureFlags'); + }); + + /** + * - Mocks the LKE-E capability + * - Confirms the Cluster Type selection can be made + * - Confirms that HA is enabled by default with LKE-E selection + * @todo LKE-E: Add onto this test as the LKE-E changes to the Create flow are built out + */ + it('creates an LKE-E cluster with the account capability', () => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + cy.visitWithLogin('/kubernetes/clusters'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/kubernetes/create'); + + cy.findByText('Cluster Type').should('be.visible'); + + // Confirm both cluster types exist and the LKE card is selected by default + cy.get(`[data-qa-select-card-heading="LKE"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'data-qa-selection-card-checked', 'true'); + + cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'data-qa-selection-card-checked', 'false') + .click(); + + // Select LKE-E as the cluster type + cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'data-qa-selection-card-checked', 'true'); + + // Confirm HA section is hidden since LKE-E includes HA by default + cy.findByText('HA Control Plane').should('not.exist'); + + // TODO: finish the rest of this test in subsequent PRs + }); + + it('disables the Cluster Type selection without the LKE-E account capability', () => { + cy.visitWithLogin('/kubernetes/clusters'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/kubernetes/create'); + + // Confirm the Cluster Type selection can be made when the LKE-E feature is enabled + cy.findByText('Cluster Type').should('be.visible'); + + // Confirm both tiers exist and the LKE card is selected by default + cy.get(`[data-qa-select-card-heading="LKE"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'data-qa-selection-card-checked', 'true'); + + cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'disabled'); + }); + }); +}); diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index b82143a66fb..4991bf1e32b 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { CardBase } from './CardBase'; +import type { TooltipProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material/styles'; export interface SelectionCardProps { @@ -87,6 +88,11 @@ export interface SelectionCardProps { * Optional text to set in a tooltip when hovering over the card. */ tooltip?: JSX.Element | string; + /** + * The placement of the tooltip + * @default top + */ + tooltipPlacement?: TooltipProps['placement']; } /** @@ -114,6 +120,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { sxGrid, sxTooltip, tooltip, + tooltipPlacement = 'top', } = props; const handleKeyPress = (e: React.KeyboardEvent) => { @@ -171,7 +178,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { componentsProps={{ tooltip: { sx: sxTooltip }, }} - placement="top" + placement={tooltipPlacement} title={tooltip} > {cardGrid} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTypePanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTypePanel.tsx new file mode 100644 index 00000000000..106537bcc31 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTypePanel.tsx @@ -0,0 +1,85 @@ +import { Stack } from '@linode/ui'; +import { Typography, useMediaQuery } from '@mui/material'; +import React from 'react'; + +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useAccountBeta } from 'src/queries/account/account'; + +import { StyledDocsLinkContainer } from './CreateCluster.styles'; + +import type { KubernetesTier } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + +interface Props { + handleClusterTypeSelection: (tier: KubernetesTier) => void; + selectedTier: KubernetesTier; +} + +export const ClusterTypePanel = (props: Props) => { + const { handleClusterTypeSelection, selectedTier } = props; + + const { data: account } = useAccountBeta(); + + const mdDownBreakpoint = useMediaQuery((theme: Theme) => + theme.breakpoints.down('md') + ); + const smDownBreakpoint = useMediaQuery((theme: Theme) => + theme.breakpoints.down('sm') + ); + + const isLkeEnterpriseSelectionDisabled = !account?.capabilities?.includes( + 'Kubernetes Enterprise' + ); + + return ( + + + + Cluster Type + + Choose from a managed solution for smaller deployments or enterprise + grade clusters with enhanced ingress, networking, and security. + + + + + + + + + handleClusterTypeSelection('standard')} + /> + handleClusterTypeSelection('enterprise')} + tooltipPlacement={smDownBreakpoint ? 'bottom' : 'right'} + /> + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.styles.ts b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.styles.ts index 1dfcbc34a65..3c4227e1a8a 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.styles.ts +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.styles.ts @@ -51,11 +51,10 @@ export const StyledFieldWithDocsStack = styled(Stack, { })); export const StyledDocsLinkContainer = styled(Box, { - label: 'StyledRegionSelectStack', + label: 'StyledDocsLinkContainer', })(({ theme }) => ({ alignSelf: 'flex-start', marginLeft: 'auto', - marginTop: theme.spacing(2), [theme.breakpoints.down('md')]: { marginLeft: 'unset', marginTop: theme.spacing(2), diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 564755f65a9..2e4523ec472 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -19,6 +19,7 @@ import { getKubeHighAvailability, getLatestVersion, useAPLAvailability, + useIsLkeEnterpriseEnabled, } from 'src/features/Kubernetes/kubeUtils'; import { useAccount } from 'src/queries/account/account'; import { @@ -38,13 +39,14 @@ import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; import { plansNoticesUtils } from 'src/utilities/planNotices'; -import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import KubeCheckoutBar from '../KubeCheckoutBar'; import { ApplicationPlatform } from './ApplicationPlatform'; +import { ClusterTypePanel } from './ClusterTypePanel'; import { ControlPlaneACLPane } from './ControlPlaneACLPane'; import { StyledDocsLinkContainer, @@ -58,6 +60,7 @@ import type { CreateKubeClusterPayload, CreateNodePoolData, KubeNodePoolResponse, + KubernetesTier, } from '@linode/api-v4/lib/kubernetes'; import type { APIError } from '@linode/api-v4/lib/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; @@ -92,6 +95,9 @@ export const CreateCluster = () => { const [ipV6Addr, setIPv6Addr] = React.useState([ stringToExtendedIP(''), ]); + const [selectedTier, setSelectedTier] = React.useState( + 'standard' + ); const { data: kubernetesHighAvailabilityTypesData, @@ -99,6 +105,13 @@ export const CreateCluster = () => { isLoading: isLoadingKubernetesTypes, } = useKubernetesTypesQuery(); + const handleClusterTypeSelection = (tier: KubernetesTier) => { + if (tier === 'enterprise') { + setHighAvailability(false); + } + setSelectedTier(tier); + }; + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( (type) => type.id === 'lke-ha' ); @@ -125,6 +138,11 @@ export const CreateCluster = () => { isError: versionLoadError, } = useKubernetesVersionQuery(); + const { + isLkeEnterpriseLAFeatureEnabled, + isLkeEnterpriseLAFlagEnabled, + } = useIsLkeEnterpriseEnabled(); + const versions = (versionData ?? []).map((thisVersion) => ({ label: thisVersion.id, value: thisVersion.id, @@ -195,9 +213,10 @@ export const CreateCluster = () => { payload = { ...payload, apl_enabled }; } - const createClusterFn = showAPL - ? createKubernetesClusterBeta - : createKubernetesCluster; + const createClusterFn = + showAPL || isLkeEnterpriseLAFeatureEnabled + ? createKubernetesClusterBeta + : createKubernetesCluster; createClusterFn(payload) .then((cluster) => { @@ -300,6 +319,15 @@ export const CreateCluster = () => { label="Cluster Label" value={label || ''} /> + {isLkeEnterpriseLAFlagEnabled && ( + <> + + + + )} @@ -316,7 +344,9 @@ export const CreateCluster = () => { value={selectedRegionId} /> - + ({ marginTop: theme.spacing(2) })} + > { )} - {showHighAvailability && ( + {showHighAvailability && selectedTier !== 'enterprise' && ( { )} {showControlPlaneACL && ( <> - + {selectedTier !== 'enterprise' && } { setIPv4Addr(newIpV4Addr); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 93ad77b2501..9f621dfb6f3 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -164,7 +164,7 @@ describe('helper functions', () => { }); describe('useIsLkeEnterpriseEnabled', () => { - it('returns false if the account does not have the capability', () => { + it('returns false for feature enablement if the account does not have the capability', () => { queryMocks.useAccountBeta.mockReturnValue({ data: { capabilities: [], @@ -180,12 +180,14 @@ describe('useIsLkeEnterpriseEnabled', () => { const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); expect(result.current).toStrictEqual({ - isLkeEnterpriseGAEnabled: false, - isLkeEnterpriseLAEnabled: false, + isLkeEnterpriseGAFeatureEnabled: false, + isLkeEnterpriseGAFlagEnabled: true, + isLkeEnterpriseLAFeatureEnabled: false, + isLkeEnterpriseLAFlagEnabled: true, }); }); - it('returns true for LA if the account has the capability + enabled LA feature flag values', () => { + it('returns true for LA feature enablement if the account has the capability + enabled LA feature flag values', () => { queryMocks.useAccountBeta.mockReturnValue({ data: { capabilities: ['Kubernetes Enterprise'], @@ -201,12 +203,14 @@ describe('useIsLkeEnterpriseEnabled', () => { const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); expect(result.current).toStrictEqual({ - isLkeEnterpriseGAEnabled: false, - isLkeEnterpriseLAEnabled: true, + isLkeEnterpriseGAFeatureEnabled: false, + isLkeEnterpriseGAFlagEnabled: false, + isLkeEnterpriseLAFeatureEnabled: true, + isLkeEnterpriseLAFlagEnabled: true, }); }); - it('returns true for GA if the account has the capability + enabled GA feature flag values', () => { + it('returns true for GA feature enablement if the account has the capability + enabled GA feature flag values', () => { queryMocks.useAccountBeta.mockReturnValue({ data: { capabilities: ['Kubernetes Enterprise'], @@ -222,8 +226,10 @@ describe('useIsLkeEnterpriseEnabled', () => { const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); expect(result.current).toStrictEqual({ - isLkeEnterpriseGAEnabled: true, - isLkeEnterpriseLAEnabled: true, + isLkeEnterpriseGAFeatureEnabled: true, + isLkeEnterpriseGAFlagEnabled: true, + isLkeEnterpriseLAFeatureEnabled: true, + isLkeEnterpriseLAFlagEnabled: true, }); }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index b90963aa481..ff7882d843b 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,6 +1,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useAccountBeta } from 'src/queries/account/account'; import { useAccountBetaQuery } from 'src/queries/account/betas'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getBetaStatus } from 'src/utilities/betaUtils'; import { sortByVersion } from 'src/utilities/sort-by'; @@ -12,7 +13,6 @@ import type { } from '@linode/api-v4/lib/kubernetes'; import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; export const nodesDeletionWarning = `All nodes will be deleted and new nodes will be created to replace them.`; export const localStorageWarning = `Any local storage (such as \u{2019}hostPath\u{2019} volumes) will be erased.`; @@ -191,29 +191,34 @@ export const getLatestVersion = ( * Hook to determine if the LKE-Enterprise feature should be visible to the user. * Based on the user's account capability and the feature flag. * - * @returns {boolean, boolean} - Whether the LKE-Enterprise feature is enabled for the current user in LA and GA, respectively. + * @returns {boolean, boolean, boolean, boolean} - Whether the LKE-Enterprise flags are enabled for LA/GA and whether feature is enabled for LA/GA (flags + account capability). */ export const useIsLkeEnterpriseEnabled = () => { const flags = useFlags(); const { data: account } = useAccountBeta(); - const isLkeEnterpriseLA = Boolean( + const isLkeEnterpriseLAFlagEnabled = Boolean( flags?.lkeEnterprise?.enabled && flags.lkeEnterprise.la ); - const isLkeEnterpriseGA = Boolean( + const isLkeEnterpriseGAFlagEnabled = Boolean( flags.lkeEnterprise?.enabled && flags.lkeEnterprise.ga ); - const isLkeEnterpriseLAEnabled = isFeatureEnabledV2( + const isLkeEnterpriseLAFeatureEnabled = isFeatureEnabledV2( 'Kubernetes Enterprise', - isLkeEnterpriseLA, + isLkeEnterpriseLAFlagEnabled, account?.capabilities ?? [] ); - const isLkeEnterpriseGAEnabled = isFeatureEnabledV2( + const isLkeEnterpriseGAFeatureEnabled = isFeatureEnabledV2( 'Kubernetes Enterprise', - isLkeEnterpriseGA, + isLkeEnterpriseGAFlagEnabled, account?.capabilities ?? [] ); - return { isLkeEnterpriseLAEnabled, isLkeEnterpriseGAEnabled }; + return { + isLkeEnterpriseGAFeatureEnabled, + isLkeEnterpriseGAFlagEnabled, + isLkeEnterpriseLAFeatureEnabled, + isLkeEnterpriseLAFlagEnabled, + }; }; diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 9dbe78e0ce9..cbb467c027b 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -136,8 +136,8 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { export const useKubernetesClusterQuery = (id: number) => { const { isLoading: isAPLAvailabilityLoading, showAPL } = useAPLAvailability(); - const { isLkeEnterpriseLAEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = showAPL || isLkeEnterpriseLAEnabled; + const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const useBetaEndpoint = showAPL || isLkeEnterpriseLAFeatureEnabled; return useQuery({ ...kubernetesQueries.cluster(id)._ctx.cluster(useBetaEndpoint), @@ -150,8 +150,8 @@ export const useKubernetesClustersQuery = ( filter: Filter, enabled = true ) => { - const { isLkeEnterpriseLAEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = isLkeEnterpriseLAEnabled; + const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const useBetaEndpoint = isLkeEnterpriseLAFeatureEnabled; return useQuery, APIError[]>({ ...kubernetesQueries.lists._ctx.paginated(params, filter, useBetaEndpoint), @@ -391,8 +391,8 @@ export const useKubernetesTieredVersionQuery = (tier: string) => { * Before you use this, consider implementing infinite scroll instead. */ export const useAllKubernetesClustersQuery = (enabled = false) => { - const { isLkeEnterpriseLAEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = isLkeEnterpriseLAEnabled; + const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const useBetaEndpoint = isLkeEnterpriseLAFeatureEnabled; return useQuery({ ...kubernetesQueries.lists._ctx.all(useBetaEndpoint),