@@ -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/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 153094a6a23..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,7 +13,7 @@ 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';
@@ -28,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 {
@@ -35,18 +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';
// @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/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'),