diff --git a/packages/manager/.changeset/pr-11069-fixed-1728443895478.md b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md
new file mode 100644
index 00000000000..057ac261ea2
--- /dev/null
+++ b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069))
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx
index 8321d07ee4f..fc7673d7e3e 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx
@@ -24,7 +24,7 @@ import { useOrder } from 'src/hooks/useOrder';
import { usePagination } from 'src/hooks/usePagination';
import { useLinodesQuery } from 'src/queries/linodes/linodes';
import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics';
-import { privateIPRegex } from 'src/utilities/ipUtils';
+import { isPrivateIP } from 'src/utilities/ipUtils';
import { isNumeric } from 'src/utilities/stringUtils';
import {
@@ -105,7 +105,7 @@ export const LinodeSelectTable = (props: Props) => {
const queryClient = useQueryClient();
const handleSelect = async (linode: Linode) => {
- const hasPrivateIP = linode.ipv4.some((ipv4) => privateIPRegex.test(ipv4));
+ const hasPrivateIP = linode.ipv4.some(isPrivateIP);
reset((prev) => ({
...prev,
backup_id: null,
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts
index 55641bee1ae..92fd7443e30 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts
+++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts
@@ -6,7 +6,7 @@ import { linodeQueries } from 'src/queries/linodes/linodes';
import { stackscriptQueries } from 'src/queries/stackscripts';
import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics';
import { sendLinodeCreateFormErrorEvent } from 'src/utilities/analytics/formEventAnalytics';
-import { privateIPRegex } from 'src/utilities/ipUtils';
+import { isPrivateIP } from 'src/utilities/ipUtils';
import { utoa } from 'src/utilities/metadata';
import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined';
import { omitProps } from 'src/utilities/omittedProps';
@@ -299,8 +299,7 @@ export const defaultValues = async (
? await queryClient.ensureQueryData(linodeQueries.linode(params.linodeID))
: null;
- const privateIp =
- linode?.ipv4.some((ipv4) => privateIPRegex.test(ipv4)) ?? false;
+ const privateIp = linode?.ipv4.some(isPrivateIP) ?? false;
const values: LinodeCreateFormValues = {
backup_id: params.backupID,
diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx
index a6c44f5eef3..8a0b44c0abf 100644
--- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx
@@ -1,4 +1,3 @@
-import { Linode } from '@linode/api-v4';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
@@ -8,49 +7,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { LinodeSelect } from './LinodeSelect';
-const fakeLinodeData = linodeFactory.build({
- id: 1,
- image: 'metadata-test-image',
- label: 'metadata-test-region',
- region: 'eu-west',
-});
+import type { Linode } from '@linode/api-v4';
+
const TEXTFIELD_ID = 'textfield-input';
describe('LinodeSelect', () => {
- test('renders custom options using renderOption', async () => {
- // Create a mock renderOption function
- const mockRenderOption = (linode: Linode, selected: boolean) => (
-
- {`${linode.label} - ${selected ? 'Selected' : 'Not Selected'}`}
-
- );
-
- // Render the component with the custom renderOption function
- renderWithTheme(
-
- );
-
- const input = screen.getByTestId(TEXTFIELD_ID);
-
- // Open the dropdown
- await userEvent.click(input);
-
- // Wait for the options to load (use some unique identifier for the options)
- await waitFor(() => {
- const customOption = screen.getByTestId('custom-option-1');
- expect(customOption).toBeInTheDocument();
- expect(customOption).toHaveTextContent(
- 'metadata-test-region - Not Selected'
- );
- });
- });
test('should display custom no options message', async () => {
const customNoOptionsMessage = 'Custom No Options Message';
const options: Linode[] = []; // Assuming no options are available
diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx
index 84722abb76d..0b0de416bac 100644
--- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx
+++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx
@@ -45,10 +45,6 @@ interface LinodeSelectProps {
optionsFilter?: (linode: Linode) => boolean;
/* Displayed when the input is blank. */
placeholder?: string;
- /* Render a custom option. */
- renderOption?: (linode: Linode, selected: boolean) => JSX.Element;
- /* Render a custom option label. */
- renderOptionLabel?: (linode: Linode) => string;
/* Displays an indication that the input is required. */
required?: boolean;
/* Adds custom styles to the component. */
@@ -98,8 +94,6 @@ export const LinodeSelect = (
options,
optionsFilter,
placeholder,
- renderOption,
- renderOptionLabel,
sx,
value,
} = props;
@@ -122,9 +116,6 @@ export const LinodeSelect = (
return (
- renderOptionLabel ? renderOptionLabel(linode) : linode.label
- }
isOptionEqualToValue={
checkIsOptionEqualToValue
? (option, value) => option.id === value.id
@@ -145,17 +136,6 @@ export const LinodeSelect = (
? 'Select Linodes'
: 'Select a Linode'
}
- renderOption={
- renderOption
- ? (props, option, { selected }) => {
- return (
-
- {renderOption(option, selected)}
-
- );
- }
- : undefined
- }
value={
typeof value === 'function'
? multiple && Array.isArray(value)
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
index a7e1f3de72e..424e4f7c87f 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
@@ -3,7 +3,7 @@ import * as React from 'react';
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import { ShowMore } from 'src/components/ShowMore/ShowMore';
import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
-import { privateIPRegex } from 'src/utilities/ipUtils';
+import { isPrivateIP } from 'src/utilities/ipUtils';
import { tail } from 'src/utilities/tail';
import {
@@ -55,7 +55,7 @@ export interface IPAddressProps {
}
export const sortIPAddress = (ip1: string, ip2: string) =>
- (privateIPRegex.test(ip1) ? 1 : -1) - (privateIPRegex.test(ip2) ? 1 : -1);
+ (isPrivateIP(ip1) ? 1 : -1) - (isPrivateIP(ip2) ? 1 : -1);
export const IPAddress = (props: IPAddressProps) => {
const {
diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx
index 1684f27b21e..be63b6a4e08 100644
--- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx
+++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx
@@ -15,7 +15,7 @@ import {
handleFieldErrors,
handleGeneralErrors,
} from 'src/utilities/formikErrorUtils';
-import { privateIPRegex, removePrefixLength } from 'src/utilities/ipUtils';
+import { isPrivateIP, removePrefixLength } from 'src/utilities/ipUtils';
import {
StyledIPGrid,
@@ -168,9 +168,7 @@ const EditSSHAccessDrawer = (props: EditSSHAccessDrawerProps) => {
},
...options
// Remove Private IPs
- .filter(
- (option) => !privateIPRegex.test(option.value)
- )
+ .filter((option) => !isPrivateIP(option.value))
// Remove the prefix length from each option.
.map((option) => ({
label: removePrefixLength(option.value),
diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
index 6630b7509c9..2372102a9d3 100644
--- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
+++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
@@ -1,96 +1,102 @@
-import { Box } from '@mui/material';
-import * as React from 'react';
+import React from 'react';
+import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles';
-import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect';
-import { privateIPRegex } from 'src/utilities/ipUtils';
+import { Box } from 'src/components/Box';
+import { Stack } from 'src/components/Stack';
+import { Typography } from 'src/components/Typography';
+import { useAllLinodesQuery } from 'src/queries/linodes/linodes';
-import type { Linode } from '@linode/api-v4/lib/linodes';
-import type { TextFieldProps } from 'src/components/TextField';
+import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils';
-interface ConfigNodeIPSelectProps {
+interface Props {
+ /**
+ * Disables the select
+ */
disabled?: boolean;
- errorText?: string;
+ /**
+ * Validation error text
+ */
+ errorText: string | undefined;
+ /**
+ * Function that is called when the select's value changes
+ */
handleChange: (nodeIndex: number, ipAddress: null | string) => void;
+ /**
+ * Override the default input `id` for the select
+ */
inputId?: string;
- nodeAddress?: string;
+ /**
+ * The selected private IP address
+ */
+ nodeAddress: string | undefined;
+ /**
+ * The index of the config node in state
+ */
nodeIndex: number;
- selectedRegion?: string;
- textfieldProps: Omit;
+ /**
+ * The region for which to load Linodes and to show private IPs
+ * @note IPs won't load until a region is passed
+ */
+ region: string | undefined;
}
-export const ConfigNodeIPSelect = React.memo(
- (props: ConfigNodeIPSelectProps) => {
- const {
- handleChange: _handleChange,
- inputId,
- nodeAddress,
- nodeIndex,
- } = props;
- const handleChange = (linode: Linode | null) => {
- if (!linode) {
- _handleChange(nodeIndex, null);
- }
+export const ConfigNodeIPSelect = React.memo((props: Props) => {
+ const {
+ disabled,
+ errorText,
+ handleChange,
+ inputId,
+ nodeAddress,
+ nodeIndex,
+ region,
+ } = props;
- const thisLinodesPrivateIP = linode?.ipv4.find((ipv4) =>
- ipv4.match(privateIPRegex)
- );
+ const { data: linodes, error, isLoading } = useAllLinodesQuery(
+ {},
+ { region },
+ region !== undefined
+ );
- if (!thisLinodesPrivateIP) {
- return;
- }
+ const options = getPrivateIPOptions(linodes);
- /**
- * we can be sure the selection has a private IP because of the
- * filterCondition prop in the render method below
- */
- _handleChange(nodeIndex, thisLinodesPrivateIP);
- };
-
- return (
- {
- /**
- * if the Linode doesn't have an private IP OR if the Linode
- * is in a different region that the NodeBalancer, don't show it
- * in the select dropdown
- */
- return (
- !!linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) &&
- linode.region === props.selectedRegion
- );
- }}
- renderOption={(linode, selected) => (
- <>
-
-
- {linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex))}
-
- {linode.label}
-
-
- >
- )}
- renderOptionLabel={(linode) =>
- linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) ?? ''
- }
- clearable
- disabled={props.disabled}
- errorText={props.errorText}
- id={inputId}
- label="IP Address"
- noMarginTop
- onSelectionChange={handleChange}
- placeholder="Enter IP Address"
- value={(linode) => linode.ipv4.some((ip) => ip === nodeAddress)}
- />
- );
- }
-);
+ return (
+ (
+
+
+
+ theme.font.bold}
+ >
+ {option.label}
+
+ {option.linode.label}
+
+ {selected && }
+
+
+ )}
+ disabled={disabled}
+ errorText={errorText ?? error?.[0].reason}
+ id={inputId}
+ label="IP Address"
+ loading={isLoading}
+ noMarginTop
+ noOptionsText="No options - please ensure you have at least 1 Linode with a private IP located in the selected region."
+ onChange={(e, value) => handleChange(nodeIndex, value?.label ?? null)}
+ options={options}
+ placeholder="Enter IP Address"
+ value={options.find((o) => o.label === nodeAddress) ?? null}
+ />
+ );
+});
diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts
new file mode 100644
index 00000000000..e95b738d915
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts
@@ -0,0 +1,32 @@
+import { linodeFactory } from 'src/factories';
+
+import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils';
+
+describe('getPrivateIPOptions', () => {
+ it('returns an empty array when linodes are undefined', () => {
+ expect(getPrivateIPOptions(undefined)).toStrictEqual([]);
+ });
+
+ it('returns an empty array when there are no Linodes', () => {
+ expect(getPrivateIPOptions([])).toStrictEqual([]);
+ });
+
+ it('returns an option for each private IPv4 on a Linode', () => {
+ const linode = linodeFactory.build({ ipv4: ['192.168.1.1', '172.16.0.1'] });
+
+ expect(getPrivateIPOptions([linode])).toStrictEqual([
+ { label: '192.168.1.1', linode },
+ { label: '172.16.0.1', linode },
+ ]);
+ });
+
+ it('does not return an option for public IPv4s on a Linode', () => {
+ const linode = linodeFactory.build({
+ ipv4: ['143.198.125.230', '192.168.1.1'],
+ });
+
+ expect(getPrivateIPOptions([linode])).toStrictEqual([
+ { label: '192.168.1.1', linode },
+ ]);
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts
new file mode 100644
index 00000000000..1dfc830bbf8
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts
@@ -0,0 +1,36 @@
+import { isPrivateIP } from 'src/utilities/ipUtils';
+
+import type { Linode } from '@linode/api-v4';
+
+interface PrivateIPOption {
+ /**
+ * A private IPv4 address
+ */
+ label: string;
+ /**
+ * The Linode associated with the private IPv4 address
+ */
+ linode: Linode;
+}
+
+/**
+ * Given an array of Linodes, this function returns an array of private
+ * IPv4 options intended to be used in a Select component.
+ */
+export const getPrivateIPOptions = (linodes: Linode[] | undefined) => {
+ if (!linodes) {
+ return [];
+ }
+
+ const options: PrivateIPOption[] = [];
+
+ for (const linode of linodes) {
+ for (const ip of linode.ipv4) {
+ if (isPrivateIP(ip)) {
+ options.push({ label: ip, linode });
+ }
+ }
+ }
+
+ return options;
+};
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
index 05f47659c98..c97f7bc1345 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
@@ -134,18 +134,13 @@ export const NodeBalancerConfigNode = React.memo(
diff --git a/packages/manager/src/utilities/ipUtils.test.ts b/packages/manager/src/utilities/ipUtils.test.ts
new file mode 100644
index 00000000000..371cbf7b855
--- /dev/null
+++ b/packages/manager/src/utilities/ipUtils.test.ts
@@ -0,0 +1,13 @@
+import { isPrivateIP } from './ipUtils';
+
+describe('isPrivateIP', () => {
+ it('returns true for a private IPv4', () => {
+ expect(isPrivateIP('192.168.1.1')).toBe(true);
+ expect(isPrivateIP('172.16.5.12')).toBe(true);
+ });
+
+ it('returns false for a public IPv4', () => {
+ expect(isPrivateIP('45.79.245.236')).toBe(false);
+ expect(isPrivateIP('100.78.0.8')).toBe(false);
+ });
+});
diff --git a/packages/manager/src/utilities/ipUtils.ts b/packages/manager/src/utilities/ipUtils.ts
index 74368b21f55..06392a895a2 100644
--- a/packages/manager/src/utilities/ipUtils.ts
+++ b/packages/manager/src/utilities/ipUtils.ts
@@ -1,3 +1,4 @@
+import { PRIVATE_IPv4_REGEX } from '@linode/validation';
import { parseCIDR, parse as parseIP } from 'ipaddr.js';
/**
@@ -8,9 +9,12 @@ import { parseCIDR, parse as parseIP } from 'ipaddr.js';
export const removePrefixLength = (ip: string) => ip.replace(/\/\d+/, '');
/**
- * Regex for determining if a string is a private IP Addresses
+ * Determines if an IPv4 address is private
+ * @returns true if the given IPv4 address is private
*/
-export const privateIPRegex = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/;
+export const isPrivateIP = (ip: string) => {
+ return PRIVATE_IPv4_REGEX.test(ip);
+};
export interface ExtendedIP {
address: string;
diff --git a/packages/validation/.changeset/pr-11069-added-1728444089255.md b/packages/validation/.changeset/pr-11069-added-1728444089255.md
new file mode 100644
index 00000000000..42512f52050
--- /dev/null
+++ b/packages/validation/.changeset/pr-11069-added-1728444089255.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Added
+---
+
+`PRIVATE_IPv4_REGEX` for determining if an IPv4 address is private ([#11069](https://github.com/linode/manager/pull/11069))
diff --git a/packages/validation/.changeset/pr-11069-changed-1728444173048.md b/packages/validation/.changeset/pr-11069-changed-1728444173048.md
new file mode 100644
index 00000000000..286ee280e1f
--- /dev/null
+++ b/packages/validation/.changeset/pr-11069-changed-1728444173048.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Changed
+---
+
+Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069))
diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts
index 46d2d889e2b..4cc2c15793c 100644
--- a/packages/validation/src/nodebalancers.schema.ts
+++ b/packages/validation/src/nodebalancers.schema.ts
@@ -3,6 +3,8 @@ import { array, boolean, mixed, number, object, string } from 'yup';
const PORT_WARNING = 'Port must be between 1 and 65535.';
const LABEL_WARNING = 'Label must be between 3 and 32 characters.';
+export const PRIVATE_IPv4_REGEX = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/;
+
export const nodeBalancerConfigNodeSchema = object({
label: string()
.matches(
@@ -16,10 +18,7 @@ export const nodeBalancerConfigNodeSchema = object({
address: string()
.typeError('IP address is required.')
.required('IP address is required.')
- .matches(
- /^192\.168\.\d{1,3}\.\d{1,3}$/,
- 'Must be a valid private IPv4 address.'
- ),
+ .matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.'),
port: number()
.typeError('Port must be a number.')