fix: instance api alignment
This commit is contained in:
@@ -7,7 +7,7 @@ const Basic = () => {
|
||||
<>
|
||||
<Form.Item<FormData>
|
||||
data-field="name"
|
||||
name="name"
|
||||
name={['metadata', 'name']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
@@ -17,7 +17,7 @@ const Basic = () => {
|
||||
>
|
||||
<CInput.Input label="实例名称" required />
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name="description">
|
||||
<Form.Item<FormData> name={['spec', 'description']}>
|
||||
<CInput.TextArea label="描述" scaleSize />
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -11,21 +11,53 @@ import {
|
||||
useWrapperContext
|
||||
} from '@gpustack/core-ui';
|
||||
import { Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import {
|
||||
EnvItem as TemplateEnvItem,
|
||||
PortItem as TemplatePortItem
|
||||
} from '../../templates/config/types';
|
||||
import TemplateBasicForm from '../../templates/forms/basic';
|
||||
import { instanceTypeOptions, StorageModeValueMap } from '../config';
|
||||
import { DEFAULT_SSH_PUBLIC_KEY_NAME, StorageModeValueMap } from '../config';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import Basic from './basic';
|
||||
import InstanceTypeFormItem from './instance-type';
|
||||
import StorageVolume from './storage-volume';
|
||||
|
||||
const SSH_PORT = 22;
|
||||
|
||||
type InstanceFormValues = FormData & {
|
||||
// instance-type holders
|
||||
type?: string;
|
||||
gpu_count?: number;
|
||||
// template holders (mirrored into spec.* on submit)
|
||||
image?: string;
|
||||
command?: string[];
|
||||
ports?: TemplatePortItem[];
|
||||
env?: TemplateEnvItem[];
|
||||
volumeMount?: string;
|
||||
resources?: { cpu?: string; ram?: string };
|
||||
// storage holders
|
||||
storage_mode?: string;
|
||||
storage_name?: string;
|
||||
local_storage_size_gb?: number;
|
||||
// ssh holder
|
||||
enable_ssh?: boolean;
|
||||
};
|
||||
|
||||
interface InstanceFormProps {
|
||||
ref?: any;
|
||||
open: boolean;
|
||||
action: PageActionType;
|
||||
currentData?: ListItem | null;
|
||||
namespace?: string;
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -43,22 +75,28 @@ const requiredFields = {
|
||||
},
|
||||
[TABKeysMap.INSTANCE_TYPE]: {
|
||||
sort: 2,
|
||||
fields: ['instance_type_id']
|
||||
fields: ['type', 'gpu_count']
|
||||
},
|
||||
[TABKeysMap.TEMPLATE]: {
|
||||
sort: 3,
|
||||
fields: ['template_id', 'gpu_count']
|
||||
fields: ['image', 'ports']
|
||||
},
|
||||
[TABKeysMap.STORAGE]: {
|
||||
sort: 4,
|
||||
fields: ['storage_mode', 'storage_id', 'local_storage_size_gb']
|
||||
fields: ['storage_mode', 'storage_name', 'local_storage_size_gb']
|
||||
}
|
||||
};
|
||||
|
||||
const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
(props, ref) => {
|
||||
const { action, currentData, open, onFinish } = props;
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const {
|
||||
action,
|
||||
currentData,
|
||||
open,
|
||||
namespace = 'default',
|
||||
onFinish
|
||||
} = props;
|
||||
const [form] = Form.useForm<InstanceFormValues>();
|
||||
const scrollTabsRef = useRef<any>(null);
|
||||
const { getScrollElementScrollableHeight } = useWrapperContext();
|
||||
const {
|
||||
@@ -76,32 +114,41 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
]
|
||||
});
|
||||
|
||||
const segmentOptions = [
|
||||
{
|
||||
value: TABKeysMap.BASIC,
|
||||
label: '基础信息',
|
||||
icon: <IconFont type="icon-basic" />,
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.INSTANCE_TYPE,
|
||||
label: '实例类型',
|
||||
icon: <IconFont type="icon-gpu1" />,
|
||||
field: 'instanceType'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.TEMPLATE,
|
||||
label: '实例模板',
|
||||
icon: <IconFont type="icon-model" />,
|
||||
field: 'vendor'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.STORAGE,
|
||||
label: '存储卷',
|
||||
icon: <IconFont type="icon-storage-outlined" />,
|
||||
field: 'storage'
|
||||
}
|
||||
];
|
||||
const segmentOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: TABKeysMap.BASIC,
|
||||
label: '基础信息',
|
||||
icon: <IconFont type="icon-basic" />,
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.INSTANCE_TYPE,
|
||||
label: '实例类型',
|
||||
icon: <IconFont type="icon-gpu1" />,
|
||||
field: 'instanceType'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.TEMPLATE,
|
||||
label: '实例模板',
|
||||
icon: <IconFont type="icon-model" />,
|
||||
field: 'template'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.STORAGE,
|
||||
label: '存储卷',
|
||||
icon: <IconFont type="icon-storage-outlined" />,
|
||||
field: 'storage'
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const ports = (Form.useWatch('ports', form) || []) as TemplatePortItem[];
|
||||
const hasSshPort = useMemo(
|
||||
() => ports.some((p) => Number(p?.port) === SSH_PORT),
|
||||
[ports]
|
||||
);
|
||||
|
||||
const onTargetChange = (key: string) => {
|
||||
scrollTabsRef.current?.handleTargetChange(key);
|
||||
@@ -120,38 +167,132 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
}
|
||||
|
||||
if (action === PageAction.EDIT && currentData) {
|
||||
const persistentName = currentData.spec?.volume?.persistent?.name;
|
||||
const ephemeralCapacity = currentData.spec?.volume?.ephemeral?.capacity;
|
||||
const isExisting = !!persistentName;
|
||||
const isTemporary = !!ephemeralCapacity;
|
||||
const localSizeGb = ephemeralCapacity
|
||||
? Number(String(ephemeralCapacity).replace(/Gi$/i, '')) || undefined
|
||||
: undefined;
|
||||
|
||||
form.setFieldsValue({
|
||||
name: currentData.name,
|
||||
description: currentData.description,
|
||||
instance_type: currentData.instance_type,
|
||||
instance_type_id: currentData.instance_type_id,
|
||||
template_id: currentData.template_id,
|
||||
image: currentData.image,
|
||||
gpu_count: currentData.gpu_count,
|
||||
replicas: currentData.replicas,
|
||||
storage_mode: currentData.storage_mode,
|
||||
storage_id: currentData.storage_id,
|
||||
local_storage_size_gb: currentData.local_storage_size_gb
|
||||
metadata: {
|
||||
name: currentData.metadata?.name,
|
||||
namespace: currentData.metadata?.namespace
|
||||
},
|
||||
spec: { ...currentData.spec },
|
||||
type: currentData.spec?.type,
|
||||
gpu_count: currentData.spec?.resources?.accelerator
|
||||
? Number(currentData.spec.resources.accelerator) || 1
|
||||
: 1,
|
||||
// template holders mirrored from spec.*
|
||||
image: currentData.spec?.image,
|
||||
command: currentData.spec?.command || [],
|
||||
ports: (currentData.spec?.ports || []) as TemplatePortItem[],
|
||||
env: (currentData.spec?.env || []) as TemplateEnvItem[],
|
||||
volumeMount: currentData.spec?.volumeMount,
|
||||
resources: {
|
||||
cpu: currentData.spec?.resources?.cpu,
|
||||
ram: currentData.spec?.resources?.ram
|
||||
},
|
||||
storage_mode: isExisting
|
||||
? StorageModeValueMap.Existing
|
||||
: isTemporary
|
||||
? StorageModeValueMap.Temporary
|
||||
: StorageModeValueMap.Existing,
|
||||
storage_name: persistentName,
|
||||
local_storage_size_gb: localSizeGb,
|
||||
enable_ssh: !!currentData.spec?.sshPublicKey?.name
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultInstanceType = instanceTypeOptions[0];
|
||||
const defaultTemplate = mockTemplateData[0];
|
||||
const defaultStorage = mockStorageData[0];
|
||||
|
||||
form.setFieldsValue({
|
||||
instance_type: defaultInstanceType?.name,
|
||||
instance_type_id: defaultInstanceType?.id,
|
||||
template_id: defaultTemplate?.id,
|
||||
image: defaultTemplate?.image,
|
||||
metadata: {
|
||||
namespace
|
||||
},
|
||||
spec: {
|
||||
description: '',
|
||||
displayName: ''
|
||||
} as FormData['spec'],
|
||||
gpu_count: 1,
|
||||
replicas: 1,
|
||||
// template holders seeded from default template
|
||||
image: defaultTemplate?.image || '',
|
||||
command: defaultTemplate?.command || [],
|
||||
ports: (defaultTemplate?.ports || []) as TemplatePortItem[],
|
||||
env: (defaultTemplate?.env || []) as TemplateEnvItem[],
|
||||
volumeMount: defaultTemplate?.volumeMount || '',
|
||||
resources: {
|
||||
cpu: defaultTemplate?.resources?.cpu,
|
||||
ram: defaultTemplate?.resources?.ram
|
||||
},
|
||||
storage_mode: StorageModeValueMap.Existing,
|
||||
storage_id: defaultStorage?.id,
|
||||
local_storage_size_gb: 50
|
||||
storage_name: defaultStorage?.metadata?.name,
|
||||
local_storage_size_gb: 50,
|
||||
enable_ssh: false
|
||||
});
|
||||
}, [action, currentData, form, open]);
|
||||
}, [action, currentData, form, open, namespace]);
|
||||
|
||||
const buildPayload = (values: InstanceFormValues): FormData => {
|
||||
const acceleratorStr = values.gpu_count
|
||||
? String(values.gpu_count)
|
||||
: undefined;
|
||||
|
||||
const spec: FormData['spec'] = {
|
||||
...values.spec,
|
||||
type: values.type || values.spec?.type,
|
||||
image: values.image ?? values.spec?.image,
|
||||
command: values.command ?? values.spec?.command,
|
||||
ports: (values.ports ??
|
||||
values.spec?.ports) as FormData['spec']['ports'],
|
||||
env: (values.env ?? values.spec?.env) as FormData['spec']['env'],
|
||||
volumeMount: values.volumeMount ?? values.spec?.volumeMount,
|
||||
resources: {
|
||||
...values.spec?.resources,
|
||||
cpu: values.resources?.cpu ?? values.spec?.resources?.cpu,
|
||||
ram: values.resources?.ram ?? values.spec?.resources?.ram,
|
||||
accelerator: acceleratorStr
|
||||
}
|
||||
} as FormData['spec'];
|
||||
|
||||
// Storage volume mapping
|
||||
if (values.storage_mode === StorageModeValueMap.Existing) {
|
||||
spec.volume = { persistent: { name: values.storage_name || '' } };
|
||||
} else if (values.storage_mode === StorageModeValueMap.Temporary) {
|
||||
spec.volume = {
|
||||
ephemeral: {
|
||||
capacity: values.local_storage_size_gb
|
||||
? `${values.local_storage_size_gb}Gi`
|
||||
: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// SSH public key — include only when checkbox is checked AND port 22 is in ports
|
||||
const portsList = (values.ports || []) as TemplatePortItem[];
|
||||
const has22 = portsList.some((p) => Number(p?.port) === SSH_PORT);
|
||||
if (values.enable_ssh && has22) {
|
||||
spec.sshPublicKey = { name: DEFAULT_SSH_PUBLIC_KEY_NAME };
|
||||
} else {
|
||||
delete (spec as any).sshPublicKey;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
name: values.metadata?.name,
|
||||
namespace: values.metadata?.namespace || namespace
|
||||
},
|
||||
spec
|
||||
} as FormData;
|
||||
};
|
||||
|
||||
const handleFinish = async (values: InstanceFormValues) => {
|
||||
const payload = buildPayload(values);
|
||||
await onFinish(payload);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit: () => {
|
||||
@@ -178,7 +319,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
<Form
|
||||
name="gpuServiceInstanceForm"
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
onFinish={handleFinish}
|
||||
onFinishFailed={handleOnFinishFailed}
|
||||
preserve={false}
|
||||
>
|
||||
@@ -208,18 +349,16 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<Form.Item<FormData>
|
||||
name="enable_ssh"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<CheckboxField label={'SSH Terminal Access'}></CheckboxField>
|
||||
</Form.Item>
|
||||
<Form.Item<FormData>
|
||||
data-field="name"
|
||||
hidden
|
||||
name={['spec', 'sshPublicKey', 'name']}
|
||||
>
|
||||
{hasSshPort && (
|
||||
<Form.Item
|
||||
name="enable_ssh"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<CheckboxField label="SSH Terminal Access" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item<FormData> hidden name={['spec', 'sshPublicKey', 'name']}>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Empty, Input } from 'antd';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import InstanceTypeList from '../components/instance-type-list';
|
||||
import { instanceTypeOptions } from '../config';
|
||||
import { InstanceTypeItem } from '../config/types';
|
||||
|
||||
const DrawerBody = styled.div`
|
||||
display: flex;
|
||||
@@ -14,14 +14,16 @@ const DrawerBody = styled.div`
|
||||
|
||||
interface InstanceTypeOverlayProps {
|
||||
open: boolean;
|
||||
value?: number;
|
||||
value?: string;
|
||||
dataList: InstanceTypeItem[];
|
||||
onCancel: () => void;
|
||||
onChange?: (value: number) => void;
|
||||
onChange?: (item: InstanceTypeItem) => void;
|
||||
}
|
||||
|
||||
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
|
||||
open,
|
||||
value,
|
||||
dataList,
|
||||
onCancel,
|
||||
onChange
|
||||
}) => {
|
||||
@@ -30,22 +32,23 @@ const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
|
||||
const filteredOptions = useMemo(() => {
|
||||
const currentKeyword = keyword.trim().toLowerCase();
|
||||
if (!currentKeyword) {
|
||||
return instanceTypeOptions;
|
||||
return dataList;
|
||||
}
|
||||
|
||||
return instanceTypeOptions.filter((item) => {
|
||||
return dataList.filter((item) => {
|
||||
const name = item.metadata?.name || item.name || '';
|
||||
return [
|
||||
item.name,
|
||||
String(item.gpu_count),
|
||||
String(item.vram),
|
||||
String(item.ram),
|
||||
String(item.vCPU)
|
||||
].some((text) => text.toLowerCase().includes(currentKeyword));
|
||||
name,
|
||||
item.spec?.memory ?? '',
|
||||
item.status?.cpu?.capacity ?? '',
|
||||
item.status?.ram?.capacity ?? '',
|
||||
item.status?.accelerator?.remaining ?? ''
|
||||
].some((text) => String(text).toLowerCase().includes(currentKeyword));
|
||||
});
|
||||
}, [keyword]);
|
||||
}, [keyword, dataList]);
|
||||
|
||||
const handleChange = (id: number) => {
|
||||
onChange?.(id);
|
||||
const handleChange = (item: InstanceTypeItem) => {
|
||||
onChange?.(item);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -70,7 +73,7 @@ const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索名称、GPU、显存、内存或 vCPU"
|
||||
placeholder="搜索名称、显存、内存或 vCPU"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
Input as CInput,
|
||||
InputNumber as CInputNumber
|
||||
} from '@gpustack/core-ui';
|
||||
import { Flex, Form } from 'antd';
|
||||
import { Flex, Form, Tag } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { instanceTypeOptions, InstanceTypeStatusValueMap } from '../config';
|
||||
import { FormData } from '../config/types';
|
||||
import { InstanceTypePhaseValueMap } from '../config';
|
||||
import { FormData, InstanceTypeItem } from '../config/types';
|
||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||
import InstanceTypeOverlay from './instance-type-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
@@ -16,7 +17,6 @@ const FieldBlock = styled.div`
|
||||
|
||||
const SelectedCard = styled.div`
|
||||
display: grid;
|
||||
height: 102px;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -38,83 +38,112 @@ const SummaryTitle = styled.div`
|
||||
const SummaryMeta = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
interface InstanceTypePickerProps {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
dataList: InstanceTypeItem[];
|
||||
}
|
||||
|
||||
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
||||
value,
|
||||
onChange
|
||||
onChange,
|
||||
dataList
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === value);
|
||||
}, [value]);
|
||||
return dataList.find(
|
||||
(item) => (item.metadata?.name || item.name) === value
|
||||
);
|
||||
}, [value, dataList]);
|
||||
|
||||
const handleChange = (item: InstanceTypeItem) => {
|
||||
onChange?.(item.metadata?.name || item.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedCard>
|
||||
<div>
|
||||
<SummaryTitle>
|
||||
<Flex gap={16}>
|
||||
<Flex gap={16} align="center">
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{selected?.name || '请选择实例类型'}
|
||||
{(selected?.metadata?.name || selected?.name) ??
|
||||
'请选择实例类型'}
|
||||
</AutoTooltip>
|
||||
{/* <Tag
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: 'var(--ant-color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
库存 {selected?.gpu_count}
|
||||
</Tag> */}
|
||||
{selected && (
|
||||
<Tag
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: 'var(--ant-color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
库存 {selected?.status?.accelerator?.remaining ?? '-'}
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
</SummaryTitle>
|
||||
<SummaryMeta>
|
||||
<span>显存 {selected?.vram ?? '-'} GiB</span>
|
||||
<span>显存 {selected?.spec?.memory ?? '-'}</span>
|
||||
<Flex gap={16}>
|
||||
<span>内存 {selected?.ram ?? '-'} GiB</span>
|
||||
<span>CPU {selected?.vCPU ?? '-'}</span>
|
||||
<span>内存 {selected?.status?.ram?.capacity ?? '-'}</span>
|
||||
<span>vCPU {selected?.status?.cpu?.capacity ?? '-'}</span>
|
||||
</Flex>
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
{/* <Button onClick={() => setOpen(true)}>更换</Button> */}
|
||||
</SelectedCard>
|
||||
<InstanceTypeOverlay
|
||||
open={open}
|
||||
value={value}
|
||||
dataList={dataList}
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InstanceTypeFormItem = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const instanceTypeId = Form.useWatch('instance_type_id', form);
|
||||
const form = Form.useFormInstance<
|
||||
FormData & { type?: string; gpu_count?: number }
|
||||
>();
|
||||
const typeName = Form.useWatch('type', form) as string | undefined;
|
||||
|
||||
const selectedInstanceType = useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
|
||||
}, [instanceTypeId]);
|
||||
|
||||
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
|
||||
const { detailData, fetchData } = useQueryInstanceTypes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceTypeId) {
|
||||
const defaultType = instanceTypeOptions.find(
|
||||
(item) => item.status === InstanceTypeStatusValueMap.Available
|
||||
fetchData({});
|
||||
}, [fetchData]);
|
||||
|
||||
const dataList = detailData?.items || [];
|
||||
|
||||
const selectedInstanceType = useMemo(() => {
|
||||
return dataList.find(
|
||||
(item) => (item.metadata?.name || item.name) === typeName
|
||||
);
|
||||
}, [dataList, typeName]);
|
||||
|
||||
const maxGpuCount = useMemo(() => {
|
||||
const remaining = selectedInstanceType?.status?.accelerator?.remaining;
|
||||
const num = remaining ? Number(remaining) : NaN;
|
||||
return Number.isFinite(num) && num > 0 ? num : 1;
|
||||
}, [selectedInstanceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeName && dataList.length > 0) {
|
||||
const defaultType = dataList.find(
|
||||
(item) => item.status?.phase === InstanceTypePhaseValueMap.Available
|
||||
);
|
||||
if (defaultType) {
|
||||
form.setFieldValue('instance_type_id', defaultType.id);
|
||||
form.setFieldValue(
|
||||
'type',
|
||||
defaultType.metadata?.name || defaultType.name
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -127,17 +156,13 @@ const InstanceTypeFormItem = () => {
|
||||
if (currentGpuCount < 1) {
|
||||
form.setFieldValue('gpu_count', 1);
|
||||
}
|
||||
|
||||
if (selectedInstanceType) {
|
||||
form.setFieldValue('instance_type', selectedInstanceType.name);
|
||||
}
|
||||
}, [form, instanceTypeId, maxGpuCount, selectedInstanceType]);
|
||||
}, [form, typeName, maxGpuCount, dataList]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldBlock data-field="instanceType">
|
||||
<Form.Item<FormData>
|
||||
name="instance_type_id"
|
||||
<Form.Item
|
||||
name="type"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
@@ -145,10 +170,10 @@ const InstanceTypeFormItem = () => {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InstanceTypePicker />
|
||||
<InstanceTypePicker dataList={dataList} />
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
<Form.Item<FormData>
|
||||
<Form.Item
|
||||
name="gpu_count"
|
||||
rules={[
|
||||
{
|
||||
@@ -171,11 +196,11 @@ const InstanceTypeFormItem = () => {
|
||||
min={1}
|
||||
max={maxGpuCount}
|
||||
precision={0}
|
||||
label={`GPU 数量`}
|
||||
label="GPU 数量"
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name="instance_type" hidden>
|
||||
<Form.Item name="type_display" hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -183,12 +208,16 @@ const InstanceTypeFormItem = () => {
|
||||
};
|
||||
|
||||
export const useSelectedInstanceType = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const instanceTypeId = Form.useWatch('instance_type_id', form);
|
||||
const form = Form.useFormInstance<FormData & { type?: string }>();
|
||||
const typeName = Form.useWatch('type', form) as string | undefined;
|
||||
const { detailData } = useQueryInstanceTypes();
|
||||
const dataList = detailData?.items || [];
|
||||
|
||||
return useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
|
||||
}, [instanceTypeId]);
|
||||
return dataList.find(
|
||||
(item) => (item.metadata?.name || item.name) === typeName
|
||||
);
|
||||
}, [dataList, typeName]);
|
||||
};
|
||||
|
||||
export default InstanceTypeFormItem;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
|
||||
import { Button, Flex, Form, Radio } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { StorageModeValueMap } from '../config';
|
||||
@@ -9,17 +10,33 @@ const FieldBlock = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const StorageHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
type StorageHolderFields = {
|
||||
storage_mode?: string;
|
||||
storage_name?: string;
|
||||
local_storage_size_gb?: number;
|
||||
};
|
||||
|
||||
const StorageVolume = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const storageMode = Form.useWatch('storage_mode', form);
|
||||
const form = Form.useFormInstance<FormData & StorageHolderFields>();
|
||||
const storageMode = Form.useWatch('storage_mode', form) as string | undefined;
|
||||
const storageName = Form.useWatch('storage_name', form) as string | undefined;
|
||||
const localSize = Form.useWatch('local_storage_size_gb', form) as
|
||||
| number
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (storageMode === StorageModeValueMap.Existing) {
|
||||
form.setFieldValue(['spec', 'volume'], {
|
||||
persistent: { name: storageName || '' }
|
||||
});
|
||||
} else if (storageMode === StorageModeValueMap.Temporary) {
|
||||
form.setFieldValue(['spec', 'volume'], {
|
||||
ephemeral: {
|
||||
capacity: localSize ? `${localSize}Gi` : ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [form, storageMode, storageName, localSize]);
|
||||
|
||||
return (
|
||||
<FieldBlock data-field="storage">
|
||||
@@ -30,7 +47,7 @@ const StorageVolume = () => {
|
||||
marginBottom: 6
|
||||
}}
|
||||
>
|
||||
<Form.Item<FormData>
|
||||
<Form.Item
|
||||
name="storage_mode"
|
||||
style={{ marginBottom: 12 }}
|
||||
rules={[
|
||||
@@ -42,10 +59,7 @@ const StorageVolume = () => {
|
||||
>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{
|
||||
label: '持久存储',
|
||||
value: StorageModeValueMap.Existing
|
||||
},
|
||||
{ label: '持久存储', value: StorageModeValueMap.Existing },
|
||||
{ label: '临时存储', value: StorageModeValueMap.Temporary }
|
||||
]}
|
||||
/>
|
||||
@@ -56,12 +70,12 @@ const StorageVolume = () => {
|
||||
</Flex>
|
||||
|
||||
{storageMode === StorageModeValueMap.Existing && (
|
||||
<Form.Item<FormData>
|
||||
name="storage_id"
|
||||
<Form.Item
|
||||
name="storage_name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择预存储卷'
|
||||
message: '请选择持久卷'
|
||||
}
|
||||
]}
|
||||
>
|
||||
@@ -70,14 +84,14 @@ const StorageVolume = () => {
|
||||
required
|
||||
options={mockStorageData.map((item) => ({
|
||||
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
|
||||
value: item.id
|
||||
value: item.metadata?.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{storageMode === StorageModeValueMap.Temporary && (
|
||||
<Form.Item<FormData>
|
||||
<Form.Item
|
||||
name="local_storage_size_gb"
|
||||
rules={[
|
||||
{
|
||||
|
||||
@@ -36,13 +36,9 @@ const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
|
||||
}
|
||||
|
||||
return mockTemplateData.filter((item) => {
|
||||
return [
|
||||
item.name,
|
||||
item.image,
|
||||
item.vendor,
|
||||
item.volume_mount_path,
|
||||
String(item.volume_size_gb ?? '')
|
||||
].some((text) => (text || '').toLowerCase().includes(currentKeyword));
|
||||
return [item.name, item.image, item.volumeMount].some((text) =>
|
||||
(text || '').toLowerCase().includes(currentKeyword)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
@@ -73,7 +69,7 @@ const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索模板名称、镜像、厂商或挂载路径"
|
||||
placeholder="搜索模板名称、镜像或挂载路径"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -81,11 +81,9 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-storage-outlined" /> 存储:
|
||||
</span>
|
||||
<span className="value">
|
||||
{item.volume_size_gb ?? '-'} GB {item.volume_mount_path || '-'}
|
||||
<IconFont className="icon" type="icon-storage-outlined" /> 挂载:
|
||||
</span>
|
||||
<span className="value">{item.volumeMount || '-'}</span>
|
||||
</div>
|
||||
</TemplateContent>
|
||||
</TemplateCard>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '../../templates/config/types';
|
||||
import useCreateTemplate from '../../templates/hooks/use-create-template';
|
||||
import { FormData } from '../config/types';
|
||||
import { useSelectedInstanceType } from './instance-type';
|
||||
import TemplateOverlay from './template-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
@@ -25,7 +24,6 @@ const FieldBlock = styled.div`
|
||||
|
||||
const SelectedCard = styled.div`
|
||||
display: grid;
|
||||
height: 102px;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -78,23 +76,12 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{selected?.name || '请选择实例模板'}
|
||||
</AutoTooltip>
|
||||
{/* {selected && (
|
||||
<Tag
|
||||
color={selected.status === 'enabled' ? 'success' : 'default'}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{selected.status === 'enabled' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
)} */}
|
||||
</SummaryTitle>
|
||||
<SummaryMeta>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span>镜像: {selected?.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
<span>
|
||||
存储: {selected?.volume_size_gb ?? '-'} GB /{' '}
|
||||
{selected?.volume_mount_path || '-'}
|
||||
</span>
|
||||
<span>挂载: {selected?.volumeMount || '-'}</span>
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
<Flex gap={8}>
|
||||
@@ -115,7 +102,6 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
onCreate={() => {
|
||||
// setOpen(false);
|
||||
onCreate?.();
|
||||
}}
|
||||
/>
|
||||
@@ -124,10 +110,8 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
|
||||
};
|
||||
|
||||
const TemplateFormItem = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const templateId = Form.useWatch('template_id', form);
|
||||
const selectedInstanceType = useSelectedInstanceType();
|
||||
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
|
||||
const form = Form.useFormInstance<FormData & { template_id?: number }>();
|
||||
const templateId = Form.useWatch('template_id', form) as number | undefined;
|
||||
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
||||
useCreateTemplate();
|
||||
|
||||
@@ -136,13 +120,23 @@ const TemplateFormItem = () => {
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
form.setFieldValue('template_id', mockTemplateData[0]?.id);
|
||||
if (!templateId && mockTemplateData[0]) {
|
||||
form.setFieldValue('template_id', mockTemplateData[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
form.setFieldValue('image', selectedTemplate.image);
|
||||
form.setFieldValue(['spec', 'image'], selectedTemplate.image);
|
||||
form.setFieldValue(['spec', 'command'], selectedTemplate.command || []);
|
||||
form.setFieldValue(['spec', 'ports'], selectedTemplate.ports || []);
|
||||
form.setFieldValue(['spec', 'env'], selectedTemplate.env || []);
|
||||
form.setFieldValue(['spec', 'volumeMount'], selectedTemplate.volumeMount);
|
||||
const currentResources = form.getFieldValue(['spec', 'resources']) || {};
|
||||
form.setFieldValue(['spec', 'resources'], {
|
||||
...currentResources,
|
||||
cpu: selectedTemplate.resources?.cpu,
|
||||
ram: selectedTemplate.resources?.ram
|
||||
});
|
||||
}
|
||||
}, [form, selectedTemplate, templateId]);
|
||||
|
||||
@@ -174,7 +168,7 @@ const TemplateFormItem = () => {
|
||||
return (
|
||||
<div>
|
||||
<FieldBlock data-field="template">
|
||||
<Form.Item<FormData>
|
||||
<Form.Item
|
||||
name="template_id"
|
||||
rules={[
|
||||
{
|
||||
@@ -190,7 +184,7 @@ const TemplateFormItem = () => {
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
|
||||
<Form.Item<FormData> name="image" hidden>
|
||||
<Form.Item<FormData> name={['spec', 'image']} hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user