feat(gpu-service): use grouped flavor select in instance type create drawer
This commit is contained in:
@@ -138,6 +138,8 @@ export default {
|
||||
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||
'gpuservice.instanceType.flavor': 'Flavor',
|
||||
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
|
||||
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
|
||||
'gpuservice.instanceType.product': 'Product',
|
||||
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||
|
||||
@@ -137,6 +137,8 @@ export default {
|
||||
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||
'gpuservice.instanceType.flavor': 'Flavor',
|
||||
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
|
||||
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
|
||||
'gpuservice.instanceType.product': 'Product',
|
||||
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||
|
||||
@@ -136,6 +136,8 @@ export default {
|
||||
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||
'gpuservice.instanceType.flavor': 'Flavor',
|
||||
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
|
||||
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
|
||||
'gpuservice.instanceType.product': 'Product',
|
||||
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||
|
||||
@@ -132,6 +132,8 @@ export default {
|
||||
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||
'gpuservice.instanceType.flavor': 'Flavor',
|
||||
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
|
||||
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
|
||||
'gpuservice.instanceType.product': 'Product',
|
||||
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||
|
||||
@@ -125,6 +125,8 @@ export default {
|
||||
'gpuservice.instanceType.add': '添加实例类型',
|
||||
'gpuservice.instanceType.flavor': '规格',
|
||||
'gpuservice.instanceType.flavor.required': '请选择实例类型规格',
|
||||
'gpuservice.instanceType.flavor.gpuGroup': 'GPU 算力',
|
||||
'gpuservice.instanceType.flavor.cpuGroup': 'CPU 算力',
|
||||
'gpuservice.instanceType.product': '商品',
|
||||
'gpuservice.instanceType.unitCpu': '单位 CPU',
|
||||
'gpuservice.instanceType.unitCpu.tip': '每 GPU 对应多少 CPU',
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import Separator from '@/pages/llmodels/components/separator';
|
||||
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Typography, message } from 'antd';
|
||||
import { message } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FlavorItem, FormData } from '../config/types';
|
||||
import GPUServiceInstanceTypeForm from '../forms';
|
||||
import useQueryFlavors from '../services/use-query-flavors';
|
||||
import styles from '../styles/instance-types.module.less';
|
||||
import FlavorList from './flavor-list';
|
||||
|
||||
type AddInstanceTypeModalProps = {
|
||||
title: string;
|
||||
@@ -18,28 +15,6 @@ type AddInstanceTypeModalProps = {
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ColTitle: React.FC<{
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}> = ({ children, style }) => (
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 16,
|
||||
margin: 0,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
backgroundColor: 'var(--ant-color-bg-elevated)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography.Title>
|
||||
);
|
||||
|
||||
const AddInstanceTypeModal: React.FC<AddInstanceTypeModalProps> = ({
|
||||
title,
|
||||
open,
|
||||
@@ -101,63 +76,37 @@ const AddInstanceTypeModal: React.FC<AddInstanceTypeModalProps> = ({
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
styles={{
|
||||
wrapper: { width: 'min(900px, calc(100vw - 220px))' },
|
||||
wrapper: { width: 'min(600px, calc(100vw - 220px))' },
|
||||
body: { overflowY: 'hidden' }
|
||||
}}
|
||||
footer={false}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.colWrapper}>
|
||||
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
|
||||
<div className={styles.panelBody}>
|
||||
<div className={styles.stickyHead}>
|
||||
<ColTitle
|
||||
style={{
|
||||
paddingBottom: 0
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
|
||||
</ColTitle>
|
||||
</div>
|
||||
<FlavorList
|
||||
value={selectedFlavor?.name}
|
||||
dataList={flavorList}
|
||||
loading={flavorLoading}
|
||||
onChange={setSelectedFlavor}
|
||||
/>
|
||||
</div>
|
||||
</ColumnWrapper>
|
||||
<Separator />
|
||||
</div>
|
||||
<div className={styles.formWrapper}>
|
||||
<ColumnWrapper
|
||||
styles={{ container: { paddingBlock: 0 } }}
|
||||
footer={
|
||||
<ModalFooter
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
style={{
|
||||
padding: '16px 24px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ColTitle>
|
||||
{intl.formatMessage({ id: 'common.title.config' })}
|
||||
</ColTitle>
|
||||
<GPUServiceInstanceTypeForm
|
||||
ref={form}
|
||||
open={open}
|
||||
selectedFlavor={selectedFlavor}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
/>
|
||||
</ColumnWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<ColumnWrapper
|
||||
styles={{ container: { paddingBlock: 0 } }}
|
||||
footer={
|
||||
<ModalFooter
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
style={{
|
||||
padding: '16px 24px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GPUServiceInstanceTypeForm
|
||||
ref={form}
|
||||
open={open}
|
||||
selectedFlavor={selectedFlavor}
|
||||
flavorList={flavorList}
|
||||
flavorLoading={flavorLoading}
|
||||
onFlavorChange={setSelectedFlavor}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
/>
|
||||
</ColumnWrapper>
|
||||
</GSDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { validateLabelNameRegxFor63 } from '@/config';
|
||||
import {
|
||||
AutoTooltip,
|
||||
Input as CInput,
|
||||
InputNumber,
|
||||
Select as SealSelect,
|
||||
ThemeTag,
|
||||
useAppUtils
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form } from 'antd';
|
||||
import { Flex, Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { formatMemoryDisplay } from '../../instances/config';
|
||||
import { manufactureColorMap } from '../../templates/config';
|
||||
import { formatManufacturer } from '../../utils';
|
||||
import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config';
|
||||
import { FlavorItem, FormData } from '../config/types';
|
||||
import styles from '../styles/instance-types.module.less';
|
||||
|
||||
// RAM / storage are entered as a plain number in GB but stored/submitted as a
|
||||
// "Gi" quantity string. These drive the FormItem's submit (`normalize`) and
|
||||
@@ -23,16 +29,39 @@ const giValueProps = (value?: string | null) => ({
|
||||
interface InstanceTypeFormProps {
|
||||
ref?: any;
|
||||
open: boolean;
|
||||
// The flavor picked in the drawer's first column. Its acceleratorGroup /
|
||||
// The flavor picked from the flavor Select. Its acceleratorGroup /
|
||||
// generalGroup / acceleratable are copied into the created instance type.
|
||||
selectedFlavor?: FlavorItem | null;
|
||||
flavorList: FlavorItem[];
|
||||
flavorLoading?: boolean;
|
||||
onFlavorChange: (flavor: FlavorItem | null) => void;
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
onFinishFailed?: (errorInfo: any) => void;
|
||||
}
|
||||
|
||||
// A flavor's title mirrors the flavor card: a generic (no product, no/`generic`
|
||||
// manufacturer, non-acceleratable) flavor reads as "CPU-only".
|
||||
const getFlavorTitle = (flavor: FlavorItem) => {
|
||||
const spec = flavor.spec || {};
|
||||
const manufacturer = spec.manufacturer || '';
|
||||
const isCpuOnly =
|
||||
!spec.acceleratable &&
|
||||
!spec.product &&
|
||||
(!manufacturer || manufacturer.toLowerCase() === 'generic');
|
||||
return isCpuOnly ? 'CPU-only' : spec.product || flavor.name || '-';
|
||||
};
|
||||
|
||||
const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
|
||||
(props, ref) => {
|
||||
const { open, selectedFlavor, onFinish, onFinishFailed } = props;
|
||||
const {
|
||||
open,
|
||||
selectedFlavor,
|
||||
flavorList,
|
||||
flavorLoading,
|
||||
onFlavorChange,
|
||||
onFinish,
|
||||
onFinishFailed
|
||||
} = props;
|
||||
const intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const [form] = Form.useForm<FormData>();
|
||||
@@ -68,6 +97,119 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
|
||||
}
|
||||
}));
|
||||
|
||||
// A flavor's dropdown / selected label shows the same info as the flavor
|
||||
// Secondary line, dot-separated: manufacturer · memory · sliceable. memory
|
||||
// and sliceable apply to accelerator (GPU) flavors only; sliceable stays a
|
||||
// tag. Returns null when a (generic) flavor has nothing to show.
|
||||
const renderFlavorMeta = (flavor: FlavorItem) => {
|
||||
const spec = flavor.spec || {};
|
||||
const manufacturer = spec.manufacturer || '';
|
||||
const color = manufactureColorMap[manufacturer] ?? 'purple';
|
||||
const memory = spec.acceleratable
|
||||
? formatMemoryDisplay(spec.memory ?? undefined)
|
||||
: '';
|
||||
|
||||
const pieces: React.ReactNode[] = [];
|
||||
if (manufacturer) {
|
||||
pieces.push(
|
||||
<ThemeTag
|
||||
key="vendor"
|
||||
color={color}
|
||||
style={{ fontWeight: 400, marginInlineEnd: 0 }}
|
||||
>
|
||||
{formatManufacturer(manufacturer)}
|
||||
</ThemeTag>
|
||||
);
|
||||
}
|
||||
if (memory) {
|
||||
pieces.push(<span key="memory">{memory}</span>);
|
||||
}
|
||||
if (spec.acceleratable && spec.sliceable) {
|
||||
pieces.push(
|
||||
<ThemeTag
|
||||
key="sliceable"
|
||||
color="geekblue"
|
||||
style={{ fontWeight: 400, marginInlineEnd: 0 }}
|
||||
>
|
||||
{intl.formatMessage({ id: 'gpuservice.instance.sliceable' })}
|
||||
</ThemeTag>
|
||||
);
|
||||
}
|
||||
if (!pieces.length) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
{pieces.flatMap((piece, index) =>
|
||||
index === 0
|
||||
? [piece]
|
||||
: [
|
||||
<span
|
||||
key={`dot-${index}`}
|
||||
style={{ color: 'var(--ant-color-text-quaternary)' }}
|
||||
>
|
||||
·
|
||||
</span>,
|
||||
piece
|
||||
]
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// Dropdown item: two lines — name on top, meta row below.
|
||||
const renderFlavorOption = (flavor: FlavorItem) => (
|
||||
<Flex vertical gap={4} style={{ minWidth: 0, padding: '2px 0' }}>
|
||||
<AutoTooltip ghost minWidth={20} maxWidth={'100%'}>
|
||||
{getFlavorTitle(flavor)}
|
||||
</AutoTooltip>
|
||||
{renderFlavorMeta(flavor)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// Collapsed selected value: single line — name then meta inline.
|
||||
const renderFlavorSelected = (flavor: FlavorItem) => (
|
||||
<Flex align="center" gap={8} style={{ minWidth: 0 }}>
|
||||
<AutoTooltip ghost minWidth={20} maxWidth={200}>
|
||||
{getFlavorTitle(flavor)}
|
||||
</AutoTooltip>
|
||||
{renderFlavorMeta(flavor)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// Split flavors into two groups: CPU compute (generic) and GPU compute
|
||||
// (accelerator). Groups render as labeled sections in the dropdown.
|
||||
const toFlavorOption = (flavor: FlavorItem) => ({
|
||||
value: flavor.name,
|
||||
label: getFlavorTitle(flavor),
|
||||
flavor
|
||||
});
|
||||
const cpuFlavors = flavorList.filter((flavor) => !flavor.spec?.acceleratable);
|
||||
const gpuFlavors = flavorList.filter((flavor) => flavor.spec?.acceleratable);
|
||||
const flavorOptions = [
|
||||
cpuFlavors.length && {
|
||||
label: intl.formatMessage({
|
||||
id: 'gpuservice.instanceType.flavor.cpuGroup'
|
||||
}),
|
||||
title: 'cpu',
|
||||
options: cpuFlavors.map(toFlavorOption)
|
||||
},
|
||||
gpuFlavors.length && {
|
||||
label: intl.formatMessage({
|
||||
id: 'gpuservice.instanceType.flavor.gpuGroup'
|
||||
}),
|
||||
title: 'gpu',
|
||||
options: gpuFlavors.map(toFlavorOption)
|
||||
}
|
||||
].filter(Boolean) as any;
|
||||
|
||||
const handleFinish = async (values: FormData) => {
|
||||
// The hardware group / acceleratable flags are not user-editable; they
|
||||
// come from the chosen flavor. os is fixed to lowercase "linux". ram /
|
||||
@@ -117,6 +259,33 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<SealSelect
|
||||
label={intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
|
||||
required
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
classNames={{ popup: { root: styles.flavorDropdown } }}
|
||||
loading={flavorLoading}
|
||||
value={selectedFlavor?.name}
|
||||
options={flavorOptions}
|
||||
onChange={(val: string) =>
|
||||
onFlavorChange(
|
||||
flavorList.find((flavor) => flavor.name === val) ?? null
|
||||
)
|
||||
}
|
||||
optionRender={(option: any) =>
|
||||
renderFlavorOption(option.data.flavor)
|
||||
}
|
||||
labelRender={({ value }) => {
|
||||
const flavor = flavorList.find((item) => item.name === value);
|
||||
return flavor
|
||||
? renderFlavorSelected(flavor)
|
||||
: ((value ?? '') as React.ReactNode);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<CInput.Input
|
||||
disabled
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
// ============ flavor select dropdown ============
|
||||
.flavorDropdown {
|
||||
:global {
|
||||
// tighten the indent of grouped options
|
||||
.ant-select-item-option-grouped {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
// divider between options
|
||||
.ant-select-item-option {
|
||||
border-block-end: 1px solid var(--ant-color-border-secondary);
|
||||
|
||||
&:last-child {
|
||||
border-block-end: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ list card (Linear-style, minimal) ============
|
||||
.listCard {
|
||||
height: auto !important;
|
||||
|
||||
Reference in New Issue
Block a user