diff --git a/packages/manager/.changeset/pr-10157-fixed-1707328749030.md b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md new file mode 100644 index 00000000000..3495d7c8dd3 --- /dev/null +++ b/packages/manager/.changeset/pr-10157-fixed-1707328749030.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 038d5a1a95f..5f3e0fc32cc 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1011,4 +1011,229 @@ describe('LKE cluster updates for DC-specific prices', () => { // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool. cy.findByText('$43.20/month').should('be.visible'); }); + + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be changed. + * - Confirms that drawer reflects $0 pricing. + * - Confirms that details page still shows $0 pricing after resizing. + */ + it('can resize pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(3), + }); + + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + region: dcSpecificPricingRegion.id, + type: dcPricingMockLinodeTypes[2].id, + }); + } + ); + + const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getVersions', + '@getLinodeType', + ]); + + // Confirm that nodes are visible. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) + .should('be.visible') + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + } + }); + }); + + // Confirm total price is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Click "Resize Pool" and increase size to 4 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '4'); + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( + 'be.visible' + ); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@resizeNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); + + /* + * - Confirms UI flow when adding node pools using mocked API responses. + * - Confirms that drawer reflects $0 prices. + * - Confirms that details page still shows $0 pricing after adding node pool. + */ + it('can add node pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); + + const mockNewNodePool = nodePoolFactory.build({ + count: 2, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(2), + }); + + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: dcPricingMockLinodeTypes[2].id, + nodes: kubeLinodeFactory.buildList(1), + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); + + // Assert that initial node pool is shown on the page. + cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible'); + + // Confirm total price of $0 is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Linode 2 GB') + .should('be.visible') + .closest('tr') + .within(() => { + // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. + cy.findAllByText('$0').should('have.length', 2); + cy.findByLabelText('Add 1').should('be.visible').click().click(); + }); + + // Assert that $0 prices are displayed as helper text. + cy.contains( + 'This pool will add $0/month (2 nodes at $0/month) to this cluster.' + ).should('be.visible'); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for API responses. + cy.wait(['@addNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 9d9fc2bcf57..0d0e2b5e95e 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -69,6 +69,12 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { id: 'us-west', monthly: 12.2, }, + { + // Mock a DC with $0 region prices, which is possible in some circumstances (e.g. Limited Availability). + hourly: 0.0, + id: 'us-southeast', + monthly: 0.0, + }, ], }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index f8914146720..f34cc4228c9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,4 +1,5 @@ import { Theme } from '@mui/material/styles'; +import { isNumber } from 'lodash'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -20,6 +21,7 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; import type { Region } from '@linode/api-v4'; @@ -102,10 +104,12 @@ export const AddNodePoolDrawer = (props: Props) => { ?.monthly; const totalPrice = - selectedTypeInfo && pricePerNode + selectedTypeInfo && isNumber(pricePerNode) ? selectedTypeInfo.count * pricePerNode : undefined; + const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); + React.useEffect(() => { if (open) { resetDrawer(); @@ -199,7 +203,7 @@ export const AddNodePoolDrawer = (props: Props) => { /> )} - {selectedTypeInfo && !totalPrice && !pricePerNode && ( + {selectedTypeInfo && hasInvalidPrice && ( { )} { await findByText(/linode 1 GB/i); }); - it('should display a warning if the user tries to resize a node pool to < 3 nodes', () => { - const { getByText } = renderWithTheme( + it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { + const { findByText } = renderWithTheme( ); - expect(getByText(/minimum of 3 nodes/i)); + expect(await findByText(/minimum of 3 nodes/i)); }); - it('should display a warning if the user tries to resize to a smaller node count', () => { - const { getByTestId, getByText } = renderWithTheme( + it('should display a warning if the user tries to resize to a smaller node count', async () => { + const { findByTestId, getByText } = renderWithTheme( ); - const decrement = getByTestId('decrement-button'); + + const decrement = await findByTestId('decrement-button'); fireEvent.click(decrement); expect(getByText(/resizing to fewer nodes/i)); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index 5c77d3ee9a9..5f44f7681cd 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,7 +1,7 @@ import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -19,6 +19,8 @@ import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; +import { hasInvalidNodePoolPrice } from './utils'; +import { isNumber } from 'lodash'; const useStyles = makeStyles()((theme: Theme) => ({ helperText: { @@ -107,85 +109,89 @@ export const ResizeNodePoolDrawer = (props: Props) => { types: planType ? [planType] : [], }); + const hasInvalidPrice = hasInvalidNodePoolPrice( + pricePerNode, + totalMonthlyPrice + ); + return ( - {isLoadingTypes && } -
) => { - e.preventDefault(); - handleSubmit(); - }} - > -
- {totalMonthlyPrice && ( + {isLoadingTypes ? ( + + ) : ( + ) => { + e.preventDefault(); + handleSubmit(); + }} + > +
Current pool: $ - {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)}/month{' '} - ({pluralize('node', 'nodes', nodePool.count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)} + /month ({pluralize('node', 'nodes', nodePool.count)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) - )} -
- - {error && } - -
- - Enter the number of nodes you'd like in this pool: - - -
+
+ + {error && } -
- {/* Renders total pool price/month for N nodes at price per node/month. */} - {pricePerNode && ( +
+ + Enter the number of nodes you'd like in this pool: + + +
+ +
+ {/* Renders total pool price/month for N nodes at price per node/month. */} {`Resized pool: $${renderMonthlyPriceToCorrectDecimalPlace( - updatedCount * pricePerNode + isNumber(pricePerNode) ? updatedCount * pricePerNode : undefined )}/month`}{' '} ({pluralize('node', 'nodes', updatedCount)} at $ {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) +
+ + {updatedCount < nodePool.count && ( + + )} + + {updatedCount < 3 && ( + )} -
- - {updatedCount < nodePool.count && ( - - )} - - {updatedCount < 3 && ( - - )} - - {nodePool.count && (!pricePerNode || !totalMonthlyPrice) && ( - + )} + + - )} - - - + + )}
); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts new file mode 100644 index 00000000000..ac3166ae4f3 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -0,0 +1,19 @@ +import { hasInvalidNodePoolPrice } from './utils'; + +describe('hasInvalidNodePoolPrice', () => { + it('returns false if the prices are both zero, which is valid', () => { + expect(hasInvalidNodePoolPrice(0, 0)).toBe(false); + }); + + it('returns true if at least one of the prices is undefined', () => { + expect(hasInvalidNodePoolPrice(0, undefined)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(undefined, undefined)).toBe(true); + }); + + it('returns true if at least one of the prices is null', () => { + expect(hasInvalidNodePoolPrice(0, null)).toBe(true); + expect(hasInvalidNodePoolPrice(null, 0)).toBe(true); + expect(hasInvalidNodePoolPrice(null, null)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts new file mode 100644 index 00000000000..3b52451b27d --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -0,0 +1,13 @@ +/** + * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. + * @returns true if either value is null or undefined + */ +export const hasInvalidNodePoolPrice = ( + pricePerNode: null | number | undefined, + totalPrice: null | number | undefined +) => { + const isInvalidPricePerNode = !pricePerNode && pricePerNode !== 0; + const isInvalidTotalPrice = !totalPrice && totalPrice !== 0; + + return isInvalidPricePerNode || isInvalidTotalPrice; +};