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']);