Files
gpustack-ui/src/pages/cluster-management/components/pool-form.tsx
T

475 lines
13 KiB
TypeScript

import {
regionInstanceTypeListAtom,
regionOSImageListAtom
} from '@/atoms/clusters';
import CollapsibleContainer, {
CollapsibleContainerProps
} from '@/components/collapse-container';
import IconFont from '@/components/icon-font';
import LabelSelector from '@/components/label-selector';
import AutoComplete from '@/components/seal-form/auto-complete';
import SealInputNumber from '@/components/seal-form/input-number';
import SealInput from '@/components/seal-form/seal-input';
import SealSelect from '@/components/seal-form/seal-select';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useAppUtils from '@/hooks/use-app-utils';
import { CardContainer } from '@/pages/llmodels/components/gpu-card';
import { DeleteOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd';
import { useAtom } from 'jotai';
import _ from 'lodash';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState
} from 'react';
import styled from 'styled-components';
import { ProviderType, instanceTypeFieldMap, vendorIconMap } from '../config';
import { NodePoolFormData as FormData } from '../config/types';
import VolumesConfig from './volumes-config';
const Container = styled.div`
pointer-events: auto;
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0 16px;
.ant-form-item:nth-child(1) {
grid-column: 1 / 3;
}
.ant-form-item:nth-child(2) {
grid-column: 1 / 3;
}
.ant-form-item:nth-child(5) {
grid-column: 1 / 3;
}
.ant-form-item:nth-child(6) {
grid-column: 1 / 3;
}
.ant-form-item:nth-child(7) {
grid-column: 1 / 3;
}
`;
const NoContent = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding-block: 12px;
`;
const OptionItem = styled.div.attrs({
className: 'option-item'
})`
display: flex;
gap: 8px;
align-items: center;
`;
const DescriptionWrapper = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px 8px;
font-weight: 400;
`;
const NotFoundContent = () => {
const intl = useIntl();
return (
<NoContent>
{intl.formatMessage({ id: 'clusters.create.noInstanceTypes' })}
</NoContent>
);
};
const RenderLabel = (data: {
label: React.ReactNode;
vendor: string;
style?: React.CSSProperties;
}) => {
const { label, vendor, style } = data;
return (
<div style={style} className="flex-center gap-8">
<IconFont type={_.get(vendorIconMap, vendor, 'icon-gpu1')}></IconFont>
{label}
</div>
);
};
export const RenderOption = (option: any) => {
const { data = {}, styles } = option;
const entries = Object.entries(data?.specInfo || {});
return (
<CardContainer
key={data.value}
header={
<RenderLabel
label={data.description || data.label}
vendor={data.vendor}
style={styles?.header}
/>
}
description={
entries.length > 0 && (
<DescriptionWrapper style={{ ...styles?.description }}>
{Object.entries(data?.specInfo)
.filter(([key, value]) => value)
.map(([key, value]) => (
<OptionItem key={key}>
<span className="label">
{_.get(instanceTypeFieldMap, key, key)}:
</span>
<span className="value">{value as string}</span>
</OptionItem>
))}
</DescriptionWrapper>
)
}
/>
);
};
type AddModalProps = {
ref: any;
name?: string;
action: PageActionType;
provider: ProviderType; // 'kubernetes' | 'custom' | 'digitalocean';
currentData?: FormData | null;
onFinish: (values: FormData) => void;
onDelete?: () => void;
showDelete?: boolean;
collapseProps?: CollapsibleContainerProps;
};
const InstanceSpecData: React.FC<{ instanceSpec: Record<string, any> }> = ({
instanceSpec
}) => {
return (
<>
{Object.entries(instanceSpec)
.filter(([key, value]) => value)
.map(([key, value]) => (
<Form.Item key={key} name={['instance_spec', key]} hidden>
<SealInput.Input />
</Form.Item>
))}
</>
);
};
const PoolForm: React.FC<AddModalProps> = forwardRef((props, ref) => {
const {
action,
name = 'workerPoolForm',
onFinish,
onDelete,
showDelete,
currentData,
collapseProps
} = props;
const [instanceTypeList] = useAtom(regionInstanceTypeListAtom);
const [osImageList] = useAtom(regionOSImageListAtom);
const { collapsible, onToggle, ...restCollapseProps } = collapseProps || {};
const [form] = Form.useForm();
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const labels = Form.useWatch('labels', form);
const title = Form.useWatch('name', form);
const [instanceSpec, setInstanceSpec] = useState<Record<string, any>>({});
useEffect(() => {
if (currentData) {
// when change the region, shoudle check the instance type and the os Image.
const selectOSImage = osImageList.find(
(item) => item.os_image === currentData.os_image
);
const selectInstanceType = instanceTypeList.find(
(item) => item.value === currentData.instance_type
);
form.setFieldsValue({
...currentData,
instance_type: selectInstanceType?.value || '',
os_image: selectOSImage?.os_image || '',
image_name: selectOSImage?.value || ''
});
setInstanceSpec(() => {
return selectInstanceType ? currentData.instance_spec : {};
});
}
}, [currentData, instanceTypeList, osImageList]);
const imageList = useMemo(() => {
if (instanceSpec.count === 8) {
return osImageList.filter((item) => item.os_image === 'gpu-h100x8-base');
}
if (instanceSpec.count === 1 && instanceSpec.vendor === 'amd') {
return osImageList.filter((item) => item.os_image === 'gpu-amd-base');
}
if (instanceSpec.count === 1 && instanceSpec.vendor === 'nvidia') {
return osImageList.filter((item) => item.os_image === 'gpu-h100x1-base');
}
return osImageList;
}, [osImageList, instanceSpec]);
const instanceLabelRender = (data: {
label: React.ReactNode;
value: string | number;
}) => {
const currentInstanceSpec =
instanceTypeList.find((item) => item.value === data.value) ||
instanceSpec;
if (!currentInstanceSpec || _.isEmpty(currentInstanceSpec)) {
return null;
}
return (
<RenderLabel
label={data.label || currentInstanceSpec?.label || data.value}
vendor={currentInstanceSpec?.vendor || ''}
/>
);
};
const handleOsImageChange = (value: string, option: any) => {
form.setFieldsValue({
os_image: option.os_image || value
});
};
const handleInstanceTypeChange = (value: string, option: any) => {
setInstanceSpec({
...option.specInfo,
label: option.label,
vendor: option.vendor,
description: option.description,
count: option.count
});
form.setFieldsValue({
instance_spec: {
...option.specInfo,
label: option.label,
vendor: option.vendor,
description: option.description,
count: option.count
}
});
};
const filterInstanceOption = (inputValue: string, option: any) => {
return (
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
option.description.toLowerCase().includes(inputValue.toLowerCase())
);
};
useImperativeHandle(ref, () => ({
resetFields: () => {
form.resetFields();
},
submit: () => {
form.submit();
},
setFieldsValue: (values: any) => {
form.setFieldsValue(values);
},
getFieldsValue: () => {
return form.getFieldsValue();
},
validateFields: async () => {
return await form.validateFields();
}
}));
return (
<CollapsibleContainer
{...restCollapseProps}
title={title}
collapsible={collapsible}
onToggle={onToggle}
deleteBtn={
showDelete && (
<Button
onClick={onDelete}
icon={<DeleteOutlined />}
danger
type="text"
variant="filled"
color="danger"
size="small"
></Button>
)
}
>
<Form
name={name}
form={form}
onFinish={onFinish}
preserve={false}
scrollToFirstError={true}
initialValues={{
replicas: 1,
batch_size: 1
}}
>
<Container>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'common.table.name'
})}
required
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="instance_type"
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.workerpool.instanceType'
)
}
]}
>
<SealSelect
showSearch
filterOption={filterInstanceOption}
labelRender={instanceLabelRender}
notFoundContent={<NotFoundContent />}
label={intl.formatMessage({
id: 'clusters.workerpool.instanceType'
})}
required
options={instanceTypeList}
disabled={action === PageAction.EDIT}
optionRender={RenderOption}
onChange={handleInstanceTypeChange}
></SealSelect>
</Form.Item>
<Form.Item<FormData>
name="replicas"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.replicas')
}
]}
>
<SealInputNumber
label={intl.formatMessage({
id: 'clusters.workerpool.replicas'
})}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="batch_size"
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.workerpool.batchSize'
)
}
]}
>
<SealInputNumber
description={intl.formatMessage({
id: 'clusters.workerpool.batchSize.desc'
})}
label={intl.formatMessage({
id: 'clusters.workerpool.batchSize'
})}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="image_name"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.osImage')
}
]}
>
<AutoComplete
showSearch
allowClear
onChange={handleOsImageChange}
optionRender={(option) =>
RenderOption({
...option,
styles: { header: { marginBlock: 5 } }
})
}
options={imageList}
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'clusters.workerpool.osImage'
})}
required
></AutoComplete>
</Form.Item>
<Form.Item<FormData>
name="labels"
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (_.keys(value).length > 0) {
if (_.some(_.keys(value), (k: string) => !value[k])) {
return Promise.reject(
intl.formatMessage(
{
id: 'common.validate.value'
},
{
name: 'labels'
}
)
);
}
}
return Promise.resolve();
}
})
]}
>
<LabelSelector
label={intl.formatMessage({ id: 'resources.table.labels' })}
labels={labels || {}}
btnText={intl.formatMessage({ id: 'common.button.addLabel' })}
></LabelSelector>
</Form.Item>
<VolumesConfig disabled={action === PageAction.EDIT}></VolumesConfig>
<Form.Item<FormData> name="os_image" hidden>
<SealInput.Input></SealInput.Input>
</Form.Item>
<InstanceSpecData instanceSpec={instanceSpec} />
</Container>
</Form>
</CollapsibleContainer>
);
});
export default PoolForm;