fix: instance api alignment

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 7ee1635833
commit 63ac4e0e75
22 changed files with 755 additions and 382 deletions
+1 -1
View File
@@ -356,7 +356,7 @@ export default (props: any) => {
config={{ config={{
apiBaseUrl: GPUSTACK_API_BASE_URL, apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme, theme: userSettings.theme,
iconUrl: '//at.alicdn.com/t/c/font_4613488_ieeghfm8o3o.js', iconUrl: '//at.alicdn.com/t/c/font_4613488_tzcsatubq4f.js',
isDarkTheme: userSettings.isDarkTheme, isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY defaultColorPrimary: COLOR_PRIMARY
}} }}
+1
View File
@@ -3,6 +3,7 @@ export const apiVersion = 'worker.gpustack.ai/v1';
export const KindMapping = { export const KindMapping = {
sshPublicKey: 'InstanceSSHPublicKey', sshPublicKey: 'InstanceSSHPublicKey',
instance: 'Instance', instance: 'Instance',
instanceType: 'InstanceType',
instancePersistentVolume: 'InstancePersistentVolume', instancePersistentVolume: 'InstancePersistentVolume',
instanceTemplate: 'InstanceTemplate' instanceTemplate: 'InstanceTemplate'
}; };
+53 -51
View File
@@ -1,6 +1,5 @@
import { request } from '@umijs/max'; import { request } from '@umijs/max';
import { mockInstanceData } from '../config/mock-data'; import { InstanceTypeItem, ListItem } from '../config/types';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
export const GPU_SERVICE_INSTANCES_API = (namespace: string) => export const GPU_SERVICE_INSTANCES_API = (namespace: string) =>
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instances`; `/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instances`;
@@ -12,61 +11,64 @@ export async function queryGPUServiceInstances(
params: Global.K8sSearchParams, params: Global.K8sSearchParams,
options?: any options?: any
) { ) {
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, { return request<Global.K8sPageResponse<ListItem>>(
// method: 'GET', GPU_SERVICE_INSTANCES_API(params.namespace || ''),
// params, {
// cancelToken: options?.token method: 'GET',
// }); params,
const page = params.page || 1; cancelToken: options?.token
const perPage = params.perPage || 10;
const search = params.search?.toLowerCase();
const clusterId = params.cluster_id;
const filteredData = mockInstanceData.filter((item) => {
const matchSearch = search
? item.name.toLowerCase().includes(search)
: true;
const matchCluster = clusterId ? item.cluster_id === clusterId : true;
return matchSearch && matchCluster;
});
const start = (page - 1) * perPage;
const items = filteredData.slice(start, start + perPage);
return {
items,
pagination: {
total: filteredData.length,
totalPage: Math.ceil(filteredData.length / perPage),
page,
perPage
} }
} as Global.K8sPageResponse<ListItem>; );
} }
export async function createGPUServiceInstance(params: { data: FormData }) { export async function createGPUServiceInstance(
// return request<ListItem>(GPU_SERVICE_INSTANCES_API, { params: {
// method: 'POST', namespace: string;
// data: params.data data: Global.K8sCommonData;
// }); },
return true; option?: any
} ) {
return request<ListItem>(GPU_SERVICE_INSTANCES_API(params.namespace), {
export async function updateGPUServiceInstance(params: { method: 'POST',
id: number; data: params.data,
data: FormData; cancelToken: option?.token
}) {
// return request<ListItem>(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, {
// method: 'PUT',
// data: params.data
// });
return true;
}
export async function deleteGPUServiceInstance(id: number) {
return request(`${GPU_SERVICE_INSTANCES_API}/${id}`, {
method: 'DELETE'
}); });
} }
export async function updateGPUServiceInstance(
params: {
namespace: string;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
return request<ListItem>(
`${GPU_SERVICE_INSTANCES_API(params.namespace)}/${params.id}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
}
export async function deleteGPUServiceInstance(
params: {
namespace: string;
id: number;
},
option?: any
) {
return request(
`${GPU_SERVICE_INSTANCES_API(params.namespace)}/${params.id}`,
{
method: 'DELETE',
cancelToken: option?.token
}
);
}
// =========== Instance Types =========== // =========== Instance Types ===========
export async function queryGPUServiceInstanceTypes( export async function queryGPUServiceInstanceTypes(
@@ -3,13 +3,13 @@ import Separator from '@/pages/llmodels/components/separator';
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from '@ant-design/icons';
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui'; import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import { Empty, Input, Typography } from 'antd'; import { Empty, Input, Typography } from 'antd';
import { useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data'; import { mockTemplateData } from '../../templates/config/mock-data';
import { instanceTypeOptions } from '../config'; import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import { FormData, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms'; import GPUServiceInstanceForm from '../forms';
import TemplateSelector from '../forms/template-selector'; import TemplateSelector from '../forms/template-selector';
import useQueryInstanceTypes from '../services/use-query-instance-types';
import InstanceTypeList from './instance-type-list'; import InstanceTypeList from './instance-type-list';
const Container = styled.div` const Container = styled.div`
@@ -58,26 +58,37 @@ const AddModal: React.FC<AddModalProps> = ({
onCancel onCancel
}) => { }) => {
const form = useRef<any>(null); const form = useRef<any>(null);
const [instanceTypeId, setInstanceTypeId] = useState<number>(); const [instanceTypeName, setInstanceTypeName] = useState<string>();
const [templateId, setTemplateId] = useState<number>(); const [templateId, setTemplateId] = useState<number>();
const [instanceKeyword, setInstanceKeyword] = useState(''); const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState('');
const { detailData, fetchData } = useQueryInstanceTypes();
useEffect(() => {
if (open) {
fetchData({});
}
}, [open, fetchData]);
const instanceTypeList = detailData?.items || [];
const filteredInstanceTypes = useMemo(() => { const filteredInstanceTypes = useMemo(() => {
const keyword = instanceKeyword.trim().toLowerCase(); const keyword = instanceKeyword.trim().toLowerCase();
if (!keyword) { if (!keyword) {
return instanceTypeOptions; return instanceTypeList;
} }
return instanceTypeOptions.filter((item) => return instanceTypeList.filter((item) => {
[ const name = item.metadata?.name || item.name || '';
item.name, return [
String(item.gpu_count), name,
String(item.vram), item.spec?.memory ?? '',
String(item.ram), item.status?.cpu?.capacity ?? '',
String(item.vCPU) item.status?.ram?.capacity ?? '',
].some((text) => text.toLowerCase().includes(keyword)) item.status?.accelerator?.remaining ?? ''
); ].some((text) => String(text).toLowerCase().includes(keyword));
}, [instanceKeyword]); });
}, [instanceKeyword, instanceTypeList]);
const filteredTemplates = useMemo(() => { const filteredTemplates = useMemo(() => {
const keyword = templateKeyword.trim().toLowerCase(); const keyword = templateKeyword.trim().toLowerCase();
@@ -85,13 +96,9 @@ const AddModal: React.FC<AddModalProps> = ({
return mockTemplateData; return mockTemplateData;
} }
return mockTemplateData.filter((item) => return mockTemplateData.filter((item) =>
[ [item.name, item.image, item.volumeMount].some((text) =>
item.name, (text || '').toLowerCase().includes(keyword)
item.image, )
item.vendor,
item.volume_mount_path,
String(item.volume_size_gb ?? '')
].some((text) => (text || '').toLowerCase().includes(keyword))
); );
}, [templateKeyword]); }, [templateKeyword]);
@@ -110,6 +117,10 @@ const AddModal: React.FC<AddModalProps> = ({
}); });
}; };
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
setInstanceTypeName(item.metadata?.name || item.name);
};
return ( return (
<GSDrawer <GSDrawer
title={title} title={title}
@@ -142,16 +153,16 @@ const AddModal: React.FC<AddModalProps> = ({
<Input <Input
allowClear allowClear
prefix={<SearchOutlined className="text-tertiary" />} prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索名称、GPU、显存、内存或 vCPU" placeholder="搜索名称、显存、内存或 vCPU"
value={instanceKeyword} value={instanceKeyword}
onChange={(e) => setInstanceKeyword(e.target.value)} onChange={(e) => setInstanceKeyword(e.target.value)}
/> />
</div> </div>
{filteredInstanceTypes.length > 0 ? ( {filteredInstanceTypes.length > 0 ? (
<InstanceTypeList <InstanceTypeList
value={instanceTypeId} value={instanceTypeName}
dataList={filteredInstanceTypes} dataList={filteredInstanceTypes}
onChange={setInstanceTypeId} onChange={handleInstanceTypeChange}
/> />
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
@@ -174,7 +185,7 @@ const AddModal: React.FC<AddModalProps> = ({
<Input <Input
allowClear allowClear
prefix={<SearchOutlined className="text-tertiary" />} prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索模板名称、镜像、厂商或挂载路径" placeholder="搜索模板名称、镜像或挂载路径"
value={templateKeyword} value={templateKeyword}
onChange={(e) => setTemplateKeyword(e.target.value)} onChange={(e) => setTemplateKeyword(e.target.value)}
/> />
@@ -1,8 +1,8 @@
import { AutoTooltip, TemplateCard } from '@gpustack/core-ui'; import { AutoTooltip, TemplateCard } from '@gpustack/core-ui';
import { Flex, Tag } from 'antd'; import { Flex, Tag } from 'antd';
import styled from 'styled-components'; import styled from 'styled-components';
import { InstanceTypeStatusValueMap, instanceTypeOptions } from '../config'; import { InstanceTypePhaseValueMap } from '../config';
import { InstanceItem } from '../config/types'; import { InstanceTypeItem } from '../config/types';
const TypeGrid = styled.div` const TypeGrid = styled.div`
display: flex; display: flex;
@@ -38,43 +38,44 @@ const TypeMeta = styled.div`
`; `;
interface InstanceTypeListProps { interface InstanceTypeListProps {
value?: number; value?: string;
onChange?: (value: number) => void; onChange?: (item: InstanceTypeItem) => void;
dataList?: InstanceItem[]; dataList?: InstanceTypeItem[];
} }
const isAvailable = (item: InstanceTypeItem) =>
item.status?.phase === InstanceTypePhaseValueMap.Available;
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
value, value,
onChange, onChange,
dataList = instanceTypeOptions dataList = []
}) => { }) => {
const handleSelect = (item: InstanceItem) => { const handleSelect = (item: InstanceTypeItem) => {
if (item.status !== InstanceTypeStatusValueMap.Available) { if (!isAvailable(item)) return;
return; onChange?.(item);
}
onChange?.(item.id);
}; };
return ( return (
<TypeGrid> <TypeGrid>
{dataList.map((item) => { {dataList.map((item) => {
const disabled = item.status !== InstanceTypeStatusValueMap.Available; const disabled = !isAvailable(item);
const name = item.metadata?.name || item.name;
return ( return (
<TemplateCard <TemplateCard
key={item.id} key={name}
clickable clickable
ghost ghost
hoverable hoverable
height={104} height={104}
active={value === item.id} active={value === name}
disabled={disabled} disabled={disabled}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
<TypeName> <TypeName>
<Flex gap={16}> <Flex gap={16}>
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{item.name} {name}
</AutoTooltip> </AutoTooltip>
</Flex> </Flex>
<Tag <Tag
@@ -83,14 +84,14 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
color: 'var(--ant-color-text-tertiary)' color: 'var(--ant-color-text-tertiary)'
}} }}
> >
<span> {item.gpu_count}</span> <span> {item.status?.accelerator?.remaining ?? '-'}</span>
</Tag> </Tag>
</TypeName> </TypeName>
<TypeMeta> <TypeMeta>
<span className="meta-row"> {item.vram} GiB</span> <span className="meta-row"> {item.spec?.memory ?? '-'}</span>
<span className="meta-row gap-16"> <span className="meta-row gap-16">
<span> {item.ram} GiB</span> <span> {item.status?.ram?.capacity ?? '-'}</span>
<span>CPU {item.vCPU}</span> <span>vCPU {item.status?.cpu?.capacity ?? '-'}</span>
</span> </span>
</TypeMeta> </TypeMeta>
</TemplateCard> </TemplateCard>
@@ -1,7 +1,6 @@
import { StatusMaps } from '@/config'; import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types'; import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui'; import { icons } from '@gpustack/core-ui';
import { InstanceItem } from './types';
export const InstanceStatusValueMap = { export const InstanceStatusValueMap = {
Ready: 'ready', Ready: 'ready',
@@ -37,42 +36,15 @@ export const rowActionList = [
} }
]; ];
export const InstanceTypeStatusValueMap = { export const InstanceTypePhaseValueMap = {
Available: 'available', Available: 'Available',
Unavailable: 'unavailable' Unavailable: 'Unavailable'
}; };
export const instanceTypeOptions: InstanceItem[] = [
{
id: 1,
name: 'NVIDIA L4 Small',
vram: 24,
ram: 64,
vCPU: 16,
gpu_count: 1,
status: InstanceTypeStatusValueMap.Available
},
{
id: 2,
name: 'NVIDIA A100 Training',
vram: 80,
ram: 256,
vCPU: 64,
gpu_count: 8,
status: InstanceTypeStatusValueMap.Available
},
{
id: 3,
name: 'NVIDIA H100 Inference',
vram: 80,
ram: 512,
vCPU: 96,
gpu_count: 0,
status: InstanceTypeStatusValueMap.Unavailable
}
];
export const StorageModeValueMap = { export const StorageModeValueMap = {
Existing: 'existing', Existing: 'existing',
Temporary: 'temporary' Temporary: 'temporary'
}; };
// Constant SSH public key resource name used when SSH is enabled
export const DEFAULT_SSH_PUBLIC_KEY_NAME = 'default';
@@ -1,58 +1,94 @@
import { InstanceStatusValueMap } from '.'; import { InstanceStatusValueMap } from '.';
import { ListItem } from './types'; import { ListItem } from './types';
export const mockInstanceData: ListItem[] = [ export const mockInstanceData: (ListItem & { cluster_id: number })[] = [
{ {
id: 1, id: 1,
name: 'cuda-dev-01',
instance_type: 'NVIDIA L4 Small',
instance_type_id: 1,
template_id: 1,
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
gpu_count: 1,
replicas: 1,
storage_mode: 'existing',
storage_id: 1,
cluster_id: 1, cluster_id: 1,
metadata: {
name: 'cuda-dev-01',
namespace: 'default'
},
spec: {
type: 'nvidia-l4-small',
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
displayName: 'CUDA Dev 01',
command: ['/bin/bash'],
ports: [{ protocol: 'tcp', port: 22 }],
env: [],
volumeMount: '/workspace',
resources: {
cpu: '4',
ram: '16Gi',
accelerator: '1'
},
description: 'CUDA development workspace',
volume: {
persistent: { name: 'local-nvme-cache' }
},
sshPublicKey: { name: 'default' }
},
status: InstanceStatusValueMap.Ready, status: InstanceStatusValueMap.Ready,
endpoint: 'https://cuda-dev-01.example.com',
description: 'CUDA development workspace',
created_at: '2026-04-01T10:00:00Z', created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z' updated_at: '2026-04-10T10:00:00Z'
}, },
{ {
id: 2, id: 2,
name: 'training-job-a100',
instance_type: 'NVIDIA A100 Training',
instance_type_id: 2,
template_id: 2,
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
gpu_count: 4,
replicas: 1,
storage_mode: 'existing',
storage_id: 2,
cluster_id: 1, cluster_id: 1,
metadata: {
name: 'training-job-a100',
namespace: 'default'
},
spec: {
type: 'nvidia-a100-training',
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
displayName: 'Training A100',
command: ['python', 'train.py'],
ports: [],
env: [{ name: 'PYTHONUNBUFFERED', value: '1' }],
volumeMount: '/data',
resources: {
cpu: '32',
ram: '128Gi',
accelerator: '4'
},
description: 'PyTorch training environment',
volume: {
persistent: { name: 'shared-model-store' }
},
sshPublicKey: { name: '' }
},
status: InstanceStatusValueMap.Pending, status: InstanceStatusValueMap.Pending,
endpoint: 'https://training-job-a100.example.com',
description: 'PyTorch training environment',
created_at: '2026-04-02T10:00:00Z', created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z' updated_at: '2026-04-11T10:00:00Z'
}, },
{ {
id: 3, id: 3,
name: 'inference-vllm',
instance_type: 'NVIDIA H100 Inference',
instance_type_id: 3,
template_id: 6,
image: 'vllm/vllm-openai:latest',
gpu_count: 2,
replicas: 2,
storage_mode: 'temporary',
local_storage_size_gb: 100,
cluster_id: 1, cluster_id: 1,
metadata: {
name: 'inference-vllm',
namespace: 'default'
},
spec: {
type: 'nvidia-h100-inference',
image: 'vllm/vllm-openai:latest',
displayName: 'Inference vLLM',
command: ['python', '-m', 'vllm.entrypoints.openai.api_server'],
ports: [{ protocol: 'tcp', port: 8080 }],
env: [{ name: 'VLLM_WORKER_MULTIPROC_METHOD', value: 'spawn' }],
volumeMount: '/models',
resources: {
cpu: '16',
ram: '64Gi',
accelerator: '2'
},
description: 'OpenAI-compatible inference service',
volume: {
ephemeral: { capacity: '100Gi' }
},
sshPublicKey: { name: '' }
},
status: InstanceStatusValueMap.Error, status: InstanceStatusValueMap.Error,
endpoint: 'http://inference-vllm.example.com',
description: 'OpenAI-compatible inference service',
created_at: '2026-04-03T10:00:00Z', created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z' updated_at: '2026-04-12T10:00:00Z'
} }
@@ -33,6 +33,7 @@ export interface FormData {
namespace: string; namespace: string;
}; };
spec: { spec: {
type: string;
image: string; image: string;
displayName: string; displayName: string;
command: string[]; command: string[];
@@ -7,7 +7,7 @@ const Basic = () => {
<> <>
<Form.Item<FormData> <Form.Item<FormData>
data-field="name" data-field="name"
name="name" name={['metadata', 'name']}
rules={[ rules={[
{ {
required: true, required: true,
@@ -17,7 +17,7 @@ const Basic = () => {
> >
<CInput.Input label="实例名称" required /> <CInput.Input label="实例名称" required />
</Form.Item> </Form.Item>
<Form.Item<FormData> name="description"> <Form.Item<FormData> name={['spec', 'description']}>
<CInput.TextArea label="描述" scaleSize /> <CInput.TextArea label="描述" scaleSize />
</Form.Item> </Form.Item>
</> </>
+205 -66
View File
@@ -11,21 +11,53 @@ import {
useWrapperContext useWrapperContext
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { Form } from 'antd'; 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 { mockStorageData } from '../../storage/config/mock-data';
import { mockTemplateData } from '../../templates/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 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 { FormData, ListItem } from '../config/types';
import Basic from './basic'; import Basic from './basic';
import InstanceTypeFormItem from './instance-type'; import InstanceTypeFormItem from './instance-type';
import StorageVolume from './storage-volume'; 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 { interface InstanceFormProps {
ref?: any; ref?: any;
open: boolean; open: boolean;
action: PageActionType; action: PageActionType;
currentData?: ListItem | null; currentData?: ListItem | null;
namespace?: string;
onFinish: (values: FormData) => Promise<void>; onFinish: (values: FormData) => Promise<void>;
} }
@@ -43,22 +75,28 @@ const requiredFields = {
}, },
[TABKeysMap.INSTANCE_TYPE]: { [TABKeysMap.INSTANCE_TYPE]: {
sort: 2, sort: 2,
fields: ['instance_type_id'] fields: ['type', 'gpu_count']
}, },
[TABKeysMap.TEMPLATE]: { [TABKeysMap.TEMPLATE]: {
sort: 3, sort: 3,
fields: ['template_id', 'gpu_count'] fields: ['image', 'ports']
}, },
[TABKeysMap.STORAGE]: { [TABKeysMap.STORAGE]: {
sort: 4, 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( const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
(props, ref) => { (props, ref) => {
const { action, currentData, open, onFinish } = props; const {
const [form] = Form.useForm<FormData>(); action,
currentData,
open,
namespace = 'default',
onFinish
} = props;
const [form] = Form.useForm<InstanceFormValues>();
const scrollTabsRef = useRef<any>(null); const scrollTabsRef = useRef<any>(null);
const { getScrollElementScrollableHeight } = useWrapperContext(); const { getScrollElementScrollableHeight } = useWrapperContext();
const { const {
@@ -76,32 +114,41 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
] ]
}); });
const segmentOptions = [ const segmentOptions = useMemo(
{ () => [
value: TABKeysMap.BASIC, {
label: '基础信息', value: TABKeysMap.BASIC,
icon: <IconFont type="icon-basic" />, label: '基础信息',
field: 'name' icon: <IconFont type="icon-basic" />,
}, field: 'name'
{ },
value: TABKeysMap.INSTANCE_TYPE, {
label: '实例类型', value: TABKeysMap.INSTANCE_TYPE,
icon: <IconFont type="icon-gpu1" />, label: '实例类型',
field: 'instanceType' icon: <IconFont type="icon-gpu1" />,
}, field: 'instanceType'
{ },
value: TABKeysMap.TEMPLATE, {
label: '实例模板', value: TABKeysMap.TEMPLATE,
icon: <IconFont type="icon-model" />, label: '实例模板',
field: 'vendor' icon: <IconFont type="icon-model" />,
}, field: 'template'
{ },
value: TABKeysMap.STORAGE, {
label: '存储卷', value: TABKeysMap.STORAGE,
icon: <IconFont type="icon-storage-outlined" />, label: '存储卷',
field: 'storage' 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) => { const onTargetChange = (key: string) => {
scrollTabsRef.current?.handleTargetChange(key); scrollTabsRef.current?.handleTargetChange(key);
@@ -120,38 +167,132 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
} }
if (action === PageAction.EDIT && currentData) { 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({ form.setFieldsValue({
name: currentData.name, metadata: {
description: currentData.description, name: currentData.metadata?.name,
instance_type: currentData.instance_type, namespace: currentData.metadata?.namespace
instance_type_id: currentData.instance_type_id, },
template_id: currentData.template_id, spec: { ...currentData.spec },
image: currentData.image, type: currentData.spec?.type,
gpu_count: currentData.gpu_count, gpu_count: currentData.spec?.resources?.accelerator
replicas: currentData.replicas, ? Number(currentData.spec.resources.accelerator) || 1
storage_mode: currentData.storage_mode, : 1,
storage_id: currentData.storage_id, // template holders mirrored from spec.*
local_storage_size_gb: currentData.local_storage_size_gb 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; return;
} }
const defaultInstanceType = instanceTypeOptions[0];
const defaultTemplate = mockTemplateData[0]; const defaultTemplate = mockTemplateData[0];
const defaultStorage = mockStorageData[0]; const defaultStorage = mockStorageData[0];
form.setFieldsValue({ form.setFieldsValue({
instance_type: defaultInstanceType?.name, metadata: {
instance_type_id: defaultInstanceType?.id, namespace
template_id: defaultTemplate?.id, },
image: defaultTemplate?.image, spec: {
description: '',
displayName: ''
} as FormData['spec'],
gpu_count: 1, 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_mode: StorageModeValueMap.Existing,
storage_id: defaultStorage?.id, storage_name: defaultStorage?.metadata?.name,
local_storage_size_gb: 50 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, () => ({ useImperativeHandle(ref, () => ({
submit: () => { submit: () => {
@@ -178,7 +319,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
<Form <Form
name="gpuServiceInstanceForm" name="gpuServiceInstanceForm"
form={form} form={form}
onFinish={onFinish} onFinish={handleFinish}
onFinishFailed={handleOnFinishFailed} onFinishFailed={handleOnFinishFailed}
preserve={false} preserve={false}
> >
@@ -208,18 +349,16 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
} }
]} ]}
/> />
<Form.Item<FormData> {hasSshPort && (
name="enable_ssh" <Form.Item
valuePropName="checked" name="enable_ssh"
style={{ marginBottom: 8 }} valuePropName="checked"
> style={{ marginBottom: 8 }}
<CheckboxField label={'SSH Terminal Access'}></CheckboxField> >
</Form.Item> <CheckboxField label="SSH Terminal Access" />
<Form.Item<FormData> </Form.Item>
data-field="name" )}
hidden <Form.Item<FormData> hidden name={['spec', 'sshPublicKey', 'name']}>
name={['spec', 'sshPublicKey', 'name']}
>
<CInput.Input /> <CInput.Input />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -4,7 +4,7 @@ import { Empty, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import InstanceTypeList from '../components/instance-type-list'; import InstanceTypeList from '../components/instance-type-list';
import { instanceTypeOptions } from '../config'; import { InstanceTypeItem } from '../config/types';
const DrawerBody = styled.div` const DrawerBody = styled.div`
display: flex; display: flex;
@@ -14,14 +14,16 @@ const DrawerBody = styled.div`
interface InstanceTypeOverlayProps { interface InstanceTypeOverlayProps {
open: boolean; open: boolean;
value?: number; value?: string;
dataList: InstanceTypeItem[];
onCancel: () => void; onCancel: () => void;
onChange?: (value: number) => void; onChange?: (item: InstanceTypeItem) => void;
} }
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({ const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
open, open,
value, value,
dataList,
onCancel, onCancel,
onChange onChange
}) => { }) => {
@@ -30,22 +32,23 @@ const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase(); const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) { if (!currentKeyword) {
return instanceTypeOptions; return dataList;
} }
return instanceTypeOptions.filter((item) => { return dataList.filter((item) => {
const name = item.metadata?.name || item.name || '';
return [ return [
item.name, name,
String(item.gpu_count), item.spec?.memory ?? '',
String(item.vram), item.status?.cpu?.capacity ?? '',
String(item.ram), item.status?.ram?.capacity ?? '',
String(item.vCPU) item.status?.accelerator?.remaining ?? ''
].some((text) => text.toLowerCase().includes(currentKeyword)); ].some((text) => String(text).toLowerCase().includes(currentKeyword));
}); });
}, [keyword]); }, [keyword, dataList]);
const handleChange = (id: number) => { const handleChange = (item: InstanceTypeItem) => {
onChange?.(id); onChange?.(item);
onCancel(); onCancel();
}; };
@@ -70,7 +73,7 @@ const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
<Input <Input
allowClear allowClear
prefix={<SearchOutlined className="text-tertiary" />} prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索名称、GPU、显存、内存或 vCPU" placeholder="搜索名称、显存、内存或 vCPU"
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
@@ -3,11 +3,12 @@ import {
Input as CInput, Input as CInput,
InputNumber as CInputNumber InputNumber as CInputNumber
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { Flex, Form } from 'antd'; import { Flex, Form, Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { instanceTypeOptions, InstanceTypeStatusValueMap } from '../config'; import { InstanceTypePhaseValueMap } from '../config';
import { FormData } from '../config/types'; import { FormData, InstanceTypeItem } from '../config/types';
import useQueryInstanceTypes from '../services/use-query-instance-types';
import InstanceTypeOverlay from './instance-type-overlay'; import InstanceTypeOverlay from './instance-type-overlay';
const FieldBlock = styled.div` const FieldBlock = styled.div`
@@ -16,7 +17,6 @@ const FieldBlock = styled.div`
const SelectedCard = styled.div` const SelectedCard = styled.div`
display: grid; display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content; grid-template-columns: minmax(0, 1fr) max-content;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -38,83 +38,112 @@ const SummaryTitle = styled.div`
const SummaryMeta = styled.div` const SummaryMeta = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: wrap;
gap: 4px; gap: 4px;
color: var(--ant-color-text-secondary); color: var(--ant-color-text-secondary);
font-size: 13px; font-size: 13px;
`; `;
interface InstanceTypePickerProps { interface InstanceTypePickerProps {
value?: number; value?: string;
onChange?: (value: number) => void; onChange?: (value: string) => void;
dataList: InstanceTypeItem[];
} }
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value, value,
onChange onChange,
dataList
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selected = useMemo(() => { const selected = useMemo(() => {
return instanceTypeOptions.find((item) => item.id === value); return dataList.find(
}, [value]); (item) => (item.metadata?.name || item.name) === value
);
}, [value, dataList]);
const handleChange = (item: InstanceTypeItem) => {
onChange?.(item.metadata?.name || item.name);
};
return ( return (
<> <>
<SelectedCard> <SelectedCard>
<div> <div>
<SummaryTitle> <SummaryTitle>
<Flex gap={16}> <Flex gap={16} align="center">
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例类型'} {(selected?.metadata?.name || selected?.name) ??
'请选择实例类型'}
</AutoTooltip> </AutoTooltip>
{/* <Tag {selected && (
style={{ <Tag
fontWeight: 400, style={{
color: 'var(--ant-color-text-tertiary)' fontWeight: 400,
}} color: 'var(--ant-color-text-tertiary)'
> }}
库存 {selected?.gpu_count} >
</Tag> */} {selected?.status?.accelerator?.remaining ?? '-'}
</Tag>
)}
</Flex> </Flex>
</SummaryTitle> </SummaryTitle>
<SummaryMeta> <SummaryMeta>
<span> {selected?.vram ?? '-'} GiB</span> <span> {selected?.spec?.memory ?? '-'}</span>
<Flex gap={16}> <Flex gap={16}>
<span> {selected?.ram ?? '-'} GiB</span> <span> {selected?.status?.ram?.capacity ?? '-'}</span>
<span>CPU {selected?.vCPU ?? '-'}</span> <span>vCPU {selected?.status?.cpu?.capacity ?? '-'}</span>
</Flex> </Flex>
</SummaryMeta> </SummaryMeta>
</div> </div>
{/* <Button onClick={() => setOpen(true)}>更换</Button> */}
</SelectedCard> </SelectedCard>
<InstanceTypeOverlay <InstanceTypeOverlay
open={open} open={open}
value={value} value={value}
dataList={dataList}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onChange={onChange} onChange={handleChange}
/> />
</> </>
); );
}; };
const InstanceTypeFormItem = () => { const InstanceTypeFormItem = () => {
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<
const instanceTypeId = Form.useWatch('instance_type_id', form); FormData & { type?: string; gpu_count?: number }
>();
const typeName = Form.useWatch('type', form) as string | undefined;
const selectedInstanceType = useMemo(() => { const { detailData, fetchData } = useQueryInstanceTypes();
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
}, [instanceTypeId]);
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
useEffect(() => { useEffect(() => {
if (!instanceTypeId) { fetchData({});
const defaultType = instanceTypeOptions.find( }, [fetchData]);
(item) => item.status === InstanceTypeStatusValueMap.Available
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) { if (defaultType) {
form.setFieldValue('instance_type_id', defaultType.id); form.setFieldValue(
'type',
defaultType.metadata?.name || defaultType.name
);
} }
return; return;
} }
@@ -127,17 +156,13 @@ const InstanceTypeFormItem = () => {
if (currentGpuCount < 1) { if (currentGpuCount < 1) {
form.setFieldValue('gpu_count', 1); form.setFieldValue('gpu_count', 1);
} }
}, [form, typeName, maxGpuCount, dataList]);
if (selectedInstanceType) {
form.setFieldValue('instance_type', selectedInstanceType.name);
}
}, [form, instanceTypeId, maxGpuCount, selectedInstanceType]);
return ( return (
<> <>
<FieldBlock data-field="instanceType"> <FieldBlock data-field="instanceType">
<Form.Item<FormData> <Form.Item
name="instance_type_id" name="type"
rules={[ rules={[
{ {
required: true, required: true,
@@ -145,10 +170,10 @@ const InstanceTypeFormItem = () => {
} }
]} ]}
> >
<InstanceTypePicker /> <InstanceTypePicker dataList={dataList} />
</Form.Item> </Form.Item>
</FieldBlock> </FieldBlock>
<Form.Item<FormData> <Form.Item
name="gpu_count" name="gpu_count"
rules={[ rules={[
{ {
@@ -171,11 +196,11 @@ const InstanceTypeFormItem = () => {
min={1} min={1}
max={maxGpuCount} max={maxGpuCount}
precision={0} precision={0}
label={`GPU 数量`} label="GPU 数量"
required required
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData> name="instance_type" hidden> <Form.Item name="type_display" hidden>
<CInput.Input /> <CInput.Input />
</Form.Item> </Form.Item>
</> </>
@@ -183,12 +208,16 @@ const InstanceTypeFormItem = () => {
}; };
export const useSelectedInstanceType = () => { export const useSelectedInstanceType = () => {
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData & { type?: string }>();
const instanceTypeId = Form.useWatch('instance_type_id', form); const typeName = Form.useWatch('type', form) as string | undefined;
const { detailData } = useQueryInstanceTypes();
const dataList = detailData?.items || [];
return useMemo(() => { return useMemo(() => {
return instanceTypeOptions.find((item) => item.id === instanceTypeId); return dataList.find(
}, [instanceTypeId]); (item) => (item.metadata?.name || item.name) === typeName
);
}, [dataList, typeName]);
}; };
export default InstanceTypeFormItem; export default InstanceTypeFormItem;
@@ -1,5 +1,6 @@
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui'; import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { Button, Flex, Form, Radio } from 'antd'; import { Button, Flex, Form, Radio } from 'antd';
import { useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { mockStorageData } from '../../storage/config/mock-data'; import { mockStorageData } from '../../storage/config/mock-data';
import { StorageModeValueMap } from '../config'; import { StorageModeValueMap } from '../config';
@@ -9,17 +10,33 @@ const FieldBlock = styled.div`
margin-bottom: 24px; margin-bottom: 24px;
`; `;
const StorageHeader = styled.div` type StorageHolderFields = {
display: flex; storage_mode?: string;
align-items: center; storage_name?: string;
justify-content: flex-end; local_storage_size_gb?: number;
gap: 12px; };
margin-bottom: 12px;
`;
const StorageVolume = () => { const StorageVolume = () => {
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData & StorageHolderFields>();
const storageMode = Form.useWatch('storage_mode', form); 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 ( return (
<FieldBlock data-field="storage"> <FieldBlock data-field="storage">
@@ -30,7 +47,7 @@ const StorageVolume = () => {
marginBottom: 6 marginBottom: 6
}} }}
> >
<Form.Item<FormData> <Form.Item
name="storage_mode" name="storage_mode"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
rules={[ rules={[
@@ -42,10 +59,7 @@ const StorageVolume = () => {
> >
<Radio.Group <Radio.Group
options={[ options={[
{ { label: '持久存储', value: StorageModeValueMap.Existing },
label: '持久存储',
value: StorageModeValueMap.Existing
},
{ label: '临时存储', value: StorageModeValueMap.Temporary } { label: '临时存储', value: StorageModeValueMap.Temporary }
]} ]}
/> />
@@ -56,12 +70,12 @@ const StorageVolume = () => {
</Flex> </Flex>
{storageMode === StorageModeValueMap.Existing && ( {storageMode === StorageModeValueMap.Existing && (
<Form.Item<FormData> <Form.Item
name="storage_id" name="storage_name"
rules={[ rules={[
{ {
required: true, required: true,
message: '请选择预存储卷' message: '请选择持久卷'
} }
]} ]}
> >
@@ -70,14 +84,14 @@ const StorageVolume = () => {
required required
options={mockStorageData.map((item) => ({ options={mockStorageData.map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`, label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.id value: item.metadata?.name
}))} }))}
/> />
</Form.Item> </Form.Item>
)} )}
{storageMode === StorageModeValueMap.Temporary && ( {storageMode === StorageModeValueMap.Temporary && (
<Form.Item<FormData> <Form.Item
name="local_storage_size_gb" name="local_storage_size_gb"
rules={[ rules={[
{ {
@@ -36,13 +36,9 @@ const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
} }
return mockTemplateData.filter((item) => { return mockTemplateData.filter((item) => {
return [ return [item.name, item.image, item.volumeMount].some((text) =>
item.name, (text || '').toLowerCase().includes(currentKeyword)
item.image, );
item.vendor,
item.volume_mount_path,
String(item.volume_size_gb ?? '')
].some((text) => (text || '').toLowerCase().includes(currentKeyword));
}); });
}, [keyword]); }, [keyword]);
@@ -73,7 +69,7 @@ const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
<Input <Input
allowClear allowClear
prefix={<SearchOutlined className="text-tertiary" />} prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索模板名称、镜像、厂商或挂载路径" placeholder="搜索模板名称、镜像或挂载路径"
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
@@ -81,11 +81,9 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
</div> </div>
<div className="info"> <div className="info">
<span> <span>
<IconFont className="icon" type="icon-storage-outlined" /> : <IconFont className="icon" type="icon-storage-outlined" /> :
</span>
<span className="value">
{item.volume_size_gb ?? '-'} GB {item.volume_mount_path || '-'}
</span> </span>
<span className="value">{item.volumeMount || '-'}</span>
</div> </div>
</TemplateContent> </TemplateContent>
</TemplateCard> </TemplateCard>
@@ -16,7 +16,6 @@ import {
} from '../../templates/config/types'; } from '../../templates/config/types';
import useCreateTemplate from '../../templates/hooks/use-create-template'; import useCreateTemplate from '../../templates/hooks/use-create-template';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import { useSelectedInstanceType } from './instance-type';
import TemplateOverlay from './template-overlay'; import TemplateOverlay from './template-overlay';
const FieldBlock = styled.div` const FieldBlock = styled.div`
@@ -25,7 +24,6 @@ const FieldBlock = styled.div`
const SelectedCard = styled.div` const SelectedCard = styled.div`
display: grid; display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content; grid-template-columns: minmax(0, 1fr) max-content;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -78,23 +76,12 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例模板'} {selected?.name || '请选择实例模板'}
</AutoTooltip> </AutoTooltip>
{/* {selected && (
<Tag
color={selected.status === 'enabled' ? 'success' : 'default'}
style={{ margin: 0 }}
>
{selected.status === 'enabled' ? '启用' : '停用'}
</Tag>
)} */}
</SummaryTitle> </SummaryTitle>
<SummaryMeta> <SummaryMeta>
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
<span>: {selected?.image || '-'}</span> <span>: {selected?.image || '-'}</span>
</AutoTooltip> </AutoTooltip>
<span> <span>: {selected?.volumeMount || '-'}</span>
: {selected?.volume_size_gb ?? '-'} GB /{' '}
{selected?.volume_mount_path || '-'}
</span>
</SummaryMeta> </SummaryMeta>
</div> </div>
<Flex gap={8}> <Flex gap={8}>
@@ -115,7 +102,6 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onChange={onChange} onChange={onChange}
onCreate={() => { onCreate={() => {
// setOpen(false);
onCreate?.(); onCreate?.();
}} }}
/> />
@@ -124,10 +110,8 @@ const TemplatePicker: React.FC<TemplateSelectorProps> = ({
}; };
const TemplateFormItem = () => { const TemplateFormItem = () => {
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData & { template_id?: number }>();
const templateId = Form.useWatch('template_id', form); const templateId = Form.useWatch('template_id', form) as number | undefined;
const selectedInstanceType = useSelectedInstanceType();
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } = const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate(); useCreateTemplate();
@@ -136,13 +120,23 @@ const TemplateFormItem = () => {
}, [templateId]); }, [templateId]);
useEffect(() => { useEffect(() => {
if (!templateId) { if (!templateId && mockTemplateData[0]) {
form.setFieldValue('template_id', mockTemplateData[0]?.id); form.setFieldValue('template_id', mockTemplateData[0].id);
return; return;
} }
if (selectedTemplate) { 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]); }, [form, selectedTemplate, templateId]);
@@ -174,7 +168,7 @@ const TemplateFormItem = () => {
return ( return (
<div> <div>
<FieldBlock data-field="template"> <FieldBlock data-field="template">
<Form.Item<FormData> <Form.Item
name="template_id" name="template_id"
rules={[ rules={[
{ {
@@ -190,7 +184,7 @@ const TemplateFormItem = () => {
</Form.Item> </Form.Item>
</FieldBlock> </FieldBlock>
<Form.Item<FormData> name="image" hidden> <Form.Item<FormData> name={['spec', 'image']} hidden>
<CInput.Input /> <CInput.Input />
</Form.Item> </Form.Item>
@@ -24,7 +24,7 @@ const useInstancesColumns = ({
return [ return [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: ['metadata', 'name'],
key: 'name', key: 'name',
sorter: tableSorter(1), sorter: tableSorter(1),
ellipsis: { ellipsis: {
@@ -54,11 +54,16 @@ const useInstancesColumns = ({
} }
}, },
{ {
title: '实例类型', title: '镜像',
dataIndex: 'instance_type', dataIndex: ['spec', 'image'],
key: 'instance_type', key: 'image',
sorter: tableSorter(3), sorter: tableSorter(3),
render: (value: string) => value ?? '-' ellipsis: {
showTitle: false
},
render: (value: string) => (
<AutoTooltip ghost>{value || '-'}</AutoTooltip>
)
}, },
{ {
title: '创建时间', title: '创建时间',
+46 -10
View File
@@ -3,6 +3,7 @@ import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types'; import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { useModel } from '@@/plugin-model';
import { import {
BaseSelect, BaseSelect,
DeleteModal, DeleteModal,
@@ -14,20 +15,52 @@ import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd'; import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box'; import { PageContainerInner } from '../../_components/page-box';
import { import {
createGPUServiceInstance,
deleteGPUServiceInstance, deleteGPUServiceInstance,
GPU_SERVICE_INSTANCES_API, GPU_SERVICE_INSTANCES_API,
queryGPUServiceInstances, queryGPUServiceInstances
updateGPUServiceInstance
} from './apis'; } from './apis';
import AddModal from './components/add-modal'; import AddModal from './components/add-modal';
import { FormData, ListItem } from './config/types'; import { FormData, ListItem } from './config/types';
import useInstancesColumns from './hooks/use-instances-columns'; import useInstancesColumns from './hooks/use-instances-columns';
import useCreateInstance from './services/use-create-instance';
import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => { const GPUService: React.FC = () => {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const deleteInstance = useCallback(
(id: number) => deleteGPUServiceInstance({ namespace, id }),
[namespace]
);
const fetchInstances = useCallback(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const res = await queryGPUServiceInstances(
{ ...params, namespace },
options
);
const total = res.items?.length ?? 0;
const perPage = params.perPage || 10;
return {
items: res.items ?? [],
pagination: {
total,
totalPage: Math.ceil(total / perPage),
page: params.page || 1,
perPage
}
};
},
[namespace]
);
const { const {
dataSource, dataSource,
rowSelection, rowSelection,
@@ -44,12 +77,15 @@ const GPUService: React.FC = () => {
handleNameChange handleNameChange
} = useTableFetch<ListItem>({ } = useTableFetch<ListItem>({
key: PaginationKey.Instances, key: PaginationKey.Instances,
fetchAPI: queryGPUServiceInstances, fetchAPI: fetchInstances,
deleteAPI: deleteGPUServiceInstance, deleteAPI: deleteInstance,
watch: false, watch: false,
API: GPU_SERVICE_INSTANCES_API, API: GPU_SERVICE_INSTANCES_API(namespace),
contentForDelete: 'GPU 实例' contentForDelete: 'GPU 实例'
}); });
const { fetchData: createInstance } = useCreateInstance();
const { fetchData: updateInstance } = useUpdateInstance();
const { const {
fetchClusterList, fetchClusterList,
cancelRequest: cancelClusterRequest, cancelRequest: cancelClusterRequest,
@@ -113,12 +149,12 @@ const GPUService: React.FC = () => {
const handleModalOk = async (data: FormData) => { const handleModalOk = async (data: FormData) => {
try { try {
if (openAddModalStatus.action === PageAction.EDIT) { if (openAddModalStatus.action === PageAction.EDIT) {
await updateGPUServiceInstance({ await updateInstance({
id: openAddModalStatus.currentData!.id, id: openAddModalStatus.currentData!.id,
data data
}); });
} else { } else {
await createGPUServiceInstance({ data }); await createInstance({ data });
} }
fetchData(); fetchData();
@@ -133,7 +169,7 @@ const GPUService: React.FC = () => {
if (val === 'edit') { if (val === 'edit') {
handleEditInstance(row); handleEditInstance(row);
} else if (val === 'delete') { } else if (val === 'delete') {
handleDelete({ ...row, name: row.name }); handleDelete({ ...row, name: row.metadata?.name });
} }
}); });
@@ -0,0 +1,50 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
interface CreateInstanceParams {
data: FormData;
}
export default function useCreateInstance() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: CreateInstanceParams, option?: any) =>
createGPUServiceInstance(
{
namespace,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateInstanceParams
>({
fetchDetail,
key: 'createInstance'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,31 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServiceInstanceTypes } from '../apis';
import { InstanceTypeItem } from '../config/types';
export default function useQueryInstanceTypes() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServiceInstanceTypes({ ...params, namespace }, options),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<InstanceTypeItem>,
Global.K8sSearchParams
>({
fetchDetail,
key: 'instanceTypes'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,52 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateInstanceParams {
id: number;
data: FormData;
}
export default function useUpdateInstance() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: UpdateInstanceParams, option?: any) =>
updateGPUServiceInstance(
{
namespace,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
UpdateInstanceParams
>({
fetchDetail,
key: 'updateInstance'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+14 -12
View File
@@ -8,7 +8,7 @@ interface BasicProps {
page?: 'template' | 'instance'; page?: 'template' | 'instance';
} }
const Basic: React.FC<BasicProps> = () => { const Basic: React.FC<BasicProps> = ({ page = 'template' }) => {
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const handleCommandChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleCommandChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -25,17 +25,19 @@ const Basic: React.FC<BasicProps> = () => {
return ( return (
<> <>
<Form.Item<FormData> {page === 'template' && (
name="name" <Form.Item<FormData>
rules={[ name="name"
{ rules={[
required: true, {
message: '请输入模板名称' required: true,
} message: '请输入模板名称'
]} }
> ]}
<CInput.Input label="名称" required /> >
</Form.Item> <CInput.Input label="名称" required />
</Form.Item>
)}
<Form.Item<FormData> <Form.Item<FormData>
name="image" name="image"
rules={[ rules={[