diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index e62aa45b79e..db4c67420e5 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-02-12] - v1.112.0 + +### Added: + +- Support for IPv4 Ranges in VPC 'Assign Linodes to subnet' drawer ([#10089](https://github.com/linode/manager/pull/10089)) +- VPC IPv4 address and range to Linode IP Address Table ([#10108](https://github.com/linode/manager/pull/10108)) +- Support for VPC IPv4 Ranges data in Unassign Linodes drawer ([#10114](https://github.com/linode/manager/pull/10114)) +- Support for VPC IPv4 Ranges in Linode Create flow and 'VPC IPv4 Ranges' column to inner Subnets table on VPC Detail page ([#10116](https://github.com/linode/manager/pull/10116)) +- Support VPC IPv4 Ranges in Add/Edit Linode Config dialog ([#10170](https://github.com/linode/manager/pull/10170)) + +### Fixed: + +- Error when enabling backups for Linodes in regions with $0 pricing ([#10153](https://github.com/linode/manager/pull/10153)) +- Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157)) +- Error in Enable All Backups drawer when one or more Linode is in a $0 region ([#10161](https://github.com/linode/manager/pull/10161)) +- Display $0.00 prices in Linode Migration dialog ([#10166](https://github.com/linode/manager/pull/10166)) + ## [2024-02-05] - v1.111.0 ### Changed: 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 258b469209c..bc5b2e91c21 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/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 68d5c24693e..01694c1fa4a 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -270,16 +270,22 @@ describe('"Enable Linode Backups" banner', () => { // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. linodeFactory.build({ label: randomLabel(), - region: 'us-east', + region: 'us-ord', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[0].id, }), linodeFactory.build({ label: randomLabel(), - region: 'us-west', + region: 'us-east', backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[1].id, }), + linodeFactory.build({ + label: randomLabel(), + region: 'us-west', + backups: { enabled: false }, + type: dcPricingMockLinodeTypesForBackups[2].id, + }), linodeFactory.build({ label: randomLabel(), region: 'us-central', @@ -317,6 +323,7 @@ describe('"Enable Linode Backups" banner', () => { // The expected backup price for each Linode, as shown in backups drawer table. const expectedPrices = [ + '$0.00/mo', // us-ord mocked price. '$3.57/mo', // us-east mocked price. '$4.17/mo', // us-west mocked price. '$2.00/mo', // regular price. @@ -358,7 +365,7 @@ describe('"Enable Linode Backups" banner', () => { ); // Confirm that expected total cost is shown. - cy.contains(`Total for 3 Linodes: ${expectedTotal}`).should( + cy.contains(`Total for 4 Linodes: ${expectedTotal}`).should( 'be.visible' ); @@ -377,6 +384,10 @@ describe('"Enable Linode Backups" banner', () => { .closest('tr') .within(() => { cy.findByText(expectedPrice).should('be.visible'); + // Confirm no error indicator appears for $0.00 prices. + cy.findByLabelText( + 'There was an error loading the price.' + ).should('not.exist'); }); }); @@ -398,7 +409,7 @@ describe('"Enable Linode Backups" banner', () => { cy.wait([...enableBackupAliases, '@updateAccountSettings']); ui.toast.assertMessage( - '3 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' + '4 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' ); }); }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index ec272ddb620..43da412e2f0 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -153,7 +153,7 @@ describe('VPC assign/unassign flows', () => { mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as( 'getLinodeConfigs' ); - cy.findByLabelText('Linodes') + cy.findByLabelText('Linode') .should('be.visible') .click() .type(mockLinode.label) diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 9fb1445e7a0..3843a35aceb 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -75,9 +75,10 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { monthly: 12.2, }, { - hourly: 0.006, + // Mock a DC with $0 region prices, which is possible in some circumstances (e.g. Limited Availability). + hourly: 0.0, id: 'us-southeast', - monthly: 4.67, + monthly: 0.0, }, ], }); @@ -92,6 +93,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( monthly: 2.0, }, region_prices: [ + { + hourly: 0, + id: 'us-ord', + monthly: 0, + }, { hourly: 0.0048, id: 'us-east', diff --git a/packages/manager/package.json b/packages/manager/package.json index f0f3e13b000..619861d2364 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.111.0", + "version": "1.112.0", "private": true, "type": "module", "bugs": { @@ -215,4 +215,4 @@ "Firefox ESR", "not ie < 9" ] -} \ No newline at end of file +} diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 9d92323b8e4..63642a7184e 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -7,7 +7,9 @@ import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; import { InputLabel } from 'src/components/InputLabel'; +import { LinkButton } from 'src/components/LinkButton'; import { Notice } from 'src/components/Notice/Notice'; +import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; @@ -56,9 +58,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); interface Props { + buttonText?: string; className?: string; error?: string; forDatabaseAccessControls?: boolean; + forVPCIPv4Ranges?: boolean; helperText?: string; inputProps?: InputBaseProps; ips: ExtendedIP[]; @@ -72,9 +76,11 @@ interface Props { export const MultipleIPInput = React.memo((props: Props) => { const { + buttonText, className, error, forDatabaseAccessControls, + forVPCIPv4Ranges, helperText, ips, onBlur, @@ -122,6 +128,21 @@ export const MultipleIPInput = React.memo((props: Props) => { return null; } + const addIPButton = forVPCIPv4Ranges ? ( + + {buttonText} + + ) : ( + + ); + return (
{tooltip ? ( @@ -156,6 +177,7 @@ export const MultipleIPInput = React.memo((props: Props) => { direction="row" justifyContent="center" key={`domain-transfer-ip-${idx}`} + maxWidth={forVPCIPv4Ranges ? '415px' : undefined} spacing={2} > @@ -177,27 +199,22 @@ export const MultipleIPInput = React.memo((props: Props) => { value={thisIP.address} /> - {/** Don't show the button for the first input since it won't do anything, unless this component is used in DBaaS */} + {/** Don't show the button for the first input since it won't do anything, unless this component is + * used in DBaaS or for Linode VPC interfaces + */} - {idx > 0 || forDatabaseAccessControls ? ( + {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && ( - ) : null} + )} ))} - + {addIPButton}
); }); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts index 392807ae9d1..c49bf85f512 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts @@ -88,3 +88,10 @@ export const StyledScrollBox = styled(Box, { maxWidth: `${maxWidth}px`, overflow: 'auto', })); + +export const StyledItemWithPlusChip = styled('span', { + label: 'ItemWithPlusChip', +})({ + alignItems: 'center', + display: 'inline-flex', +}); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx new file mode 100644 index 00000000000..64ee4b8712b --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsListTable.tsx @@ -0,0 +1,139 @@ +import Close from '@mui/icons-material/Close'; +import * as React from 'react'; + +import { IconButton } from 'src/components/IconButton'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; + +import { TableRowEmpty } from '../TableRowEmpty/TableRowEmpty'; +import { + SelectedOptionsHeader, + StyledLabel, +} from './RemovableSelectionsList.style'; + +export type RemovableItem = { + id: number; + label: string; + // The remaining key-value pairs must have their values typed + // as 'any' because we do not know what types they could be. + // Trying to type them as 'unknown' led to type errors. +} & { [key: string]: any }; + +export interface RemovableSelectionsListTableProps { + /** + * The descriptive text to display above the list + */ + headerText: string; + /** + * If false, hide the remove button + */ + isRemovable?: boolean; + /** + * The text to display if there is no data + */ + noDataText: string; + /** + * The action to perform when a data item is clicked + */ + onRemove: (data: RemovableItem) => void; + /** + * Assumes the passed in prop is a key within the selectionData, and that the + * value of this key is a string. + * Displays the value of this key as the label of the data item, rather than data.label + */ + preferredDataLabel?: string; + /** + * The data to display in the list + */ + selectionData: RemovableItem[]; + /** + * Headers for the table containing the list of selected options + */ + tableHeaders: string[]; +} + +export const RemovableSelectionsListTable = ( + props: RemovableSelectionsListTableProps +) => { + const { + headerText, + isRemovable = true, + noDataText, + onRemove, + preferredDataLabel, + selectionData, + tableHeaders, + } = props; + + const handleOnClick = (selection: RemovableItem) => { + onRemove(selection); + }; + + const selectedOptionsJSX = + selectionData.length === 0 ? ( + + ) : ( + selectionData.map((selection) => ( + + + + {preferredDataLabel + ? selection[preferredDataLabel] + : selection.label} + + + {selection.interfaceData?.ipv4?.vpc ?? null} + + {determineNoneSingleOrMultipleWithChip( + selection.interfaceData?.ip_ranges ?? [] + )} + + + {isRemovable && ( + handleOnClick(selection)} + size="medium" + > + + + )} + + + )) + ); + + const tableHeadersJSX = tableHeaders.map((thisHeader, idx) => { + const lastHeader = idx === tableHeaders.length - 1; + + return ( + + {thisHeader} + + ); + }); + + return ( + <> + {headerText} + + + {tableHeadersJSX} + + {selectedOptionsJSX} +
+ + ); +}; diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 3b6507ec78f..95233470184 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -41,6 +41,7 @@ export * from './statusPage'; export * from './subnets'; export * from './support'; export * from './tags'; +export * from './types'; export * from './volume'; export * from './vlans'; export * from './vpcs'; diff --git a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts index 7d1a0301f8e..914551a8fa6 100644 --- a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts +++ b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts @@ -15,7 +15,7 @@ export const LinodeConfigInterfaceFactoryWithVPC = Factory.Sync.makeFactory i), - ip_ranges: ['192.0.2.0/24'], + ip_ranges: ['192.0.2.0/24', '192.0.3.0/24'], ipam_address: '10.0.0.1/24', ipv4: { nat_1_1: 'some nat', diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx new file mode 100644 index 00000000000..26eeecebc10 --- /dev/null +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { + accountSettingsFactory, + linodeFactory, + typeFactory, +} from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BackupDrawer } from './BackupDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useAllLinodesQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAllTypes: vi.fn().mockReturnValue({ + data: undefined, + }), + useTypeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useAccountSettings: vi.fn().mockReturnValue({ + data: undefined, + }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + }; +}); + +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useAllTypes: queryMocks.useAllTypes, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); + +vi.mock('src/queries/accountSettings', async () => { + const actual = await vi.importActual('src/queries/accountSettings'); + return { + ...actual, + useAccountSettings: queryMocks.useAccountSettings, + }; +}); + +describe('BackupDrawer', () => { + beforeEach(() => { + const mockType = typeFactory.build({ + id: 'mock-linode-type', + label: 'Mock Linode Type', + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + }); + queryMocks.useAccountSettings.mockReturnValue({ + data: accountSettingsFactory.build({ + backups_enabled: false, + }), + }); + queryMocks.useAllTypes.mockReturnValue({ + data: [mockType], + }); + queryMocks.useTypeQuery.mockReturnValue({ + data: mockType, + }); + }); + + describe('Total price display', () => { + it('displays total backup price', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ...linodeFactory.buildList(5, { + region: 'us-east', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 6 Linodes:')).toBeVisible(); + expect(await findByText('$12.50')).toBeVisible(); + }); + + it('displays total backup price when total is $0', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [ + linodeFactory.build({ + region: 'es-mad', + type: 'mock-linode-type', + backups: { enabled: false }, + }), + ], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + + it('displays placeholder when total backup price cannot be determined', async () => { + queryMocks.useAllTypes.mockReturnValue({ + data: undefined, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [linodeFactory.build({ backups: { enabled: false } })], + }); + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('Total for 1 Linode:')).toBeVisible(); + expect(await findByText('$--.--')).toBeVisible(); + }); + }); + + describe('Linode list', () => { + it('Only lists Linodes that do not have backups enabled', async () => { + const mockLinodesWithBackups = linodeFactory.buildList(3, { + backups: { enabled: true }, + }); + + const mockLinodesWithoutBackups = linodeFactory.buildList(3, { + backups: { enabled: false }, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [...mockLinodesWithBackups, ...mockLinodesWithoutBackups], + }); + + const { findByText, queryByText } = renderWithTheme( + + ); + // Confirm that Linodes without backups are listed in table. + /* eslint-disable no-await-in-loop */ + for (const mockLinode of mockLinodesWithoutBackups) { + expect(await findByText(mockLinode.label)).toBeVisible(); + } + // Confirm that Linodes with backups are not listed in table. + for (const mockLinode of mockLinodesWithBackups) { + expect(queryByText(mockLinode.label)).toBeNull(); + } + }); + }); +}); diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 952ebe1aa6c..67265b2c07e 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -181,9 +181,7 @@ all new Linodes will automatically be backed up.`   diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx index 68bce89c5dc..cfeb5e3cb2a 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx @@ -78,4 +78,71 @@ describe('BackupLinodeRow', () => { expect(await findByText('Jakarta, ID')).toBeVisible(); expect(await findByText('$3.57/mo')).toBeVisible(); }); + + it('should render error indicator when price cannot be determined', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res.networkError('A hypothetical network error has occurred!'); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, findByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$--.--/mo')).toBeVisible(); + expect( + await findByLabelText('There was an error loading the price.') + ).toBeVisible(); + }); + + it('should not render error indicator for $0 price', async () => { + server.use( + rest.get('*/linode/types/linode-type-test', (req, res, ctx) => { + return res( + ctx.json( + linodeTypeFactory.build({ + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'id-cgk', + monthly: 0, + }, + ], + }, + }, + label: 'Linode Test Type', + }) + ) + ); + }) + ); + + const linode = linodeFactory.build({ + label: 'my-dc-pricing-linode-to-back-up', + region: 'id-cgk', + type: 'linode-type-test', + }); + + const { findByText, queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(await findByText('$0.00/mo')).toBeVisible(); + expect( + queryByLabelText('There was an error loading the price.') + ).toBeNull(); + }); }); diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index 47d29c22619..213bea721cc 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -32,6 +32,9 @@ export const BackupLinodeRow = (props: Props) => { const regionLabel = regions?.find((r) => r.id === linode.region)?.label ?? linode.region; + const hasInvalidPrice = + backupsMonthlyPrice === null || backupsMonthlyPrice === undefined; + return ( @@ -53,8 +56,8 @@ export const BackupLinodeRow = (props: Props) => { {regionLabel ?? 'Unknown'} {`$${backupsMonthlyPrice?.toFixed(2) ?? UNKNOWN_PRICE}/mo`} 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; +}; diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index f2a3690ebe4..fcaac5bab0f 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -10,6 +10,11 @@ import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; interface LinodeSelectProps { + /** Determine whether isOptionEqualToValue prop should be defined for Autocomplete + * component (to avoid "The value provided to Autocomplete is invalid [...]" console + * errors). See https://github.com/linode/manager/pull/10089 for context & discussion. + */ + checkIsOptionEqualToValue?: boolean; /** Whether to display the clear icon. Defaults to `true`. */ clearable?: boolean; /** Disable editing the input value. */ @@ -73,6 +78,7 @@ export const LinodeSelect = ( props: LinodeMultiSelectProps | LinodeSingleSelectProps ) => { const { + checkIsOptionEqualToValue, clearable = true, disabled, errorText, @@ -116,6 +122,11 @@ export const LinodeSelect = ( getOptionLabel={(linode: Linode) => renderOptionLabel ? renderOptionLabel(linode) : linode.label } + isOptionEqualToValue={ + checkIsOptionEqualToValue + ? (option, value) => option.id === value.id + : undefined + } noOptionsText={ noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 1f7b58a7e4b..b5ac6c725c0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -62,6 +62,7 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; @@ -101,6 +102,7 @@ import { import type { Tab } from 'src/components/Tabs/TabLinkList'; export interface LinodeCreateProps { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; checkValidation: LinodeCreateValidation; @@ -108,6 +110,7 @@ export interface LinodeCreateProps { firewallId?: number; handleAgreementChange: () => void; handleFirewallChange: (firewallId: number) => void; + handleIPv4RangesForVPC: (ranges: ExtendedIP[]) => void; handleShowApiAwarenessModal: () => void; handleSubmitForm: HandleSubmit; handleSubnetChange: (subnetId: number) => void; @@ -641,9 +644,11 @@ export class LinodeCreate extends React.PureComponent< toggleAutoassignIPv4WithinVPCEnabled={ this.props.toggleAutoassignIPv4WithinVPCEnabled } + additionalIPv4RangesForVPC={this.props.additionalIPv4RangesForVPC} assignPublicIPv4Address={this.props.assignPublicIPv4Address} autoassignIPv4WithinVPC={this.props.autoassignIPv4WithinVPC} from="linodeCreate" + handleIPv4RangeChange={this.props.handleIPv4RangesForVPC} handleSelectVPC={this.props.setSelectedVPC} handleSubnetChange={this.props.handleSubnetChange} handleVPCIPv4Change={this.props.handleVPCIPv4Change} @@ -822,6 +827,9 @@ export class LinodeCreate extends React.PureComponent< this.props.selectedVPCId !== -1 ) { const vpcInterfaceData: InterfacePayload = { + ip_ranges: this.props.additionalIPv4RangesForVPC + .map((ipRange) => ipRange.address) + .filter((ipRange) => ipRange !== ''), ipam_address: null, ipv4: { nat_1_1: this.props.assignPublicIPv4Address ? 'any' : undefined, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 72ea5e286bb..a4fcfb54c08 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -66,11 +66,12 @@ import { } from 'src/utilities/analytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { ExtendedType, extendType } from 'src/utilities/extendType'; -import { isEURegion } from 'src/utilities/formatRegion'; import { getGDPRDetails, getSelectedRegionGroup, } from 'src/utilities/formatRegion'; +import { isEURegion } from 'src/utilities/formatRegion'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -93,6 +94,7 @@ import type { const DEFAULT_IMAGE = 'linode/debian11'; interface State { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; attachedVLANLabel: null | string; authorized_users: string[]; @@ -144,6 +146,7 @@ type CombinedProps = WithSnackbarProps & WithEventsPollingActionProps; const defaultState: State = { + additionalIPv4RangesForVPC: [], assignPublicIPv4Address: false, attachedVLANLabel: '', authorized_users: [], @@ -281,6 +284,7 @@ class LinodeCreateContainer extends React.PureComponent { firewallId={this.state.selectedfirewallId} handleAgreementChange={this.handleAgreementChange} handleFirewallChange={this.handleFirewallChange} + handleIPv4RangesForVPC={this.handleVPCIPv4RangesChange} handleSelectUDFs={this.setUDFs} handleShowApiAwarenessModal={this.handleShowApiAwarenessModal} handleSubmitForm={this.submitForm} @@ -519,6 +523,10 @@ class LinodeCreateContainer extends React.PureComponent { this.setState({ vpcIPv4AddressOfLinode: IPv4 }); }; + handleVPCIPv4RangesChange = (ranges: ExtendedIP[]) => { + this.setState({ additionalIPv4RangesForVPC: ranges }); + }; + params = getQueryParamsFromQueryString(this.props.location.search) as Record< string, string diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx index a203e30fee4..e5018a0679e 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.test.tsx @@ -17,9 +17,11 @@ afterEach(() => { }); const props = { + additionalIPv4RangesForVPC: [], assignPublicIPv4Address: false, autoassignIPv4WithinVPC: true, from: 'linodeCreate' as VPCPanelProps['from'], + handleIPv4RangeChange: vi.fn(), handleSelectVPC: vi.fn(), handleSubnetChange: vi.fn(), handleVPCIPv4Change: vi.fn(), @@ -105,7 +107,12 @@ describe('VPCPanel', () => { }); it('should have the VPC IPv4 auto-assign checkbox checked by default', async () => { - const _props = { ...props, region: 'us-east', selectedVPCId: 5 }; + const _props = { + ...props, + region: 'us-east', + selectedSubnetId: 2, + selectedVPCId: 5, + }; server.use( rest.get('*/regions', (req, res, ctx) => { @@ -234,6 +241,7 @@ describe('VPCPanel', () => { ...props, autoassignIPv4WithinVPC: false, region: 'us-east', + selectedSubnetId: 2, selectedVPCId: 5, vpcIPv4AddressOfLinode: '10.0.4.3', }; @@ -269,6 +277,7 @@ describe('VPCPanel', () => { ...props, assignPublicIPv4Address: true, region: 'us-east', + selectedSubnetId: 2, selectedVPCId: 5, }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index a960f2c2bff..e140925ec52 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -14,6 +14,7 @@ import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; +import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; @@ -22,15 +23,18 @@ import { useVPCsQuery } from 'src/queries/vpcs'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { VPCCreateDrawer } from './VPCCreateDrawer'; import { REGION_CAVEAT_HELPER_TEXT } from './constants'; export interface VPCPanelProps { + additionalIPv4RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; autoassignIPv4WithinVPC: boolean; from: 'linodeConfig' | 'linodeCreate'; + handleIPv4RangeChange: (ranges: ExtendedIP[]) => void; handleSelectVPC: (vpcId: number) => void; handleSubnetChange: (subnetId: number) => void; handleVPCIPv4Change: (IPv4: string) => void; @@ -41,6 +45,7 @@ export interface VPCPanelProps { subnetError?: string; toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; + vpcIPRangesError?: string; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; vpcIdError?: string; @@ -50,9 +55,11 @@ const ERROR_GROUP_STRING = 'vpc-errors'; export const VPCPanel = (props: VPCPanelProps) => { const { + additionalIPv4RangesForVPC, assignPublicIPv4Address, autoassignIPv4WithinVPC, from, + handleIPv4RangeChange, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, @@ -63,6 +70,7 @@ export const VPCPanel = (props: VPCPanelProps) => { subnetError, toggleAssignPublicIPv4Address, toggleAutoassignIPv4WithinVPCEnabled, + vpcIPRangesError, vpcIPv4AddressOfLinode, vpcIPv4Error, vpcIdError, @@ -247,87 +255,105 @@ export const VPCPanel = (props: VPCPanelProps) => { options={subnetDropdownOptions} placeholder="Select Subnet" /> - ({ - marginLeft: '2px', - paddingTop: theme.spacing(), - })} - alignItems="center" - display="flex" - flexDirection="row" - > - + ({ + marginLeft: '2px', + paddingTop: theme.spacing(), + })} + alignItems="center" + display="flex" + flexDirection="row" + > + + } + label={ + + + Auto-assign a VPC IPv4 address for this Linode in + the VPC + + + + } + data-testid="vpc-ipv4-checkbox" /> - } - label={ - - - Auto-assign a VPC IPv4 address for this Linode in the - VPC - - - - } - data-testid="vpc-ipv4-checkbox" - /> - - {!autoassignIPv4WithinVPC && ( - handleVPCIPv4Change(e.target.value)} - required={!autoassignIPv4WithinVPC} - value={vpcIPv4AddressOfLinode} - /> - )} - ({ - marginLeft: '2px', - marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, - })} - alignItems="center" - display="flex" - > - + {!autoassignIPv4WithinVPC && ( + handleVPCIPv4Change(e.target.value)} + required={!autoassignIPv4WithinVPC} + value={vpcIPv4AddressOfLinode} /> - } - label={ - - - Assign a public IPv4 address for this Linode - - - - } - /> - - {assignPublicIPv4Address && publicIPv4Error && ( - ({ - color: theme.color.red, - })} - > - {publicIPv4Error} - + )} + ({ + marginLeft: '2px', + marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, + })} + alignItems="center" + display="flex" + > + + } + label={ + + + Assign a public IPv4 address for this Linode + + + + } + /> + + {assignPublicIPv4Address && publicIPv4Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv4Error} + + )} + + )} )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx new file mode 100644 index 00000000000..29ce79397e6 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { EnableBackupsDialog } from './EnableBackupsDialog'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; +import { typeFactory } from 'src/factories/types'; +import { linodeFactory } from 'src/factories'; + +const queryMocks = vi.hoisted(() => ({ + useLinodeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), + useTypeQuery: vi.fn().mockReturnValue({ + data: undefined, + }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useLinodeQuery: queryMocks.useLinodeQuery, + }; +}); + +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); + +describe('EnableBackupsDialog component', () => { + beforeEach(() => { + queryMocks.useTypeQuery.mockReturnValue({ + data: typeFactory.build({ + id: 'mock-linode-type', + label: 'Mock Linode Type', + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + }), + }); + }); + + it('Displays the monthly backup price', async () => { + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'us-east', + }), + }); + + const { findByText } = renderWithTheme( + + ); + + // Confirm that the user is warned that they will be billed, and that the correct + // price is displayed. + expect( + await findByText( + /Are you sure you want to enable backups on this Linode\?.*/ + ) + ).toHaveTextContent(/This will add .* to your monthly bill/); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('Displays the monthly backup price when the price is $0', async () => { + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'es-mad', + }), + }); + + const { getByTestId, findByText, queryByText } = renderWithTheme( + + ); + + // Confirm that the user is warned that they will be billed, and that $0.00 + // is shown. + expect( + await findByText( + /Are you sure you want to enable backups on this Linode\?.*/ + ) + ).toHaveTextContent(/This will add .* to your monthly bill/); + expect(await findByText('$0.00')).toBeVisible(); + + // Confirm that error message is not present. + expect(queryByText(PRICES_RELOAD_ERROR_NOTICE_TEXT)).toBeNull(); + + // Confirm that "Enable Backups" button is enabled. + expect(getByTestId('confirm-enable-backups')).toBeEnabled(); + }); + + it('Displays an error when backup price cannot be determined', async () => { + queryMocks.useTypeQuery.mockReturnValue({ + data: undefined, + }); + + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 1, + label: 'Mock Linode', + type: 'mock-linode-type', + region: 'es-mad', + }), + }); + + const { getByTestId, findByText } = renderWithTheme( + + ); + + // Confirm that error message is not present. + expect(await findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT)).toBeVisible(); + + // Confirm that "Enable Backups" button is disabled. + expect(getByTestId('confirm-enable-backups')).toHaveAttribute( + 'aria-disabled' + ); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 327aa57580e..9c28b43666f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -47,6 +47,9 @@ export const EnableBackupsDialog = (props: Props) => { type, }); + const hasBackupsMonthlyPriceError = + !backupsMonthlyPrice && backupsMonthlyPrice !== 0; + const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); @@ -70,7 +73,7 @@ export const EnableBackupsDialog = (props: Props) => { { open={open} title="Enable backups?" > - {backupsMonthlyPrice ? ( + {!hasBackupsMonthlyPriceError ? ( Are you sure you want to enable backups on this Linode?{` `} This will add diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index fe5447fe110..072c1bfeeba 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -38,8 +38,6 @@ import { NATTED_PUBLIC_IP_HELPER_TEXT, NOT_NATTED_HELPER_TEXT, } from 'src/features/VPCs/constants'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { useLinodeConfigCreateMutation, useLinodeConfigUpdateMutation, @@ -53,7 +51,6 @@ import { useRegionsQuery } from 'src/queries/regions'; import { queryKey as vlansQueryKey } from 'src/queries/vlans'; import { useAllVolumesQuery } from 'src/queries/volumes'; import { vpcQueryKey } from 'src/queries/vpcs'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { DevicesAsStrings, createDevicesFromStrings, @@ -64,6 +61,7 @@ import { handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { @@ -116,6 +114,7 @@ interface Props { } const defaultInterface = { + ip_ranges: [], ipam_address: '', label: '', purpose: 'none', @@ -183,6 +182,7 @@ const interfacesToState = (interfaces?: Interface[]) => { const interfacesPayload = interfaces.map( ({ id, + ip_ranges, ipam_address, ipv4, label, @@ -192,6 +192,7 @@ const interfacesToState = (interfaces?: Interface[]) => { vpc_id, }) => ({ id, + ip_ranges, ipam_address, ipv4, label, @@ -262,8 +263,6 @@ export const LinodeConfigDialog = (props: Props) => { ); const theme = useTheme(); - const flags = useFlags(); - const { account } = useAccountManagement(); const regions = useRegionsQuery().data ?? []; @@ -291,13 +290,6 @@ export const LinodeConfigDialog = (props: Props) => { thisRegion.capabilities.includes('VPCs') ); - // @TODO VPC: remove once VPC is fully rolled out - const vpcEnabled = isFeatureEnabled( - 'VPCs', - Boolean(flags.vpc), - account?.capabilities ?? [] - ); - const { resetForm, setFieldValue, values, ...formik } = useFormik({ initialValues: defaultFieldsValues, onSubmit: (values) => onSubmit(values), @@ -493,7 +485,7 @@ export const LinodeConfigDialog = (props: Props) => { (_interface) => _interface.primary === true ); - if (vpcEnabled && indexOfExistingPrimaryInterface !== -1) { + if (indexOfExistingPrimaryInterface !== -1) { setPrimaryInterfaceIndex(indexOfExistingPrimaryInterface); } @@ -523,7 +515,7 @@ export const LinodeConfigDialog = (props: Props) => { setPrimaryInterfaceIndex(0); } } - }, [open, config, initrdFromConfig, resetForm, queryClient, vpcEnabled]); + }, [open, config, initrdFromConfig, resetForm, queryClient]); const generalError = formik.status?.generalError; @@ -667,12 +659,9 @@ export const LinodeConfigDialog = (props: Props) => { const networkInterfacesHelperText = ( - Configure the network that a selected interface will connect to ( - {vpcEnabled - ? '"Public Internet", VLAN, or VPC' - : 'either "Public Internet" or a VLAN'} - ) . Each Linode can have up to three Network Interfaces. For more - information, see our{' '} + Configure the network that a selected interface will connect to + "Public Internet", VLAN, or VPC. Each Linode can have up to + three Network Interfaces. For more information, see our{' '} Network Interfaces guide @@ -951,9 +940,7 @@ export const LinodeConfigDialog = (props: Props) => { - - {vpcEnabled ? 'Networking' : 'Network Interfaces'} - + Networking { variant="error" /> )} - {vpcEnabled && ( - <> - + + {values.interfaces.map((thisInterface, idx) => { + const thisInterfaceIPRanges: ExtendedIP[] = ( + thisInterface.ip_ranges ?? [] + ).map((ip_range, index) => { + // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid" + const errorString: string = formik.errors[ + `interfaces[${idx}].ip_ranges[${index}]` + ]?.includes('is invalid') + ? 'Invalid IP range' + : formik.errors[`interfaces[${idx}].ip_ranges[${index}]`]; + + return { + address: ip_range, + error: errorString, + }; + }); + return ( {unrecommendedConfigNoticeSelector({ @@ -1005,6 +1006,8 @@ export const LinodeConfigDialog = (props: Props) => { })} { handleChange={(newInterface: Interface) => handleInterfaceChange(idx, newInterface) } + additionalIPv4RangesForVPC={thisInterfaceIPRanges} ipamAddress={thisInterface.ipam_address} key={`eth${idx}-interface`} label={thisInterface.label} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index 312889cd1d5..45cb4a2f974 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -1,8 +1,12 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; +import { LinodeConfigInterfaceFactoryWithVPC } from 'src/factories/linodeConfigInterfaceFactory'; import { linodeIPFactory } from 'src/factories/linodes'; -import { ipResponseToDisplayRows } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses'; +import { + ipResponseToDisplayRows, + vpcConfigInterfaceToDisplayRows, +} from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses'; import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; @@ -10,6 +14,9 @@ import { IPAddressRowHandlers, LinodeIPAddressRow } from './LinodeIPAddressRow'; const ips = linodeIPFactory.build(); const ipDisplay = ipResponseToDisplayRows(ips)[0]; +const ipDisplayVPC = vpcConfigInterfaceToDisplayRows( + LinodeConfigInterfaceFactoryWithVPC.build() +)[0]; const handlers: IPAddressRowHandlers = { handleOpenEditRDNS: vi.fn(), @@ -42,6 +49,25 @@ describe('LinodeIPAddressRow', () => { getAllByText('Delete'); getAllByText('Edit RDNS'); }); + it('should render a VPC IP Address row', () => { + const { getAllByText, queryByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + getAllByText(ipDisplayVPC.address); + getAllByText(ipDisplayVPC.type); + // No actions should be rendered + expect(queryByText('Delete')).not.toBeInTheDocument(); + expect(queryByText('Edit RDNS')).not.toBeInTheDocument(); + }); it('should disable the row if disabled is true and display a tooltip', async () => { const { findByRole, getByTestId } = renderWithTheme( @@ -96,3 +122,19 @@ describe('LinodeIPAddressRow', () => { expect(editRDNSBtn).not.toHaveAttribute('aria-disabled', 'true'); }); }); + +describe('ipResponseToDisplayRows', () => { + it('should not return a Public IPv4 row if there is a VPC interface with 1:1 NAT', () => { + const ipDisplays = ipResponseToDisplayRows( + ips, + LinodeConfigInterfaceFactoryWithVPC.build() + ); + + expect( + ipDisplays.find((ipDisplay) => ipDisplay.type === 'IPv4 – Public') + ).toBeUndefined(); + expect( + ipDisplays.find((ipDisplay) => ipDisplay.type === 'VPC IPv4 – NAT') + ).toBeDefined(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index 853e6e1ffe6..5d768cda4b9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -72,7 +72,11 @@ export const LinodeIPAddressRow = (props: CombinedProps) => { {!isVPCOnlyLinode && }
- + {type} {gateway} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx index fa3a1b83735..39002b778c5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx @@ -1,4 +1,4 @@ -import { LinodeIPsResponse } from '@linode/api-v4/lib/linodes'; +import { Interface, LinodeIPsResponse } from '@linode/api-v4/lib/linodes'; import { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; @@ -52,7 +52,9 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { const { data: ips, error, isLoading } = useLinodeIPsQuery(linodeID); const readOnly = getPermissionsForLinode(grants, linodeID) === 'read_only'; - const { isVPCOnlyLinode } = useVPCConfigInterface(linodeID); + const { configInterfaceWithVPC, isVPCOnlyLinode } = useVPCConfigInterface( + linodeID + ); const [selectedIP, setSelectedIP] = React.useState(); const [selectedRange, setSelectedRange] = React.useState(); @@ -119,7 +121,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { } const renderIPTable = () => { - const ipDisplay = ipResponseToDisplayRows(ips); + const ipDisplay = ipResponseToDisplayRows(ips, configInterfaceWithVPC); return (
@@ -192,7 +194,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { isVPCOnlyLinode={ isVPCOnlyLinode && ipDisplay.type === 'IPv4 – Public' } - key={ipDisplay.address} + key={`${ipDisplay.address}-${ipDisplay.type}`} linodeId={linodeID} readOnly={readOnly} /> @@ -286,9 +288,51 @@ export interface IPDisplay { type: IPTypes; } +export const vpcConfigInterfaceToDisplayRows = ( + configInterfaceWithVPC: Interface +) => { + const ipDisplay: IPDisplay[] = []; + + const { ip_ranges, ipv4 } = configInterfaceWithVPC; + const emptyProps = { + gateway: '', + rdns: '', + subnetMask: '', + }; + + if (ipv4?.vpc) { + ipDisplay.push({ + address: ipv4.vpc, + type: 'IPv4 – VPC', + ...emptyProps, + }); + } + + if (ipv4?.nat_1_1) { + ipDisplay.push({ + address: ipv4.nat_1_1, + type: 'VPC IPv4 – NAT', + ...emptyProps, + }); + } + + if (ip_ranges) { + ip_ranges.forEach((ip_range) => { + ipDisplay.push({ + address: ip_range, + type: 'IPv4 – VPC – Range', + ...emptyProps, + }); + }); + } + + return ipDisplay; +}; + // Takes an IP Response object and returns high-level IP display rows. export const ipResponseToDisplayRows = ( - ipResponse?: LinodeIPsResponse + ipResponse?: LinodeIPsResponse, + configInterfaceWithVPC?: Interface ): IPDisplay[] => { if (!ipResponse) { return []; @@ -311,6 +355,14 @@ export const ipResponseToDisplayRows = ( ipDisplay.push(ipToDisplay(ipv6?.link_local, 'Link Local')); } + if (configInterfaceWithVPC) { + if (configInterfaceWithVPC.ipv4?.nat_1_1) { + // If there is a VPC interface with 1:1 NAT, hide the Public IPv4 IP address row + ipDisplay.shift(); + } + ipDisplay.push(...vpcConfigInterfaceToDisplayRows(configInterfaceWithVPC)); + } + // IPv6 ranges and pools to display in the networking table ipDisplay.push( ...[...(ipv6 ? ipv6.global : [])].map((thisIP) => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index efc84beeeef..b9be41758e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -37,11 +37,14 @@ export const LinodeNetworkingActionMenu = (props: Props) => { readOnly, } = props; - const showEdit = - ipType !== 'IPv4 – Private' && - ipType !== 'IPv6 – Link Local' && - ipType !== 'IPv4 – Reserved (public)' && - ipType !== 'IPv4 – Reserved (private)'; + const showEdit = ![ + 'IPv4 – Private', + 'IPv4 – Reserved (private)', + 'IPv4 – Reserved (public)', + 'IPv4 – VPC', + 'IPv6 – Link Local', + 'VPC IPv4 – NAT', + ].includes(ipType); const deletableIPTypes = ['IPv4 – Public', 'IPv4 – Private', 'IPv6 – Range']; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts index e10a2366d15..f12c35c7c2a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts @@ -4,6 +4,9 @@ export type IPTypes = | 'IPv4 – Reserved (private)' | 'IPv4 – Reserved (public)' | 'IPv4 – Shared' + | 'IPv4 – VPC – Range' + | 'IPv4 – VPC' | 'IPv6 – Link Local' | 'IPv6 – Range' - | 'IPv6 – SLAAC'; + | 'IPv6 – SLAAC' + | 'VPC IPv4 – NAT'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 6a299c39682..988041f6b8e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -18,8 +18,10 @@ import { useAccount } from 'src/queries/account'; import { useVlansQuery } from 'src/queries/vlans'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics'; +import { ExtendedIP } from 'src/utilities/ipUtils'; interface Props { + additionalIPv4RangesForVPC?: ExtendedIP[]; errors: VPCInterfaceErrors & OtherInterfaceErrors; fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; @@ -33,6 +35,7 @@ interface Props { slotNumber: number; } interface VPCInterfaceErrors { + ipRangeError?: string; labelError?: string; publicIPv4Error?: string; subnetError?: string; @@ -63,6 +66,7 @@ type CombinedProps = Props & VPCState; export const InterfaceSelect = (props: CombinedProps) => { const { + additionalIPv4RangesForVPC, errors, fromAddonsPanel, handleChange, @@ -118,6 +122,9 @@ export const InterfaceSelect = (props: CombinedProps) => { const [autoAssignLinodeIPv4, setAutoAssignLinodeIPv4] = React.useState( Boolean(nattedIPv4Address) ); + const _additionalIPv4RangesForVPC = additionalIPv4RangesForVPC?.map( + (ip_range) => ip_range.address + ); const handlePurposeChange = (selected: Item) => { const purpose = selected.value; @@ -142,6 +149,7 @@ export const InterfaceSelect = (props: CombinedProps) => { // Only clear VPC related fields if VPC selection changes if (selectedVPCId !== vpcId) { handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, ipv4: { nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, @@ -155,8 +163,22 @@ export const InterfaceSelect = (props: CombinedProps) => { } }; + const handleIPv4RangeChange = (ipv4Ranges: ExtendedIP[]) => { + const changeObj = { + ip_ranges: ipv4Ranges.map((ip_range) => ip_range.address), + ipam_address: null, + label: null, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + + handleChange(changeObj); + }; + const handleSubnetChange = (selectedSubnetId: number) => handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, ipv4: { nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, @@ -170,6 +192,7 @@ export const InterfaceSelect = (props: CombinedProps) => { const handleVPCIPv4Input = (vpcIPv4Input: string) => { const changeObj = { + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, label: null, purpose, @@ -204,6 +227,7 @@ export const InterfaceSelect = (props: CombinedProps) => { } const changeObj = { + ip_ranges: _additionalIPv4RangesForVPC, ipam_address: null, label: null, purpose, @@ -386,9 +410,11 @@ export const InterfaceSelect = (props: CombinedProps) => { toggleAutoassignIPv4WithinVPCEnabled={() => setAutoAssignVPCIPv4((autoAssignVPCIPv4) => !autoAssignVPCIPv4) } + additionalIPv4RangesForVPC={additionalIPv4RangesForVPC ?? []} assignPublicIPv4Address={autoAssignLinodeIPv4} autoassignIPv4WithinVPC={autoAssignVPCIPv4} from="linodeConfig" + handleIPv4RangeChange={handleIPv4RangeChange} handleSelectVPC={handleVPCLabelChange} handleSubnetChange={handleSubnetChange} handleVPCIPv4Change={handleVPCIPv4Input} @@ -397,6 +423,7 @@ export const InterfaceSelect = (props: CombinedProps) => { selectedSubnetId={subnetId} selectedVPCId={vpcId} subnetError={errors.subnetError} + vpcIPRangesError={errors.ipRangeError} vpcIPv4AddressOfLinode={vpcIPv4} vpcIPv4Error={errors.vpcIPv4Error} vpcIdError={errors.vpcError} diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx new file mode 100644 index 00000000000..c632327861d --- /dev/null +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.test.tsx @@ -0,0 +1,169 @@ +import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { MigrationPricing } from './MigrationPricing'; + +const backupPricesNull = { + monthly: null, + hourly: null, +}; + +const backupPricesZero = { + monthly: 0, + hourly: 0, +}; + +const backupPricesRegular = { + hourly: 0.004, + monthly: 2.5, +}; + +describe('MigrationPricing component', () => { + describe('render condition', () => { + it('does not render when prices are not specified', async () => { + // Some combinations of props that should prevent component rendering. + const propCombinations = [ + { backups: 'disabled' as const, hourly: undefined, monthly: 0 }, + { backups: backupPricesNull, hourly: null, monthly: 0.1 }, + { backups: backupPricesRegular, hourly: null, monthly: null }, + { backups: backupPricesZero, hourly: undefined, monthly: 1 }, + { backups: undefined, hourly: 0, monthly: 0 }, + { backups: undefined, hourly: 1, monthly: undefined }, + { backups: undefined, hourly: null, monthly: null }, + ]; + + propCombinations.forEach((props) => { + const { queryByTestId, unmount } = renderWithTheme( + + ); + expect(queryByTestId('migration-pricing')).toBeNull(); + unmount(); + }); + }); + + it('renders when prices are specified', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0.004, + monthly: 2.5, + }; + + const { findByTestId } = renderWithTheme( + + ); + expect(await findByTestId('migration-pricing')).not.toBeNull(); + }); + + it('renders when $0 prices are specified', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0, + monthly: 0, + }; + + const { findByTestId } = renderWithTheme( + + ); + expect(await findByTestId('migration-pricing')).not.toBeNull(); + }); + }); + + describe('price display', () => { + it('displays prices', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0.004, + monthly: 2.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('$0.004')).toBeVisible(); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('displays $0 prices', async () => { + const props = { + backups: 'disabled' as const, + hourly: 0, + monthly: 0, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('$0.000')).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + }); + + describe('backup price display', () => { + it('shows backup prices', async () => { + const props = { + backups: backupPricesRegular, + hourly: 0.001, + monthly: 1.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('| Backups', { exact: false })).toBeVisible(); + expect(await findByText('$2.50')).toBeVisible(); + }); + + it('shows $0 backup prices', async () => { + const props = { + backups: backupPricesZero, + hourly: 0.001, + monthly: 1.5, + }; + + const { findByText } = renderWithTheme( + + ); + expect(await findByText('| Backups', { exact: false })).toBeVisible(); + expect(await findByText('$0.00')).toBeVisible(); + }); + + it('hides backup prices when backups are disabled', () => { + const props = { + backups: 'disabled' as const, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + + it('hides backup prices when backups are undefined', () => { + const props = { + backups: undefined, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + + it('hides backup prices when backup prices are null', () => { + const props = { + backups: backupPricesNull, + hourly: 0.001, + monthly: 1.5, + }; + + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('| Backups', { exact: false })).toBeNull(); + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx index fbc7480a4ab..8a6a8172858 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx @@ -1,6 +1,7 @@ import { PriceObject } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import { useTheme } from '@mui/material'; +import { isNumber } from 'lodash'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -24,8 +25,17 @@ export const MigrationPricing = (props: MigrationPricingProps) => { const theme = useTheme(); const priceFontSize = `${theme.typography.body1.fontSize}`; - return monthly && hourly && backups ? ( - + const shouldShowPrice = + isNumber(monthly) && isNumber(hourly) && backups !== undefined; + + const shouldShowBackupsPrice = + backups && backups !== 'disabled' && backups.monthly !== null; + + return shouldShowPrice ? ( + {currentPanel ? 'Current' : 'New'} Price { interval="hour" price={hourly} /> - {backups !== 'disabled' && backups?.monthly && ( + {shouldShowBackupsPrice && ( <>   @@ -53,7 +63,7 @@ export const MigrationPricing = (props: MigrationPricingProps) => { )} diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx new file mode 100644 index 00000000000..ca8c7f472c1 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.test.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { + ASSIGN_IPV4_RANGES_DESCRIPTION, + ASSIGN_IPV4_RANGES_TITLE, +} from 'src/features/VPCs/constants'; +import { ExtendedIP } from 'src/utilities/ipUtils'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AssignIPRanges } from './AssignIPRanges'; + +describe('AssignIPRanges', () => { + const handleIPRangeChangeMock = vi.fn(); + const ipRanges: ExtendedIP[] = []; + const ipRangesError = 'Error message'; + + afterEach(() => { + handleIPRangeChangeMock.mockClear(); + }); + + it('renders component with title and description', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText(ASSIGN_IPV4_RANGES_TITLE)).toBeInTheDocument(); + expect(getByText(ASSIGN_IPV4_RANGES_DESCRIPTION)).toBeInTheDocument(); + }); + + it('renders error notice if ipRangesError is provided', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Error message')).toBeInTheDocument(); + }); + + it('calls handleIPRangeChange when input value changes', async () => { + const { getByText } = renderWithTheme( + + ); + + const button = getByText('Add IPv4 Range'); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx new file mode 100644 index 00000000000..5e79f07e3a2 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -0,0 +1,86 @@ +import { styled, useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; +import { Link } from 'src/components/Link'; +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { Notice } from 'src/components/Notice/Notice'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { + ASSIGN_IPV4_RANGES_DESCRIPTION, + ASSIGN_IPV4_RANGES_TITLE, + UNDERSTANDING_IP_ADDRESSES_LINK, +} from 'src/features/VPCs/constants'; +import { ExtendedIP } from 'src/utilities/ipUtils'; + +import type { SxProps } from '@mui/material/styles'; + +interface Props { + handleIPRangeChange: (ips: ExtendedIP[]) => void; + includeDescriptionInTooltip?: boolean; + ipRanges: ExtendedIP[]; + ipRangesError?: string; + sx?: SxProps; +} + +export const AssignIPRanges = (props: Props) => { + const { + handleIPRangeChange, + includeDescriptionInTooltip, + ipRanges, + ipRangesError, + sx, + } = props; + + const theme = useTheme(); + + return ( + <> + + {ipRangesError && } + + + {ASSIGN_IPV4_RANGES_TITLE} + + {includeDescriptionInTooltip ? ( + + ) : ( + {IPv4RangesDescriptionJSX} + )} + + + + ); +}; + +const StyledDescription = styled('span')(() => ({ + marginRight: '5px', +})); + +const IPv4RangesDescriptionJSX = ( + <> + {ASSIGN_IPV4_RANGES_DESCRIPTION} + Learn more. + +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index 2250290b5df..e4174419378 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -1,11 +1,22 @@ import { Subnet } from '@linode/api-v4'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { QueryClient } from 'react-query'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; +const queryClient = new QueryClient(); + +beforeAll(() => mockMatchMedia()); +afterEach(() => { + queryClient.clear(); +}); + const props = { onClose: vi.fn(), open: true, @@ -15,13 +26,27 @@ const props = { label: 'subnet-1', } as Subnet, vpcId: 1, - vpcRegion: '', + vpcRegion: 'us-east', }; describe('Subnet Assign Linodes Drawer', () => { + const linode = linodeFactory.build({ + label: 'this-linode', + region: props.vpcRegion, + }); + + server.use( + rest.get('*/linode/instances', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([linode]))); + }) + ); + it('should render a subnet assign linodes drawer', () => { - const { getByText, queryByText } = renderWithTheme( - + const { getByText, queryAllByText } = renderWithTheme( + , + { + queryClient, + } ); const header = getByText( @@ -36,14 +61,9 @@ describe('Subnet Assign Linodes Drawer', () => { `Select the Linodes you would like to assign to this subnet. Only Linodes in this VPC's region are displayed.` ); expect(helperText).toBeVisible(); - const linodeSelect = getByText('Linodes'); + const linodeSelect = queryAllByText('Linode')[0]; expect(linodeSelect).toBeVisible(); - const checkbox = getByText( - 'Auto-assign a VPC IPv4 address for this Linode' - ); - expect(checkbox).toBeVisible(); - const ipv4Textbox = queryByText('VPC IPv4'); - expect(ipv4Textbox).toBeNull(); + const assignButton = getByText('Assign Linode'); expect(assignButton).toBeVisible(); const alreadyAssigned = getByText('Linodes Assigned to Subnet (0)'); @@ -52,19 +72,26 @@ describe('Subnet Assign Linodes Drawer', () => { expect(doneButton).toBeVisible(); }); - it('should show the IPv4 textbox when the checkmark is clicked', () => { - const { getByText } = renderWithTheme( - + it.skip('should show the IPv4 textbox when the checkmark is clicked', async () => { + const { findByText, getByLabelText } = renderWithTheme( + , + { + queryClient, + } ); - const checkbox = getByText( + const selectField = getByLabelText('Linode'); + fireEvent.change(selectField, { target: { value: 'this-linode' } }); + + const checkbox = await findByText( 'Auto-assign a VPC IPv4 address for this Linode' ); - expect(checkbox).toBeVisible(); + + await waitFor(() => expect(checkbox).toBeVisible()); fireEvent.click(checkbox); - const ipv4Textbox = getByText('VPC IPv4'); - expect(ipv4Textbox).toBeVisible(); + const ipv4Textbox = await findByText('VPC IPv4'); + await waitFor(() => expect(ipv4Textbox).toBeVisible()); }); it('should close the drawer', () => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 1e9b3d35fa3..16a2eacc7b9 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -1,4 +1,5 @@ import { appendConfigInterface } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -12,10 +13,11 @@ import { FormControlLabel } from 'src/components/FormControlLabel'; import { FormHelperText } from 'src/components/FormHelperText'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; -import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { defaultPublicInterface } from 'src/features/Linodes/LinodesCreate/LinodeCreate'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, @@ -27,6 +29,7 @@ import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; import { useGrants, useProfile } from 'src/queries/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; +import { ExtendedIP } from 'src/utilities/ipUtils'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import { @@ -34,19 +37,20 @@ import { MULTIPLE_CONFIGURATIONS_MESSAGE, REGIONAL_LINODE_MESSAGE, } from '../constants'; +import { AssignIPRanges } from './AssignIPRanges'; import { StyledButtonBox } from './SubnetAssignLinodesDrawer.styles'; import type { APIError, Config, + Interface, InterfacePayload, Linode, Subnet, } from '@linode/api-v4'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; // @TODO VPC: if all subnet action menu item related components use (most of) this as their props, might be worth -// putting this in a common file and naming it something like SubnetActionMenuItemProps or somthing +// putting this in a common file and naming it something like SubnetActionMenuItemProps or something interface SubnetAssignLinodesDrawerProps { onClose: () => void; open: boolean; @@ -57,7 +61,7 @@ interface SubnetAssignLinodesDrawerProps { type LinodeAndConfigData = Linode & { configId: number; - interfaceId: number; + interfaceData: Interface | undefined; linodeConfigLabel: string; }; @@ -72,9 +76,10 @@ export const SubnetAssignLinodesDrawer = ( unassignLinodesErrors, } = useUnassignLinode(); const csvRef = React.useRef(); - const newInterfaceId = React.useRef(-1); + const newInterface = React.useRef(); const removedLinodeId = React.useRef(-1); const formattedDate = useFormattedDate(); + const theme = useTheme(); const [assignLinodesErrors, setAssignLinodesErrors] = React.useState< Record @@ -128,7 +133,7 @@ export const SubnetAssignLinodesDrawer = ( // Moved the list of linodes that are currently assignable to a subnet into a state variable (linodeOptionsToAssign) // and update that list whenever this subnet or the list of all linodes in this subnet's region changes. This takes - // care of the MUI invalid value warning that was occuring before in the Linodes autocomplete [M3-6752] + // care of the MUI invalid value warning that was occurring before in the Linodes autocomplete [M3-6752] React.useEffect(() => { if (linodes) { setLinodeOptionsToAssign(findUnassignedLinodes() ?? []); @@ -145,7 +150,7 @@ export const SubnetAssignLinodesDrawer = ( } const handleAssignLinode = async () => { - const { chosenIP, selectedConfig, selectedLinode } = values; + const { chosenIP, ipRanges, selectedConfig, selectedLinode } = values; const configId = getConfigId(linodeConfigs, selectedConfig); @@ -154,6 +159,9 @@ export const SubnetAssignLinodesDrawer = ( ); const interfacePayload: InterfacePayload = { + ip_ranges: ipRanges + .map((ipRange) => ipRange.address) + .filter((ipRange) => ipRange !== ''), ipam_address: null, ipv4: { nat_1_1: 'any', // 'any' in all cases here to help the user towards a functional configuration & hide complexity per stakeholder feedback @@ -177,7 +185,7 @@ export const SubnetAssignLinodesDrawer = ( ); } - const newInterface = await appendConfigInterface( + const _newInterface = await appendConfigInterface( selectedLinode?.id ?? -1, configId, interfacePayload @@ -185,8 +193,8 @@ export const SubnetAssignLinodesDrawer = ( // We're storing this in a ref to access this later in order // to update `assignedLinodesAndConfigData` with the new - // interfaceId without causing a re-render - newInterfaceId.current = newInterface.id; + // interface data without causing a re-render + newInterface.current = _newInterface; await invalidateQueries({ configId, @@ -195,7 +203,32 @@ export const SubnetAssignLinodesDrawer = ( vpcId, }); } catch (errors) { - const errorMap = getErrorMap(['ipv4.vpc'], errors); + const fieldsOfIPRangesErrors = errors.reduce( + (accum: any, _err: { field: string }) => { + if (_err.field && _err.field.includes('ip_ranges[')) { + return [...accum, _err.field]; + } else { + return [...accum]; + } + }, + [] + ); + + const errorMap = getErrorMap( + [...fieldsOfIPRangesErrors, 'ipv4.vpc', 'ip_ranges'], + errors + ); + + const ipRangesWithErrors = ipRanges.map((ipRange, idx) => { + const errorForThisIdx = errorMap[`ip_ranges[${idx}]`]; + return { + address: ipRange.address, + error: errorForThisIdx, + }; + }); + + setFieldValue('ipRanges', ipRangesWithErrors); + const errorMessage = determineErrorMessage(configId, errorMap); setAssignLinodesErrors({ ...errorMap, none: errorMessage }); @@ -203,12 +236,12 @@ export const SubnetAssignLinodesDrawer = ( }; const handleUnassignLinode = async (data: LinodeAndConfigData) => { - const { configId, id: linodeId, interfaceId } = data; + const { configId, id: linodeId, interfaceData } = data; removedLinodeId.current = linodeId; try { await unassignLinode({ configId, - interfaceId, + interfaceId: interfaceData?.id ?? -1, linodeId, subnetId: subnet?.id ?? -1, vpcId, @@ -244,6 +277,7 @@ export const SubnetAssignLinodesDrawer = ( enableReinitialize: true, initialValues: { chosenIP: '', + ipRanges: [] as ExtendedIP[], selectedConfig: null as Config | null, selectedLinode: null as Linode | null, }, @@ -252,6 +286,13 @@ export const SubnetAssignLinodesDrawer = ( validateOnChange: false, }); + const handleIPRangeChange = React.useCallback( + (_ipRanges: ExtendedIP[]) => { + setFieldValue('ipRanges', _ipRanges); + }, + [setFieldValue] + ); + React.useEffect(() => { // Return early if no Linode is selected if (!values.selectedLinode) { @@ -270,7 +311,7 @@ export const SubnetAssignLinodesDrawer = ( const newLinodeData = { ...values.selectedLinode, configId, - interfaceId: newInterfaceId.current, + interfaceData: newInterface?.current, // Create a label that combines Linode label and configuration label (if available) linodeConfigLabel: `${values.selectedLinode.label}${ values.selectedConfig?.label @@ -290,6 +331,7 @@ export const SubnetAssignLinodesDrawer = ( setLinodeConfigs([]); setValues({ chosenIP: '', + ipRanges: [], selectedConfig: null, selectedLinode: null, }); @@ -297,6 +339,7 @@ export const SubnetAssignLinodesDrawer = ( }, [ subnet, assignedLinodesAndConfigData, + values.ipRanges, values.selectedLinode, values.selectedConfig, linodeConfigs, @@ -390,66 +433,90 @@ export const SubnetAssignLinodesDrawer = ( setFieldValue('selectedLinode', selected); setAssignLinodesErrors({}); }} + checkIsOptionEqualToValue disabled={userCannotAssignLinodes} - label={'Linodes'} + label="Linode" // We only want to be able to assign linodes that were not already assigned to this subnet options={linodeOptionsToAssign} - placeholder="Select Linodes or type to search" + placeholder="Select Linode or type to search" sx={{ marginBottom: '8px' }} value={values.selectedLinode?.id || null} /> - - - } - label={ - - Auto-assign a VPC IPv4 address for this Linode - - } - data-testid="vpc-ipv4-checkbox" - disabled={userCannotAssignLinodes} - sx={{ marginRight: 0 }} - /> - - - {!autoAssignIPv4 && ( - { - setFieldValue('chosenIP', e.target.value); - setAssignLinodesErrors({}); - }} - disabled={userCannotAssignLinodes} - errorText={assignLinodesErrors['ipv4.vpc']} - label={'VPC IPv4'} - sx={{ marginBottom: '8px' }} - value={values.chosenIP} - /> - )} - {linodeConfigs.length > 1 && ( + {values.selectedLinode?.id && ( <> - - {MULTIPLE_CONFIGURATIONS_MESSAGE}{' '} - - Learn more - - . - - { - setFieldValue('selectedConfig', value); - setAssignLinodesErrors({}); - }} - disabled={userCannotAssignLinodes} - label={'Configuration profile'} - options={linodeConfigs} - placeholder="Select a configuration profile" - value={values.selectedConfig || null} - /> + + + } + label={ + + Auto-assign a VPC IPv4 address for this Linode + + } + data-testid="vpc-ipv4-checkbox" + disabled={userCannotAssignLinodes} + sx={{ marginRight: 0 }} + /> + + + {!autoAssignIPv4 && ( + { + setFieldValue('chosenIP', e.target.value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + errorText={assignLinodesErrors['ipv4.vpc']} + label="VPC IPv4" + sx={{ marginBottom: '8px' }} + value={values.chosenIP} + /> + )} + {linodeConfigs.length > 1 && ( + <> + + {MULTIPLE_CONFIGURATIONS_MESSAGE}{' '} + + Learn more + + . + + { + setFieldValue('selectedConfig', value); + setAssignLinodesErrors({}); + }} + disabled={userCannotAssignLinodes} + label={'Configuration profile'} + options={linodeConfigs} + placeholder="Select a configuration profile" + value={values.selectedConfig || null} + /> + + )} + {/* Display the 'Assign additional IPv4 ranges' section if + the Configuration Profile section has been populated, or + if it doesn't display b/c the linode has a single config + */} + {((linodeConfigs.length > 1 && values.selectedConfig) || + linodeConfigs.length === 1) && ( + 1 + ? theme.spacing(2) + : theme.spacing(), + }} + handleIPRangeChange={handleIPRangeChange} + ipRanges={values.ipRanges} + ipRangesError={assignLinodesErrors['ip_ranges']} + /> + )} )} @@ -477,7 +544,7 @@ export const SubnetAssignLinodesDrawer = ( /> )) : null} - { handleUnassignLinode(data as LinodeAndConfigData); setUnassignLinodesErrors([]); @@ -486,6 +553,7 @@ export const SubnetAssignLinodesDrawer = ( noDataText={'No Linodes have been assigned.'} preferredDataLabel="linodeConfigLabel" selectionData={assignedLinodesAndConfigData} + tableHeaders={['Linode', 'VPC IPv4', 'VPC IPv4 Ranges']} /> {assignedLinodesAndConfigData.length > 0 && ( { const handleUnassignLinode = vi.fn(); - it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, and Reboot and Unassign buttons', async () => { + it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, IPv4 chip, and Reboot and Unassign buttons', async () => { const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' }); server.use( rest.get('*/instances/*/configs', async (req, res, ctx) => { @@ -100,11 +100,15 @@ describe('SubnetLinodeRow', () => { getAllByText('10.0.0.0'); getByText(mockFirewall0); - const rebootLinodeButton = getAllByRole('button')[1]; + const plusChipButton = getAllByRole('button')[1]; + expect(plusChipButton).toHaveTextContent('+1'); + + const rebootLinodeButton = getAllByRole('button')[2]; expect(rebootLinodeButton).toHaveTextContent('Reboot'); fireEvent.click(rebootLinodeButton); expect(handlePowerActionsLinode).toHaveBeenCalled(); - const unassignLinodeButton = getAllByRole('button')[2]; + + const unassignLinodeButton = getAllByRole('button')[3]; expect(unassignLinodeButton).toHaveTextContent('Unassign Linode'); fireEvent.click(unassignLinodeButton); expect(handleUnassignLinode).toHaveBeenCalled(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 43977566633..be9474d2da9 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -22,6 +22,7 @@ import { useLinodeQuery, } from 'src/queries/linodes/linodes'; import { capitalizeAllWords } from 'src/utilities/capitalize'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; import { NETWORK_INTERFACES_GUIDE_URL, @@ -204,6 +205,16 @@ export const SubnetLinodeRow = (props: Props) => { )} + + + {getIPRangesCellContents( + configs ?? [], + configsLoading, + subnetId, + configsError ?? undefined + )} + + {getFirewallsCellString( @@ -294,6 +305,30 @@ const getIPv4Link = (configInterface: Interface | undefined): JSX.Element => { ); }; +const getIPRangesCellContents = ( + configs: Config[], + loading: boolean, + subnetId: number, + error?: APIError[] +): JSX.Element | string => { + if (loading) { + return 'Loading...'; + } + + if (error) { + return 'Error retrieving VPC IPv4s'; + } + + if (configs.length === 0) { + return 'None'; + } + + const configInterface = getSubnetInterfaceFromConfigs(configs, subnetId); + return determineNoneSingleOrMultipleWithChip( + configInterface?.ip_ranges ?? [] + ); +}; + const getFirewallLinks = (data: Firewall[]): JSX.Element => { const firstThreeFirewalls = data.slice(0, 3); return ( @@ -325,6 +360,9 @@ export const SubnetLinodeTableRowHead = ( VPC IPv4 + + VPC IPv4 Ranges + Firewalls diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 6f3dca58717..952ad1efdf0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -10,7 +10,7 @@ import { Box } from 'src/components/Box'; import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { SUBNET_UNASSIGN_LINODES_WARNING } from 'src/features/VPCs/constants'; import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; @@ -25,6 +25,7 @@ import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import type { APIError, DeleteLinodeConfigInterfacePayload, + Interface, Linode, UpdateConfigInterfacePayload, } from '@linode/api-v4'; @@ -37,6 +38,12 @@ interface Props { vpcId: number; } +interface ConfigInterfaceAndLinodeData extends Linode { + configId: number; + interfaceData: Interface; + interfaceId: number; +} + export const SubnetUnassignLinodesDrawer = React.memo( ({ onClose, open, singleLinodeToBeUnassigned, subnet, vpcId }: Props) => { const { data: profile } = useProfile(); @@ -53,9 +60,15 @@ export const SubnetUnassignLinodesDrawer = React.memo( const csvRef = React.useRef(); const formattedDate = useFormattedDate(); + const [selectedLinodes, setSelectedLinodes] = React.useState( singleLinodeToBeUnassigned ? [singleLinodeToBeUnassigned] : [] ); + const [ + selectedLinodesAndConfigData, + setSelectedLinodesAndConfigData, + ] = React.useState([]); + const hasError = React.useRef(false); // This flag is used to prevent the drawer from closing if an error occurs. const [ @@ -126,9 +139,11 @@ export const SubnetUnassignLinodesDrawer = React.memo( } return { + ...linode, configId: configWithVpcInterface.id, + + interfaceData: vpcInterface, interfaceId: vpcInterface.id, - linodeId: linode.id, }; } return null; @@ -136,12 +151,26 @@ export const SubnetUnassignLinodesDrawer = React.memo( ); // Filter out any null values and ensure item conforms to type using `is` type guard. - const filteredConfigInterfaces = updatedConfigInterfaces.filter( + const _selectedLinodesAndConfigData = updatedConfigInterfaces.filter( + (item): item is ConfigInterfaceAndLinodeData => item !== null + ); + + // Remove interface property for the DeleteLinodeConfigInterfacePayload data + const _updatedConfigInterfaces = updatedConfigInterfaces.map( + (item) => ({ + configId: item?.configId, + interfaceId: item?.interfaceId, + linodeId: item?.id, + }) + ); + + const filteredConfigInterfaces = _updatedConfigInterfaces.filter( (item): item is DeleteLinodeConfigInterfacePayload => item !== null ); // Update the state with the new data setConfigInterfacesToDelete([...filteredConfigInterfaces]); + setSelectedLinodesAndConfigData([..._selectedLinodesAndConfigData]); } catch (error) { // Capture errors if the promise.all fails hasError.current = true; @@ -168,15 +197,24 @@ export const SubnetUnassignLinodesDrawer = React.memo( csvRef.current.link.click(); }; - const handleRemoveLinode = (optionToRemove: Linode) => { + const handleRemoveLinode = ( + optionToRemove: ConfigInterfaceAndLinodeData + ) => { setSelectedLinodes((prevSelectedLinodes) => prevSelectedLinodes.filter((option) => option.id !== optionToRemove.id) ); + setConfigInterfacesToDelete((prevInterfacesToDelete) => prevInterfacesToDelete.filter( (option) => option.linodeId !== optionToRemove.id ) ); + + setSelectedLinodesAndConfigData((prevSelectedLinodesAndConfigData) => + prevSelectedLinodesAndConfigData.filter( + (option) => option.id !== optionToRemove.id + ) + ); }; const processUnassignLinodes = async () => { @@ -284,15 +322,16 @@ export const SubnetUnassignLinodesDrawer = React.memo( /> )} ({ marginTop: theme.spacing(2) })}> - - {selectedLinodes.length > 0 && ( + {selectedLinodesAndConfigData.length > 0 && ( )} { + it('should return None for empty arrays', () => { + expect(determineNoneSingleOrMultipleWithChip([])).toEqual('None'); + }); + + it('should return the element if the array only consists of one element', () => { + const array = ['Test']; + + expect(determineNoneSingleOrMultipleWithChip(array)).toEqual(array[0]); + }); + + it('should not return "None" nor equal the first element of the array if the array contains multiple elements', () => { + const array = ['Test', 'Test 2', 'Test 3', 'Test 4']; + + const returned = determineNoneSingleOrMultipleWithChip(array); + + expect(returned).not.toEqual('None'); + expect(returned).not.toEqual('Test'); + }); +}); diff --git a/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx new file mode 100644 index 00000000000..67b58fb2dcc --- /dev/null +++ b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { Chip } from 'src/components/Chip'; +import { StyledItemWithPlusChip } from 'src/components/RemovableSelectionsList/RemovableSelectionsList.style'; +import { Tooltip } from 'src/components/Tooltip'; + +export const remainingDataLengthChip = 'remaining-data-length-chip'; + +export const determineNoneSingleOrMultipleWithChip = ( + dataArray: string[] +): JSX.Element | string => { + if (dataArray.length === 0) { + return 'None'; + } + + if (dataArray.length === 1) { + return dataArray[0]; + } + + const allDataExceptFirstElement = dataArray.slice(1); + + const remainingData = allDataExceptFirstElement.map((datum) => ( + <> + {datum} +
+ + )); + + return ( + + {dataArray[0]}{' '} + + + + + ); +}; diff --git a/packages/manager/src/utilities/pricing/backups.test.tsx b/packages/manager/src/utilities/pricing/backups.test.tsx index a2d0d90b7f1..a3ef5db57af 100644 --- a/packages/manager/src/utilities/pricing/backups.test.tsx +++ b/packages/manager/src/utilities/pricing/backups.test.tsx @@ -100,4 +100,37 @@ describe('getTotalBackupsPrice', () => { }) ).toBe(8.57); }); + + it('correctly calculates the total price with $0 DC-specific pricing for Linode backups', () => { + const basePriceLinodes = linodeFactory.buildList(2, { type: 'my-type' }); + const zeroPriceLinode = linodeFactory.build({ + region: 'es-mad', + type: 'my-type', + }); + const linodes = [...basePriceLinodes, zeroPriceLinode]; + const types = linodeTypeFactory.buildList(1, { + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.5, + }, + region_prices: [ + { + hourly: 0, + id: 'es-mad', + monthly: 0, + }, + ], + }, + }, + id: 'my-type', + }); + expect( + getTotalBackupsPrice({ + linodes, + types, + }) + ).toBe(5); + }); }); diff --git a/packages/manager/src/utilities/pricing/backups.ts b/packages/manager/src/utilities/pricing/backups.ts index 6fe31cafe5a..f6337e2a44f 100644 --- a/packages/manager/src/utilities/pricing/backups.ts +++ b/packages/manager/src/utilities/pricing/backups.ts @@ -75,11 +75,12 @@ export const getTotalBackupsPrice = ({ return undefined; } - const backupsMonthlyPrice: PriceObject['monthly'] | undefined = - getMonthlyBackupsPrice({ - region: linode.region, - type, - }) || undefined; + const backupsMonthlyPrice: + | PriceObject['monthly'] + | undefined = getMonthlyBackupsPrice({ + region: linode.region, + type, + }); if (backupsMonthlyPrice === null || backupsMonthlyPrice === undefined) { return undefined; diff --git a/packages/manager/src/utilities/subnets.ts b/packages/manager/src/utilities/subnets.ts index 44ad6d4449a..cc14a84eb0c 100644 --- a/packages/manager/src/utilities/subnets.ts +++ b/packages/manager/src/utilities/subnets.ts @@ -7,6 +7,8 @@ export const SUBNET_LINODE_CSV_HEADERS = [ { key: 'label', label: 'Linode Label' }, { key: 'id', label: 'Linode ID' }, { key: 'ipv4', label: 'IPv4' }, + { key: 'interfaceData.ipv4.vpc', label: 'IPv4 VPC' }, + { key: 'interfaceData.ip_ranges', label: 'IPv4 VPC Ranges' }, ]; // @TODO VPC: added ipv6 related fields here, but they will not be used until VPCs support ipv6 diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 25bb28ea05b..a598898342b 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2024-02-12] - v0.40.0 + + +### Changed: + +- ip_ranges field in LinodeInterfaceSchema no longer limited to 1 element ([#10089](https://github.com/linode/manager/pull/10089)) + ## [2024-02-05] - v0.39.0 @@ -5,6 +12,7 @@ - Add `path_regex` as a valid ACLB rule match field ([#10126](https://github.com/linode/manager/pull/10126)) + ## [2024-01-22] - v0.38.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 26b568958f3..3d5d2f5e275 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.39.0", + "version": "0.40.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", @@ -68,4 +68,4 @@ }, "author": "Linode LLC", "license": "Apache-2.0" -} +} \ No newline at end of file diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 40834ed8052..2c9948e9679 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -195,7 +195,7 @@ export const LinodeInterfaceSchema = object().shape({ .of(string()) .when('purpose', { is: 'vpc', - then: array().of(string().test(validateIP)).max(1).notRequired(), + then: array().of(string().test(validateIP)).notRequired(), otherwise: array().test({ name: testnameDisallowedBasedOnPurpose('VPC'), message: testmessageDisallowedBasedOnPurpose('vpc', 'ip_ranges'),