fix: instance api alignment
This commit is contained in:
@@ -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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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: '创建时间',
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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={[
|
||||||
|
|||||||
Reference in New Issue
Block a user