diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md
index e62aa45b79e..35ff819b993 100644
--- a/packages/manager/CHANGELOG.md
+++ b/packages/manager/CHANGELOG.md
@@ -4,6 +4,27 @@ 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-13] - 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))
+
+### Changed:
+
+- "Learn more" docs link for IPv4 ranges in Add/Edit Linode Config dialog, Linode Create flow, and VPC "Assign Linodes" drawer
+
+### 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}
+
+ ) : (
+
+ {buttonText ?? 'Add an IP'}
+
+ );
+
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) && (
removeInput(idx)}
>
- ) : null}
+ )}
))}
-
- Add an IP
-
+ {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 && }
-
+
+ )}
);
};
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..946302aa5db
--- /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_COMPUTE_INSTANCE_TO_VPC_LINK,
+ ASSIGN_IPV4_RANGES_DESCRIPTION,
+ ASSIGN_IPV4_RANGES_TITLE,
+} 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..9ba366e402f 100644
--- a/packages/validation/CHANGELOG.md
+++ b/packages/validation/CHANGELOG.md
@@ -1,3 +1,10 @@
+## [2024-02-13] - 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'),