feat: templates, storage
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
Input as CInput,
|
||||
InputNumber as CInputNumber
|
||||
} from '@gpustack/core-ui';
|
||||
import { Input as CInput } from '@gpustack/core-ui';
|
||||
import { Form } from 'antd';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
const Basic = () => {
|
||||
return (
|
||||
<>
|
||||
<div data-field="name">
|
||||
<Form.Item<FormData>
|
||||
name="name"
|
||||
rules={[
|
||||
@@ -19,49 +16,10 @@ const Basic = () => {
|
||||
>
|
||||
<CInput.Input label="实例名称" required />
|
||||
</Form.Item>
|
||||
<Form.Item<FormData>
|
||||
name="image"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入镜像'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInput.Input label="镜像" required />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData>
|
||||
name="gpu_count"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入 GPU 数量'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInputNumber min={0} precision={0} label="GPU 数量" required />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData>
|
||||
name="replicas"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入副本数'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInputNumber min={1} precision={0} label="副本数" required />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item<FormData> name="description">
|
||||
<CInput.TextArea label="描述" scaleSize />
|
||||
</Form.Item>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import {
|
||||
CollapsePanel,
|
||||
IconFont,
|
||||
ScrollSpyTabs,
|
||||
useFinishFailed,
|
||||
useScrollActiveChange,
|
||||
useWrapperContext
|
||||
} from '@gpustack/core-ui';
|
||||
import { Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import { instanceTypeOptions, StorageModeValueMap } from '../config';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import Basic from './basic';
|
||||
import InstanceTypeFormItem from './instance-type';
|
||||
import StorageVolume from './storage-volume';
|
||||
import TemplateFormItem from './template';
|
||||
|
||||
interface InstanceFormProps {
|
||||
ref?: any;
|
||||
@@ -13,10 +27,89 @@ interface InstanceFormProps {
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
const TABKeysMap = {
|
||||
BASIC: 'basic',
|
||||
INSTANCE_TYPE: 'instanceType',
|
||||
TEMPLATE: 'template',
|
||||
STORAGE: 'storage'
|
||||
};
|
||||
|
||||
const requiredFields = {
|
||||
[TABKeysMap.BASIC]: {
|
||||
sort: 1,
|
||||
fields: ['name']
|
||||
},
|
||||
[TABKeysMap.INSTANCE_TYPE]: {
|
||||
sort: 2,
|
||||
fields: ['instance_type_id']
|
||||
},
|
||||
[TABKeysMap.TEMPLATE]: {
|
||||
sort: 3,
|
||||
fields: ['template_id', 'gpu_count']
|
||||
},
|
||||
[TABKeysMap.STORAGE]: {
|
||||
sort: 4,
|
||||
fields: ['storage_mode', 'storage_id', 'local_storage_size_gb']
|
||||
}
|
||||
};
|
||||
|
||||
const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
(props, ref) => {
|
||||
const { action, currentData, open, onFinish } = props;
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const scrollTabsRef = useRef<any>(null);
|
||||
const { getScrollElementScrollableHeight } = useWrapperContext();
|
||||
const {
|
||||
activeKey,
|
||||
collapseKeys,
|
||||
handleActiveChange,
|
||||
handleOnCollapseChange,
|
||||
updateActiveKey
|
||||
} = useScrollActiveChange({
|
||||
initalActiveKeys: [TABKeysMap.BASIC],
|
||||
initialCollapseKeys: [
|
||||
TABKeysMap.INSTANCE_TYPE,
|
||||
TABKeysMap.TEMPLATE,
|
||||
TABKeysMap.STORAGE
|
||||
]
|
||||
});
|
||||
|
||||
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: 'template'
|
||||
},
|
||||
{
|
||||
value: TABKeysMap.STORAGE,
|
||||
label: '存储卷',
|
||||
icon: <IconFont type="icon-storage-outlined" />,
|
||||
field: 'storage'
|
||||
}
|
||||
];
|
||||
|
||||
const onTargetChange = (key: string) => {
|
||||
scrollTabsRef.current?.handleTargetChange(key);
|
||||
};
|
||||
|
||||
const { handleOnFinishFailed } = useFinishFailed({
|
||||
requiredFields,
|
||||
onTargetChange,
|
||||
updateActiveKey
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -28,16 +121,33 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
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
|
||||
replicas: currentData.replicas,
|
||||
storage_mode: currentData.storage_mode,
|
||||
storage_id: currentData.storage_id,
|
||||
local_storage_size_gb: currentData.local_storage_size_gb
|
||||
});
|
||||
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,
|
||||
gpu_count: 1,
|
||||
replicas: 1
|
||||
replicas: 1,
|
||||
storage_mode: StorageModeValueMap.Existing,
|
||||
storage_id: defaultStorage?.id,
|
||||
local_storage_size_gb: 50
|
||||
});
|
||||
}, [action, currentData, form, open]);
|
||||
|
||||
@@ -51,14 +161,53 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
}));
|
||||
|
||||
return (
|
||||
<Form
|
||||
name="gpuServiceInstanceForm"
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
preserve={false}
|
||||
<ScrollSpyTabs
|
||||
ref={scrollTabsRef}
|
||||
defaultTarget={TABKeysMap.BASIC}
|
||||
segmentOptions={segmentOptions}
|
||||
activeKey={activeKey}
|
||||
setActiveKey={handleActiveChange}
|
||||
segmentedTop={{
|
||||
top: 0,
|
||||
offsetTop: 96
|
||||
}}
|
||||
getScrollElementScrollableHeight={getScrollElementScrollableHeight}
|
||||
>
|
||||
<Basic />
|
||||
</Form>
|
||||
<Form
|
||||
name="gpuServiceInstanceForm"
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={handleOnFinishFailed}
|
||||
preserve={false}
|
||||
>
|
||||
<Basic />
|
||||
<CollapsePanel
|
||||
activeKey={collapseKeys}
|
||||
accordion={false}
|
||||
onChange={handleOnCollapseChange}
|
||||
items={[
|
||||
{
|
||||
key: TABKeysMap.INSTANCE_TYPE,
|
||||
label: '实例类型',
|
||||
forceRender: true,
|
||||
children: <InstanceTypeFormItem />
|
||||
},
|
||||
{
|
||||
key: TABKeysMap.TEMPLATE,
|
||||
label: '实例模板',
|
||||
forceRender: true,
|
||||
children: <TemplateFormItem />
|
||||
},
|
||||
{
|
||||
key: TABKeysMap.STORAGE,
|
||||
label: '存储卷',
|
||||
forceRender: true,
|
||||
children: <StorageVolume />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</ScrollSpyTabs>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import FormOverlayView from '@/pages/_components/form-overlay-view';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
|
||||
const DrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface InstanceTypeOverlayProps {
|
||||
open: boolean;
|
||||
value?: number;
|
||||
onCancel: () => void;
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
|
||||
open,
|
||||
value,
|
||||
onCancel,
|
||||
onChange
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const currentKeyword = keyword.trim().toLowerCase();
|
||||
if (!currentKeyword) {
|
||||
return instanceTypeOptions;
|
||||
}
|
||||
|
||||
return instanceTypeOptions.filter((item) => {
|
||||
return [
|
||||
item.name,
|
||||
String(item.gpu_count),
|
||||
String(item.vram),
|
||||
String(item.ram),
|
||||
String(item.vCPU)
|
||||
].some((text) => text.toLowerCase().includes(currentKeyword));
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
const handleChange = (id: number) => {
|
||||
onChange?.(id);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const getOverlayContainer = useCallback(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(
|
||||
'.ant-layout-content'
|
||||
);
|
||||
|
||||
return containers[containers.length - 1] ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormOverlayView
|
||||
title="选择实例类型"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onCancel}
|
||||
footer={false}
|
||||
getContainer={getOverlayContainer}
|
||||
>
|
||||
<DrawerBody>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索名称、GPU、显存、内存或 vCPU"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<InstanceTypeList
|
||||
value={value}
|
||||
dataList={filteredOptions}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</DrawerBody>
|
||||
</FormOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstanceTypeOverlay;
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
AutoTooltip,
|
||||
Input as CInput,
|
||||
InputNumber as CInputNumber
|
||||
} from '@gpustack/core-ui';
|
||||
import { Button, 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 InstanceTypeOverlay from './instance-type-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const SelectedCard = styled.div`
|
||||
display: grid;
|
||||
height: 102px;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
background: var(--ant-color-bg-container);
|
||||
`;
|
||||
|
||||
const SummaryTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedCard>
|
||||
<div>
|
||||
<SummaryTitle>
|
||||
<Flex gap={16}>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{selected?.name || '请选择实例类型'}
|
||||
</AutoTooltip>
|
||||
<Tag
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: 'var(--ant-color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
库存 {selected?.gpu_count}
|
||||
</Tag>
|
||||
</Flex>
|
||||
{/* {selected && (
|
||||
<Tag color="success" style={{ margin: 0 }}>
|
||||
可用
|
||||
</Tag>
|
||||
)} */}
|
||||
</SummaryTitle>
|
||||
<SummaryMeta>
|
||||
<span>显存 {selected?.vram ?? '-'} GiB</span>
|
||||
<Flex gap={16}>
|
||||
<span>内存 {selected?.ram ?? '-'} GiB</span>
|
||||
<span>CPU {selected?.vCPU ?? '-'}</span>
|
||||
</Flex>
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)}>更换</Button>
|
||||
</SelectedCard>
|
||||
<InstanceTypeOverlay
|
||||
open={open}
|
||||
value={value}
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InstanceTypeFormItem = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const instanceTypeId = Form.useWatch('instance_type_id', form);
|
||||
|
||||
const selectedInstanceType = useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
|
||||
}, [instanceTypeId]);
|
||||
|
||||
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceTypeId) {
|
||||
const defaultType = instanceTypeOptions.find(
|
||||
(item) => item.status === InstanceTypeStatusValueMap.Available
|
||||
);
|
||||
if (defaultType) {
|
||||
form.setFieldValue('instance_type_id', defaultType.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
|
||||
if (currentGpuCount > maxGpuCount) {
|
||||
form.setFieldValue('gpu_count', maxGpuCount);
|
||||
}
|
||||
|
||||
if (currentGpuCount < 1) {
|
||||
form.setFieldValue('gpu_count', 1);
|
||||
}
|
||||
|
||||
if (selectedInstanceType) {
|
||||
form.setFieldValue('instance_type', selectedInstanceType.name);
|
||||
}
|
||||
}, [form, instanceTypeId, maxGpuCount, selectedInstanceType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldBlock data-field="instanceType">
|
||||
<Form.Item<FormData>
|
||||
name="instance_type_id"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择实例类型'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InstanceTypePicker />
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
<Form.Item<FormData>
|
||||
name="gpu_count"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入 GPU 数量'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value > maxGpuCount) {
|
||||
return Promise.reject(
|
||||
new Error(`当前实例类型最多支持 ${maxGpuCount} 个 GPU`)
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInputNumber
|
||||
min={1}
|
||||
max={maxGpuCount}
|
||||
precision={0}
|
||||
label={`GPU 数量`}
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name="instance_type" hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSelectedInstanceType = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const instanceTypeId = Form.useWatch('instance_type_id', form);
|
||||
|
||||
return useMemo(() => {
|
||||
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
|
||||
}, [instanceTypeId]);
|
||||
};
|
||||
|
||||
export default InstanceTypeFormItem;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { InputNumber as CInputNumber, Input, Select } from '@gpustack/core-ui';
|
||||
import { Button, Flex, Form, Radio } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
import { StorageStatusValueMap } from '../../storage/config';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { StorageModeValueMap } from '../config';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
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;
|
||||
`;
|
||||
|
||||
const StorageVolume = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const storageMode = Form.useWatch('storage_mode', form);
|
||||
|
||||
return (
|
||||
<FieldBlock data-field="storage">
|
||||
<Flex
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6
|
||||
}}
|
||||
>
|
||||
<Form.Item<FormData>
|
||||
name="storage_mode"
|
||||
style={{ marginBottom: 12 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择存储卷'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{
|
||||
label: '持久存储',
|
||||
value: StorageModeValueMap.Existing
|
||||
},
|
||||
{ label: '临时存储', value: StorageModeValueMap.Temporary }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="link" size="small" style={{ marginBottom: 6 }}>
|
||||
添加存储
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{storageMode === StorageModeValueMap.Existing && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData>
|
||||
name="storage_id"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择预存储卷'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
label="持久卷"
|
||||
required
|
||||
options={mockStorageData.map((item) => ({
|
||||
label: `${item.name} / ${item.capacity_gb ?? '-'} GB`,
|
||||
value: item.id,
|
||||
disabled: item.status !== StorageStatusValueMap.Available
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageMode === StorageModeValueMap.Temporary && (
|
||||
<Form.Item<FormData>
|
||||
name="local_storage_size_gb"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入本地临时存储容量'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
|
||||
</Form.Item>
|
||||
)}
|
||||
{/* 挂载路径 */}
|
||||
<Form.Item<FormData>
|
||||
name="mount_path"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入存储挂载路径'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.Input
|
||||
label="挂载路径"
|
||||
required
|
||||
placeholder="挂载路径须以 / 开头"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageVolume;
|
||||
@@ -0,0 +1,193 @@
|
||||
import FormOverlayView from '@/pages/_components/form-overlay-view';
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||
import { Button, Empty, Flex, Input } from 'antd';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
|
||||
const TemplateGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const TemplateContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--ant-color-text-quaternary);
|
||||
}
|
||||
`;
|
||||
|
||||
const DrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
dataList?: TemplateItem[];
|
||||
}
|
||||
|
||||
interface TemplateOverlayProps {
|
||||
open: boolean;
|
||||
value?: number;
|
||||
onCancel: () => void;
|
||||
onChange?: (value: number) => void;
|
||||
onCreate?: () => void;
|
||||
}
|
||||
|
||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
dataList = mockTemplateData
|
||||
}) => {
|
||||
return (
|
||||
<TemplateGrid>
|
||||
{dataList.map((item: TemplateItem) => (
|
||||
<TemplateCard
|
||||
key={item.id}
|
||||
clickable
|
||||
ghost
|
||||
hoverable
|
||||
height={102}
|
||||
active={value === item.id}
|
||||
disabled={item.status !== 'enabled'}
|
||||
onClick={() => onChange?.(item.id)}
|
||||
>
|
||||
<TemplateContent>
|
||||
<div className="name">
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{item.name}
|
||||
</AutoTooltip>
|
||||
{/* <Tag color={item.status === 'enabled' ? 'success' : 'default'}>
|
||||
{item.status === 'enabled' ? '启用' : '停用'}
|
||||
</Tag> */}
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-model" /> 镜像:
|
||||
</span>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span className="value">{item.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
</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 || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</TemplateContent>
|
||||
</TemplateCard>
|
||||
))}
|
||||
</TemplateGrid>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
|
||||
open,
|
||||
value,
|
||||
onCancel,
|
||||
onChange,
|
||||
onCreate
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const currentKeyword = keyword.trim().toLowerCase();
|
||||
if (!currentKeyword) {
|
||||
return mockTemplateData;
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
const handleChange = (id: number) => {
|
||||
onChange?.(id);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const getOverlayContainer = useCallback(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(
|
||||
'.ant-layout-content'
|
||||
);
|
||||
|
||||
return containers[containers.length - 1] ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormOverlayView
|
||||
title="选择实例模板"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onCancel}
|
||||
getContainer={getOverlayContainer}
|
||||
footer={false}
|
||||
>
|
||||
<DrawerBody>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索模板名称、镜像、厂商或挂载路径"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
|
||||
添加模板
|
||||
</Button>
|
||||
</Flex>
|
||||
{filteredTemplates.length > 0 ? (
|
||||
<TemplateSelector
|
||||
value={value}
|
||||
dataList={filteredTemplates}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</DrawerBody>
|
||||
</FormOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateOverlay;
|
||||
@@ -0,0 +1,209 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { AutoTooltip, Input as CInput } from '@gpustack/core-ui';
|
||||
import { Button, Flex, Form, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
createGPUServiceTemplate,
|
||||
updateGPUServiceTemplate
|
||||
} from '../../templates/apis';
|
||||
import AddTemplateModal from '../../templates/components/add-modal';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import {
|
||||
FormData as TemplateFormData,
|
||||
ListItem as TemplateListItem
|
||||
} 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`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const SelectedCard = styled.div`
|
||||
display: grid;
|
||||
height: 102px;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
background: var(--ant-color-bg-container);
|
||||
`;
|
||||
|
||||
const SummaryTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const SummaryMeta = styled.div`
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
onCreate?: () => void;
|
||||
onEdit?: (row: TemplateListItem) => void;
|
||||
}
|
||||
|
||||
const TemplatePicker: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onCreate,
|
||||
onEdit
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return mockTemplateData.find((item) => item.id === value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedCard>
|
||||
<div>
|
||||
<SummaryTitle>
|
||||
<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>
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
<Flex gap={8}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={!selected}
|
||||
onClick={() => selected && onEdit?.(selected)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(true)}>更换</Button>
|
||||
</Flex>
|
||||
</SelectedCard>
|
||||
<TemplateOverlay
|
||||
open={open}
|
||||
value={value}
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
onCreate={() => {
|
||||
// setOpen(false);
|
||||
onCreate?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateFormItem = () => {
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const templateId = Form.useWatch('template_id', form);
|
||||
const selectedInstanceType = useSelectedInstanceType();
|
||||
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
|
||||
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
||||
useCreateTemplate();
|
||||
|
||||
const selectedTemplate = useMemo(() => {
|
||||
return mockTemplateData.find((item) => item.id === templateId);
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
form.setFieldValue('template_id', mockTemplateData[0]?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
form.setFieldValue('image', selectedTemplate.image);
|
||||
}
|
||||
}, [form, selectedTemplate, templateId]);
|
||||
|
||||
const handleAddTemplate = () => {
|
||||
openTemplateModal(PageAction.CREATE, '添加实例模板');
|
||||
};
|
||||
|
||||
const handleEditTemplate = (row: TemplateListItem) => {
|
||||
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
|
||||
};
|
||||
|
||||
const handleTemplateModalOk = async (data: TemplateFormData) => {
|
||||
try {
|
||||
if (openTemplateModalStatus.action === PageAction.EDIT) {
|
||||
await updateGPUServiceTemplate({
|
||||
id: openTemplateModalStatus.currentData!.id,
|
||||
data
|
||||
});
|
||||
} else {
|
||||
await createGPUServiceTemplate({ data });
|
||||
}
|
||||
closeTemplateModal();
|
||||
message.success('操作成功');
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldBlock data-field="template">
|
||||
<Form.Item<FormData>
|
||||
name="template_id"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择实例模板'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<TemplatePicker
|
||||
onCreate={handleAddTemplate}
|
||||
onEdit={handleEditTemplate}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
|
||||
<Form.Item<FormData> name="image" hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
|
||||
<AddTemplateModal
|
||||
action={openTemplateModalStatus.action}
|
||||
open={openTemplateModalStatus.open}
|
||||
title={openTemplateModalStatus.title}
|
||||
currentData={openTemplateModalStatus.currentData}
|
||||
onCancel={closeTemplateModal}
|
||||
onOk={handleTemplateModalOk}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateFormItem;
|
||||
Reference in New Issue
Block a user