Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Upcoming Features
---

Accept placement group in Linode create payload ([#10195](https://github.com/linode/manager/pull/10195))
10 changes: 9 additions & 1 deletion packages/api-v4/src/linodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export interface Linode {
ipv4: string[];
ipv6: string | null;
label: string;
// While the API returns an array of PlacementGroup objects for future proofing,
// we only support one PlacementGroup per Linode at this time, hence the tuple.
placement_groups:
| [Pick<PlacementGroup, 'id' | 'label' | 'affinity_type'>] // While the API returns an array of PlacementGroup objects for future proofing, we only support one PlacementGroup per Linode at this time, hence the tuple.
| [Pick<PlacementGroup, 'id' | 'label' | 'affinity_type'>]
| [];
type: string | null;
status: LinodeStatus;
Expand Down Expand Up @@ -338,6 +340,11 @@ export interface UserData {
user_data: string | null;
}

export interface CreateLinodePlacementGroupPayload {
id?: number | null;
compliant_only?: boolean | null;
}

export interface CreateLinodeRequest {
type?: string;
region?: string;
Expand All @@ -357,6 +364,7 @@ export interface CreateLinodeRequest {
interfaces?: InterfacePayload[];
metadata?: UserData;
firewall_id?: number;
placement_group?: CreateLinodePlacementGroupPayload;
}

export type RescueRequestObject = Pick<
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add placement group details to Create Linode payload ([#10195](https://github.com/linode/manager/pull/10195))
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const Autocomplete = <
noMarginTop={noMarginTop}
placeholder={placeholder || 'Select an option'}
required={textFieldProps?.InputProps?.required}
tooltipText={textFieldProps?.tooltipText}
{...params}
{...textFieldProps}
InputProps={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { LabelAndTagsPanel } from './LabelAndTagsPanel';
import { DetailsPanel } from './DetailsPanel';

const onLabelChange = vi.fn();
const onTagsChange = vi.fn();
Expand All @@ -12,7 +12,7 @@ const TAG_LABEL = 'Custom Label';
describe('Tags list', () => {
it('should render tags input if tagsInputProps are specified', () => {
const { getByLabelText, queryByText } = renderWithTheme(
<LabelAndTagsPanel
<DetailsPanel
labelFieldProps={{
label: INPUT_LABEL,
onChange: onLabelChange,
Expand All @@ -33,7 +33,7 @@ describe('Tags list', () => {

it('should render error text if errorText or tagError is specified', () => {
const { getByText } = renderWithTheme(
<LabelAndTagsPanel
<DetailsPanel
labelFieldProps={{
errorText: 'Your label is rude!',
label: INPUT_LABEL,
Expand All @@ -58,7 +58,7 @@ describe('Tags list', () => {

it('should NOT render tags input if tagsInputProps are NOT specified', () => {
const { queryByLabelText } = renderWithTheme(
<LabelAndTagsPanel
<DetailsPanel
labelFieldProps={{
label: INPUT_LABEL,
onChange: onLabelChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
} from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect';
import { TagsInput, TagsInputProps } from 'src/components/TagsInput/TagsInput';
import { TextField, TextFieldProps } from 'src/components/TextField';
import { TooltipIcon } from 'src/components/TooltipIcon';
import { Typography } from 'src/components/Typography';
import { useFlags } from 'src/hooks/useFlags';

Expand All @@ -23,15 +22,15 @@ Add your virtual machine (VM) to a group to best meet your needs.
You may want to group VMs closer together to help improve performance, or further apart to enable high-availability configurations.
Learn more.`;

interface LabelAndTagsProps {
interface DetailsPanelProps {
error?: string;
labelFieldProps?: TextFieldProps;
placementGroupsSelectProps?: PlacementGroupsSelectProps;
regions?: Region[];
tagsInputProps?: TagsInputProps;
}

export const LabelAndTagsPanel = (props: LabelAndTagsProps) => {
export const DetailsPanel = (props: DetailsPanelProps) => {
const theme = useTheme();
const flags = useFlags();
const showPlacementGroups = Boolean(flags.vmPlacement);
Expand All @@ -51,7 +50,15 @@ export const LabelAndTagsPanel = (props: LabelAndTagsProps) => {
}}
data-qa-label-header
>
<Typography
sx={(theme) => ({ marginBottom: theme.spacing(2) })}
variant="h2"
>
Details
</Typography>

{error && <Notice text={error} variant="error" />}

<TextField
{...(labelFieldProps || {
label: 'Label',
Expand All @@ -60,7 +67,9 @@ export const LabelAndTagsPanel = (props: LabelAndTagsProps) => {
data-qa-label-input
noMarginTop
/>

{tagsInputProps && <TagsInput {...tagsInputProps} />}

{showPlacementGroups && (
<>
{!placementGroupsSelectProps?.selectedRegionId && (
Expand All @@ -85,16 +94,7 @@ export const LabelAndTagsPanel = (props: LabelAndTagsProps) => {
},
width: '400px',
}}
/>
<TooltipIcon
sxTooltipIcon={{
marginBottom: '6px',
marginLeft: theme.spacing(),
padding: 0,
}}
status="help"
text={tooltipText}
tooltipPosition="right"
textFieldProps={{ tooltipText }}
/>
</Box>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { PlacementGroup } from '@linode/api-v4';
import { AFFINITY_TYPES, PlacementGroup } from '@linode/api-v4';
import { APIError } from '@linode/api-v4/lib/types';
import { SxProps } from '@mui/system';
import * as React from 'react';

import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { TextFieldProps } from 'src/components/TextField';
import { useUnpaginatedPlacementGroupsQuery } from 'src/queries/placementGroups';

export interface PlacementGroupsSelectProps {
Expand All @@ -23,6 +24,7 @@ export interface PlacementGroupsSelectProps {
renderOptionLabel?: (placementGroups: PlacementGroup) => string;
selectedRegionId?: string;
sx?: SxProps;
textFieldProps?: Partial<TextFieldProps>;
}

export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
Expand All @@ -40,6 +42,7 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
renderOptionLabel,
selectedRegionId,
sx,
...textFieldProps
} = props;

const {
Expand All @@ -61,7 +64,9 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
getOptionLabel={(placementGroupsOptions: PlacementGroup) =>
renderOptionLabel
? renderOptionLabel(placementGroupsOptions)
: `${placementGroupsOptions.label} (${placementGroupsOptions.affinity_type})`
: `${placementGroupsOptions.label} (${
AFFINITY_TYPES[placementGroupsOptions?.affinity_type]
})`
}
noOptionsText={
noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading)
Expand Down Expand Up @@ -93,6 +98,7 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
options={placementGroupsOptions ?? []}
placeholder="Select a Placement Group"
sx={sx}
{...textFieldProps}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PlacementGroup } from '@linode/api-v4';
import {
CreateLinodePlacementGroupPayload,
InterfacePayload,
PriceObject,
restoreBackup,
Expand All @@ -17,9 +18,9 @@ import { AccessPanel } from 'src/components/AccessPanel/AccessPanel';
import { Box } from 'src/components/Box';
import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary';
import { CircleProgress } from 'src/components/CircleProgress';
import { DetailsPanel } from 'src/components/DetailsPanel/DetailsPanel';
import { DocsLink } from 'src/components/DocsLink/DocsLink';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LabelAndTagsPanel } from 'src/components/LabelAndTagsPanel/LabelAndTagsPanel';
import { Link } from 'src/components/Link';
import { Notice } from 'src/components/Notice/Notice';
import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel';
Expand All @@ -39,11 +40,11 @@ import { RegionsProps } from 'src/containers/regions.container';
import { WithTypesProps } from 'src/containers/types.container';
import { WithLinodesProps } from 'src/containers/withLinodes.container';
import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox';
import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities';
import {
getMonthlyAndHourlyNodePricing,
utoa,
} from 'src/features/Linodes/LinodesCreate/utilities';
import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities';
import { SMTPRestrictionText } from 'src/features/Linodes/SMTPRestrictionText';
import { hasPlacementGroupReachedCapacity } from 'src/features/PlacementGroups/utils';
import {
Expand Down Expand Up @@ -164,6 +165,7 @@ const errorMap = [
'interfaces[1].ipam_address',
'interfaces[0].subnet_id',
'ipv4.vpc',
'placement_group',
];

type InnerProps = WithTypesRegionsAndImages &
Expand Down Expand Up @@ -633,7 +635,7 @@ export class LinodeCreate extends React.PureComponent<
showTransfer
types={this.filterTypes()}
/>
<LabelAndTagsPanel
<DetailsPanel
labelFieldProps={{
disabled: userCannotCreateLinode,
errorText: hasErrorFor.label,
Expand All @@ -654,7 +656,8 @@ export class LinodeCreate extends React.PureComponent<
? tagsInputProps
: undefined
}
data-qa-label-and-tags-panel
data-qa-details-panel
error={hasErrorFor.placement_group}
regions={regionsData!}
/>
{/* Hide for backups and clone */}
Expand Down Expand Up @@ -833,6 +836,11 @@ export class LinodeCreate extends React.PureComponent<
'VPCs'
);

const placement_group_payload: CreateLinodePlacementGroupPayload = {
compliant_only: true,
id: this.props.placementGroupSelection?.id,
};

// eslint-disable-next-line sonarjs/no-unused-collection
const interfaces: InterfacePayload[] = [];

Expand All @@ -845,13 +853,16 @@ export class LinodeCreate extends React.PureComponent<
this.props.firewallId !== -1 ? this.props.firewallId : undefined,
image: this.props.selectedImageID,
label: this.props.label,
placement_group: this.props.flags.vmPlacement
? placement_group_payload
: undefined,
private_ip: this.props.privateIPEnabled,
region: this.props.selectedRegionID,
root_pass: this.props.password,
stackscript_data: this.props.selectedUDFs,

// StackScripts
stackscript_id: this.props.selectedStackScriptID,

tags: this.props.tags
? this.props.tags.map((eachTag) => eachTag.label)
: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import withAgreements, {
AgreementsProps,
} from 'src/features/Account/Agreements/withAgreements';
import { hasPlacementGroupReachedCapacity } from 'src/features/PlacementGroups/utils';
import {
accountAgreementsQueryKey,
reportAgreementSigningError,
Expand All @@ -68,11 +69,11 @@ 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';
Expand Down Expand Up @@ -751,6 +752,36 @@ class LinodeCreateContainer extends React.PureComponent<CombinedProps, State> {
}
}

if (payload.placement_group) {
const error = hasPlacementGroupReachedCapacity({
placementGroup: this.state.placementGroupSelection!,
region: this.props.regionsData.find(
(r) => r.id === this.state.selectedRegionID
)!,
});
if (error) {
this.setState(
{
errors: [
{
field: 'placement_group',
reason: `${this.state.placementGroupSelection?.label} (${
this.state.placementGroupSelection?.affinity_type ===
'affinity'
? 'Affinity'
: 'Anti-affinity'
}) doesn't have any capacity for this Linode.`,
},
],
},
() => {
scrollErrorIntoView();
}
);
return;
}
}

// Validation for VPC fields
if (
this.state.selectedVPCId !== undefined &&
Expand Down
6 changes: 6 additions & 0 deletions packages/validation/src/linodes.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ const MetadataSchema = object({
user_data: string().notRequired().nullable(true),
});

const PlacementGroupPayloadSchema = object({
id: number().notRequired().nullable(true),
compliant_only: boolean().notRequired().nullable(true),
});

export const CreateLinodeSchema = object({
type: string().ensure().required('Plan is required.'),
region: string().ensure().required('Region is required.'),
Expand Down Expand Up @@ -294,6 +299,7 @@ export const CreateLinodeSchema = object({
interfaces: LinodeInterfacesSchema,
metadata: MetadataSchema,
firewall_id: number().notRequired(),
placement_group: PlacementGroupPayloadSchema,
});

const alerts = object({
Expand Down