diff --git a/packages/manager/.changeset/pr-9914-upcoming-features-1700169924490.md b/packages/manager/.changeset/pr-9914-upcoming-features-1700169924490.md new file mode 100644 index 00000000000..996aac9dfac --- /dev/null +++ b/packages/manager/.changeset/pr-9914-upcoming-features-1700169924490.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Indicate unrecommended Linode configurations on VPC Detail page ([#9914](https://github.com/linode/manager/pull/9914)) diff --git a/packages/manager/src/components/Notice/Notice.tsx b/packages/manager/src/components/Notice/Notice.tsx index 5c76839835c..200998d0a60 100644 --- a/packages/manager/src/components/Notice/Notice.tsx +++ b/packages/manager/src/components/Notice/Notice.tsx @@ -79,7 +79,7 @@ export interface NoticeProps extends Grid2Props { - Appear within the page or modal - Might be triggered by user action - Typically used to alert the user to a new service, limited availability, or a potential consequence of the action being taken -- Consider using a [Dismissible Banner](/docs/components-notifications-dismissible-banners--beta-banner) if it’s not critical information +- Consider using a [Dismissible Banner](/docs/components-notifications-dismissible-banners--beta-banner) if it’s not critical information ## Types of Notices: diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.styles.ts index a51124b43ff..066e2105715 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.styles.ts +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.styles.ts @@ -1,5 +1,6 @@ import { styled } from '@mui/material/styles'; +import Warning from 'src/assets/icons/warning.svg'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -38,3 +39,9 @@ export const StyledTableHeadCell = styled(TableCell, { borderBottom: `1px solid ${theme.borderColors.borderTable} !important`, borderTop: 'none !important', })); + +export const StyledWarningIcon = styled(Warning, { + label: 'StyledWarningIcon', +})(({ theme }) => ({ + fill: theme.color.yellow, +})); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx index ff3d1515b1b..279ed791858 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx @@ -1,10 +1,15 @@ import { fireEvent } from '@testing-library/react'; -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; import { QueryClient } from 'react-query'; -import { firewallFactory } from 'src/factories'; -import { LinodeConfigInterfaceFactoryWithVPC } from 'src/factories/linodeConfigInterfaceFactory'; +import { + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, + firewallFactory, + subnetAssignedLinodeDataFactory, + subnetFactory, +} from 'src/factories'; import { linodeConfigFactory } from 'src/factories/linodeConfigs'; import { linodeFactory } from 'src/factories/linodes'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -15,6 +20,7 @@ import { wrapWithTableBody, } from 'src/utilities/testHelpers'; +import { WARNING_ICON_UNRECOMMENDED_CONFIG } from '../constants'; import { SubnetLinodeRow } from './SubnetLinodeRow'; const queryClient = new QueryClient(); @@ -25,23 +31,33 @@ afterEach(() => { }); const loadingTestId = 'circle-progress'; +const mockFirewall0 = 'mock-firewall-0'; describe('SubnetLinodeRow', () => { + const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' }); + + server.use( + rest.get('*/linodes/instances/:linodeId', (req, res, ctx) => { + return res(ctx.json(linodeFactory1)); + }), + rest.get('*/linode/instances/:id/firewalls', (req, res, ctx) => { + return res( + ctx.json( + makeResourcePage( + firewallFactory.buildList(1, { label: mockFirewall0 }) + ) + ) + ); + }) + ); + + const linodeFactory2 = linodeFactory.build({ id: 2, label: 'linode-2' }); + + const handleUnassignLinode = vi.fn(); + it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, and Reboot and Unassign buttons', async () => { const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' }); server.use( - rest.get('*/linodes/instances/:linodeId', (req, res, ctx) => { - return res(ctx.json(linodeFactory1)); - }), - rest.get('*/linode/instances/:id/firewalls', (req, res, ctx) => { - return res( - ctx.json( - makeResourcePage( - firewallFactory.buildList(1, { label: 'mock-firewall-0' }) - ) - ) - ); - }), rest.get('*/instances/*/configs', async (req, res, ctx) => { const configs = linodeConfigFactory.buildList(3); return res(ctx.json(makeResourcePage(configs))); @@ -82,7 +98,7 @@ describe('SubnetLinodeRow', () => { ); getAllByText('10.0.0.0'); - getByText('mock-firewall-0'); + getByText(mockFirewall0); const rebootLinodeButton = getAllByRole('button')[1]; expect(rebootLinodeButton).toHaveTextContent('Reboot'); @@ -97,6 +113,7 @@ describe('SubnetLinodeRow', () => { const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' }); const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ active: true, + primary: true, }); server.use( rest.get('*/linodes/instances/:linodeId', (req, res, ctx) => { @@ -106,7 +123,7 @@ describe('SubnetLinodeRow', () => { return res( ctx.json( makeResourcePage( - firewallFactory.buildList(1, { label: 'mock-firewall-0' }) + firewallFactory.buildList(1, { label: mockFirewall0 }) ) ) ); @@ -158,4 +175,76 @@ describe('SubnetLinodeRow', () => { fireEvent.click(unassignLinodeButton); expect(handleUnassignLinode).toHaveBeenCalled(); }); + + it('should display a warning icon for Linodes using unrecommended configuration profiles', async () => { + const publicInterface = LinodeConfigInterfaceFactory.build({ + active: true, + id: 5, + ipam_address: null, + primary: true, + purpose: 'public', + }); + + const vpcInterface = LinodeConfigInterfaceFactory.build({ + active: true, + id: 10, + ipam_address: null, + purpose: 'vpc', + subnet_id: 1, + }); + + const configurationProfile = linodeConfigFactory.build({ + interfaces: [publicInterface, vpcInterface], + }); + + const subnet = subnetFactory.build({ + id: 1, + linodes: [ + subnetAssignedLinodeDataFactory.build({ + id: 1, + interfaces: [ + { + active: true, + id: 5, + }, + { + active: true, + id: 10, + }, + ], + }), + ], + }); + + server.use( + rest.get('*/instances/*/configs', async (req, res, ctx) => { + return res(ctx.json(makeResourcePage([configurationProfile]))); + }) + ); + + const { getByTestId } = renderWithTheme( + wrapWithTableBody( + + ), + { + queryClient, + } + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const warningIcon = getByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG); + + await waitFor(() => { + expect(warningIcon).toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 5da402fb4b5..43977566633 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -23,13 +23,21 @@ import { } from 'src/queries/linodes/linodes'; import { capitalizeAllWords } from 'src/utilities/capitalize'; -import { VPC_REBOOT_MESSAGE } from '../constants'; -import { getSubnetInterfaceFromConfigs } from '../utils'; +import { + NETWORK_INTERFACES_GUIDE_URL, + VPC_REBOOT_MESSAGE, + WARNING_ICON_UNRECOMMENDED_CONFIG, +} from '../constants'; +import { + hasUnrecommendedConfiguration as _hasUnrecommendedConfiguration, + getSubnetInterfaceFromConfigs, +} from '../utils'; import { StyledActionTableCell, StyledTableCell, StyledTableHeadCell, StyledTableRow, + StyledWarningIcon, } from './SubnetLinodeRow.styles'; import type { Subnet } from '@linode/api-v4/lib/vpcs/types'; @@ -71,6 +79,11 @@ export const SubnetLinodeRow = (props: Props) => { isLoading: configsLoading, } = useAllLinodeConfigsQuery(linodeId); + const hasUnrecommendedConfiguration = _hasUnrecommendedConfiguration( + configs ?? [], + subnet?.id ?? -1 + ); + // If the Linode's status is running, we want to check if its interfaces associated with this subnet have become active so // that we can determine if it needs a reboot or not. So, we need to invalidate the linode configs query to get the most up to date information. React.useEffect(() => { @@ -113,6 +126,37 @@ export const SubnetLinodeRow = (props: Props) => { ); } + const linkifiedLinodeLabel = ( + {linode.label} + ); + + const labelCell = hasUnrecommendedConfiguration ? ( + + + This Linode is using an unrecommended configuration profile. Update + its configuration profile to avoid connectivity issues. Read our{' '} + + Configuration Profiles + {' '} + guide for more information. + + } + icon={} + interactive + status="other" + sxTooltipIcon={{ paddingLeft: 0 }} + /> + {linkifiedLinodeLabel} + + ) : ( + linkifiedLinodeLabel + ); + const iconStatus = getLinodeIconStatus(linode.status); const isRunning = linode.status === 'running'; const isOffline = linode.status === 'stopped' || linode.status === 'offline'; @@ -130,7 +174,7 @@ export const SubnetLinodeRow = (props: Props) => { return ( - {linode.label} + {labelCell} { expect(subnetInterfaceUndefined).toBeUndefined(); }); }); + +describe('hasUnrecommendedConfiguration function', () => { + it('returns true when a config has an active VPC interface and a non-VPC primary interface', () => { + const publicInterface = LinodeConfigInterfaceFactory.build({ + id: 10, + primary: true, + }); + + const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + active: true, + id: 20, + subnet_id: 1, + }); + + const config1 = linodeConfigFactory.build({ + interfaces: [publicInterface, vpcInterface], + }); + + const config2 = linodeConfigFactory.build(); + + const subnet = subnetFactory.build({ + id: 1, + linodes: [ + subnetAssignedLinodeDataFactory.build({ + id: 5, + interfaces: [ + { + active: true, + id: 20, + }, + { + active: true, + id: 10, + }, + ], + }), + ], + }); + + expect(hasUnrecommendedConfiguration([config1, config2], subnet.id)).toBe( + true + ); + }); + + it('returns false when a config has an active VPC interface that is the primary interface', () => { + const publicInterface = LinodeConfigInterfaceFactory.build({ id: 10 }); + const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + active: true, + id: 20, + primary: true, + subnet_id: 1, + }); + + const config1 = linodeConfigFactory.build({ + interfaces: [publicInterface], + }); + + const config2 = linodeConfigFactory.build({ + interfaces: [publicInterface, vpcInterface], + }); + + const subnet = subnetFactory.build({ + id: 1, + linodes: [ + subnetAssignedLinodeDataFactory.build({ + id: 5, + interfaces: [ + { + active: true, + id: 20, + }, + { + active: true, + id: 10, + }, + ], + }), + ], + }); + + expect(hasUnrecommendedConfiguration([config1, config2], subnet.id)).toBe( + false + ); + }); +}); diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 62b86b931f1..3c622b585c3 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -26,3 +26,34 @@ export const getSubnetInterfaceFromConfigs = ( return undefined; }; + +export const hasUnrecommendedConfiguration = ( + configs: Config[], + subnetId: number +) => { + for (const config of configs) { + const configInterfaces = config.interfaces; + + /* + If there is a VPC interface marked as active but not primary, we want to display a + message re: it not being a recommended configuration. + + Rationale: when the VPC interface is not the primary interface, it can communicate + to other VMs within the same subnet, but not to VMs in a different subnet + within the same VPC. + */ + + if ( + configInterfaces.some((_interface) => _interface.subnet_id === subnetId) + ) { + return configInterfaces.some( + (_interface) => + _interface.active && + _interface.purpose === 'vpc' && + !_interface.primary + ); + } + } + + return false; +}; diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 427e7041bf5..0f7338c6067 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -40,10 +40,16 @@ export const linodeEventsHandler = ({ event, queryClient }: EventWithStore) => { case 'linode_resize_create': case 'linode_resize_warm_create': case 'linode_reboot': + case 'linode_update': + queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); + queryClient.invalidateQueries([queryKey, 'paginated']); + queryClient.invalidateQueries([queryKey, 'all']); + queryClient.invalidateQueries([queryKey, 'infinite']); + return; case 'linode_boot': case 'linode_shutdown': - case 'linode_update': queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); + queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'configs']); // Ensure configs are fresh when Linode is booted up (see https://github.com/linode/manager/pull/9914) queryClient.invalidateQueries([queryKey, 'paginated']); queryClient.invalidateQueries([queryKey, 'all']); queryClient.invalidateQueries([queryKey, 'infinite']);