feat: templates, storage

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 3ae49dc826
commit 7ea7f6666d
52 changed files with 3604 additions and 188 deletions
+45 -2
View File
@@ -1,3 +1,46 @@
### Create form table list
## Create form table list
### Create a form
## Create a form
## StatusTag
Use `StatusTag` from `@gpustack/core-ui` for status display. Do not use `Tag` from `antd` directly.
1. Define the mapping from business status values to UI status values in the module's `config/index.ts`:
```ts
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
export const XxxStatusValueMap = {
Running: 'running',
Pending: 'pending',
Failed: 'failed'
};
export const XxxStatusLabelMap: Record<string, string> = {
[XxxStatusValueMap.Running]: 'Running',
[XxxStatusValueMap.Pending]: 'Pending',
[XxxStatusValueMap.Failed]: 'Failed'
};
export const status: Record<string, StatusType> = {
[XxxStatusValueMap.Running]: StatusMaps.success,
[XxxStatusValueMap.Pending]: StatusMaps.transitioning,
[XxxStatusValueMap.Failed]: StatusMaps.error
};
```
2. In table columns, pass only the UI status value and display text required by `StatusTag`:
```tsx
<StatusTag
statusValue={{
status: status[value],
text: XxxStatusLabelMap[value] || value,
message: record.state_message
}}
/>
```
`statusValue.status` must be a value mapped from `StatusMaps`, such as `success`, `transitioning`, `warning`, `error`, or `inactive`. Do not pass business status values such as `running` or `pending` directly.
+2 -2
View File
@@ -186,11 +186,11 @@ const baseRoutes = [
routes: [
{
path: '/gpu-service',
redirect: '/gpu-service/list'
redirect: '/gpu-service/instances'
},
{
name: 'instances',
path: '/gpu-service/list',
path: '/gpu-service/instances',
key: 'gpuServiceList',
icon: 'icon-instances-outlined',
selectedIcon: 'icon-instances-filled',
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -40,6 +40,7 @@ export const PaginationKey = {
Benchmarks: 'Benchmarks',
GPUs: 'GPUs',
ModelFiles: 'ModelFiles',
Storage: 'Storage',
Users: 'Users',
APIKeys: 'APIKeys',
Credentials: 'Credentials',
+3 -1
View File
@@ -64,7 +64,9 @@ const NO_CONTAINER_PAGES = [
'clusterCreate',
'benchmarkDetail',
'deployment',
'video'
'video',
'instances',
'storage'
];
const CHECK_RESOURCE_PATH = [
+1 -1
View File
@@ -44,5 +44,5 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage Management'
'menu.gpuService.storage': 'Storage'
};
+1 -1
View File
@@ -44,7 +44,7 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage Management'
'menu.gpuService.storage': 'Storage'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+1 -1
View File
@@ -44,7 +44,7 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage Management'
'menu.gpuService.storage': 'Storage'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+1 -1
View File
@@ -44,7 +44,7 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage Management'
'menu.gpuService.storage': 'Storage'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+1 -1
View File
@@ -44,5 +44,5 @@ export default {
'menu.gpuService': 'GPU 服务',
'menu.gpuService.instances': 'GPU 实例',
'menu.gpuService.templates': '实例模板',
'menu.gpuService.storage': '存储管理'
'menu.gpuService.storage': '存储'
};
+5 -1
View File
@@ -1,4 +1,5 @@
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import type { DrawerProps } from 'antd';
import { Tag } from 'antd';
import React from 'react';
@@ -17,6 +18,7 @@ type AddModalProps = {
width?: number | string;
footer?: React.ReactNode;
subTitle?: React.ReactNode;
push?: DrawerProps['push'];
};
const FormDrawer: React.FC<AddModalProps> = ({
title,
@@ -26,7 +28,8 @@ const FormDrawer: React.FC<AddModalProps> = ({
children,
width = 600,
subTitle,
footer
footer,
push
}) => {
return (
<GSDrawer
@@ -58,6 +61,7 @@ const FormDrawer: React.FC<AddModalProps> = ({
closable: false
}}
keyboard={false}
push={push}
styles={{
wrapper: { width }
}}
@@ -0,0 +1,59 @@
.overlay {
position: fixed;
top: 0;
right: 0;
bottom: auto;
left: auto;
z-index: 1005;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--ant-color-bg-elevated);
border-radius: var(--ant-border-radius-lg) 0 0 var(--ant-border-radius-lg);
box-shadow:
-6px 0 16px 0 rgb(0 0 0 / 8%),
-3px 0 6px -4px rgb(0 0 0 / 12%),
-9px 0 28px 8px rgb(0 0 0 / 5%);
}
.header {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: var(--ant-padding) var(--ant-padding-lg);
border-bottom: 1px solid var(--ant-color-split);
color: var(--ant-color-text);
font-size: 14px;
font-weight: 600;
line-height: 24px;
}
.title {
display: flex;
min-width: 0;
align-items: center;
}
.subTitle {
margin-left: 8px;
border-color: var(--ant-color-border-secondary);
border-radius: 4px;
color: var(--ant-color-text-secondary);
font-size: 12px;
font-weight: 400;
}
.content {
padding-block: 16px;
flex: 1;
height: calc(100vh - 57px);
overflow-y: auto;
}
.footer {
display: flex;
justify-content: flex-end;
padding: 16px 24px 8px;
}
@@ -0,0 +1,90 @@
import { ColumnWrapper, IconFont } from '@gpustack/core-ui';
import { Button, Tag } from 'antd';
import React from 'react';
import { createPortal } from 'react-dom';
import styles from './form-overlay-view.module.less';
type FormOverlayViewProps = {
title: React.ReactNode;
open: boolean;
onCancel?: () => void;
children?: React.ReactNode;
onSubmit?: () => void;
footer?: React.ReactNode;
subTitle?: React.ReactNode;
width?: number | string;
className?: string;
style?: React.CSSProperties;
getContainer?: () => HTMLElement | null | undefined;
};
const FormOverlayView: React.FC<FormOverlayViewProps> = ({
title,
open,
onCancel,
onSubmit,
children,
subTitle,
footer,
width = 600,
className,
style,
getContainer
}) => {
const [container, setContainer] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
if (!open) {
setContainer(null);
return;
}
setContainer(getContainer?.() ?? null);
}, [getContainer, open]);
if (!open) {
return null;
}
if (!container) {
return null;
}
return createPortal(
<div
className={[styles.overlay, className].filter(Boolean).join(' ')}
style={{ width, ...style }}
role="dialog"
aria-modal="true"
>
<div className={styles.header}>
<Button
type="text"
size="small"
style={{ fontWeight: 600, fontSize: 16 }}
icon={<IconFont type="icon-down2" rotate={90} />}
onClick={onCancel}
/>
<div className={styles.title}>
{title}
{subTitle && (
<Tag variant="outlined" className={styles.subTitle}>
{subTitle}
</Tag>
)}
</div>
</div>
<ColumnWrapper
styles={{
container: { paddingBlock: 16 }
}}
footer={footer}
>
{children}
</ColumnWrapper>
</div>,
container
);
};
export default FormOverlayView;
+38 -12
View File
@@ -1,4 +1,5 @@
import { request } from '@umijs/max';
import { mockInstanceData } from '../config/mock-data';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_INSTANCES_API = '/gpu-service-instances';
@@ -7,28 +8,53 @@ export async function queryGPUServiceInstances(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, {
method: 'GET',
params,
cancelToken: options?.token
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, {
// method: 'GET',
// params,
// cancelToken: options?.token
// });
const page = params.page || 1;
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.PageResponse<ListItem>;
}
export async function createGPUServiceInstance(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_INSTANCES_API, {
method: 'POST',
data: params.data
});
// return request<ListItem>(GPU_SERVICE_INSTANCES_API, {
// method: 'POST',
// data: params.data
// });
return true;
}
export async function updateGPUServiceInstance(params: {
id: number;
data: FormData;
}) {
return request<ListItem>(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
// return request<ListItem>(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, {
// method: 'PUT',
// data: params.data
// });
return true;
}
export async function deleteGPUServiceInstance(id: number) {
@@ -46,6 +46,7 @@ const AddModal: React.FC<AddModalProps> = ({
onCancel={handleCancel}
onSubmit={handleSubmit}
width={600}
push={{ distance: 24 }}
footer={
<ModalFooter
onOk={handleSubmit}
@@ -0,0 +1,103 @@
import { AutoTooltip, TemplateCard } from '@gpustack/core-ui';
import { Flex, Tag } from 'antd';
import styled from 'styled-components';
import { InstanceTypeStatusValueMap, instanceTypeOptions } from '../config';
import { InstanceItem } from '../config/types';
const TypeGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const TypeName = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
color: var(--ant-color-text);
font-weight: 500;
`;
const TypeMeta = styled.div`
display: grid;
gap: 8px;
color: var(--ant-color-text-secondary);
font-size: 13px;
.meta-row {
display: flex;
align-items: center;
gap: 16px;
}
.icon {
color: var(--ant-color-text-quaternary);
}
`;
interface InstanceTypeListProps {
value?: number;
onChange?: (value: number) => void;
dataList?: InstanceItem[];
}
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
value,
onChange,
dataList = instanceTypeOptions
}) => {
const handleSelect = (item: InstanceItem) => {
if (item.status !== InstanceTypeStatusValueMap.Available) {
return;
}
onChange?.(item.id);
};
return (
<TypeGrid>
{dataList.map((item) => {
const disabled = item.status !== InstanceTypeStatusValueMap.Available;
return (
<TemplateCard
key={item.id}
clickable
ghost
hoverable
height={104}
active={value === item.id}
disabled={disabled}
onClick={() => handleSelect(item)}
>
<TypeName>
<Flex gap={16}>
<AutoTooltip ghost minWidth={20}>
{item.name}
</AutoTooltip>
</Flex>
<Tag
style={{
fontWeight: 400,
color: 'var(--ant-color-text-tertiary)'
}}
>
<span> {item.gpu_count}</span>
</Tag>
</TypeName>
<TypeMeta>
<span className="meta-row"> {item.vram} GiB</span>
<span className="meta-row gap-16">
<span> {item.ram} GiB</span>
<span>CPU {item.vCPU}</span>
</span>
</TypeMeta>
</TemplateCard>
);
})}
</TypeGrid>
);
};
export default InstanceTypeList;
@@ -1,17 +1,24 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
import { InstanceItem } from './types';
export const InstanceStatusValueMap = {
Running: 'running',
Ready: 'ready',
Pending: 'pending',
Stopped: 'stopped',
Failed: 'failed'
Error: 'error'
};
export const InstanceStatusLabelMap: Record<string, string> = {
[InstanceStatusValueMap.Running]: '运行中',
[InstanceStatusValueMap.Pending]: '等待中',
[InstanceStatusValueMap.Stopped]: '已停止',
[InstanceStatusValueMap.Failed]: '失败'
[InstanceStatusValueMap.Ready]: 'Ready',
[InstanceStatusValueMap.Pending]: 'Pending',
[InstanceStatusValueMap.Error]: 'Error'
};
export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.Ready]: StatusMaps.success,
[InstanceStatusValueMap.Pending]: StatusMaps.transitioning,
[InstanceStatusValueMap.Error]: StatusMaps.error
};
export const rowActionList = [
@@ -29,3 +36,43 @@ export const rowActionList = [
}
}
];
export const InstanceTypeStatusValueMap = {
Available: 'available',
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 = {
Existing: 'existing',
Temporary: 'temporary'
};
@@ -0,0 +1,59 @@
import { InstanceStatusValueMap } from '.';
import { ListItem } from './types';
export const mockInstanceData: ListItem[] = [
{
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,
status: InstanceStatusValueMap.Ready,
endpoint: 'https://cuda-dev-01.example.com',
description: 'CUDA development workspace',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
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,
status: InstanceStatusValueMap.Pending,
endpoint: 'https://training-job-a100.example.com',
description: 'PyTorch training environment',
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
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,
status: InstanceStatusValueMap.Error,
endpoint: 'http://inference-vllm.example.com',
description: 'OpenAI-compatible inference service',
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
}
];
@@ -2,8 +2,16 @@ export interface FormData {
name: string;
description?: string;
image?: string;
instance_type?: string;
instance_type_id?: number;
template_id?: number;
gpu_count?: number;
replicas?: number;
storage_mode?: string;
storage_id?: number;
local_storage_size_gb?: number;
cluster_id?: number;
mount_path?: string;
}
export interface ListItem extends FormData {
@@ -13,3 +21,13 @@ export interface ListItem extends FormData {
created_at?: string;
updated_at?: string;
}
export interface InstanceItem {
id: number;
name: string;
vram: number; // GiB
ram: number; // GiB
vCPU: number; // cores
gpu_count: number;
status: string; // available, unavailable
}
@@ -1,13 +1,10 @@
import {
Input as CInput,
InputNumber as CInputNumber
} from '@gpustack/core-ui';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import { FormData } from '../config/types';
const Basic = () => {
return (
<>
<div data-field="name">
<Form.Item<FormData>
name="name"
rules={[
@@ -19,49 +16,10 @@ const Basic = () => {
>
<CInput.Input label="实例名称" required />
</Form.Item>
<Form.Item<FormData>
name="image"
rules={[
{
required: true,
message: '请输入镜像'
}
]}
>
<CInput.Input label="镜像" required />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="gpu_count"
rules={[
{
required: true,
message: '请输入 GPU 数量'
}
]}
>
<CInputNumber min={0} precision={0} label="GPU 数量" required />
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="replicas"
rules={[
{
required: true,
message: '请输入副本数'
}
]}
>
<CInputNumber min={1} precision={0} label="副本数" required />
</Form.Item>
</div>
</div>
<Form.Item<FormData> name="description">
<CInput.TextArea label="描述" scaleSize />
</Form.Item>
</>
</div>
);
};
+159 -10
View File
@@ -1,9 +1,23 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
CollapsePanel,
IconFont,
ScrollSpyTabs,
useFinishFailed,
useScrollActiveChange,
useWrapperContext
} from '@gpustack/core-ui';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { mockStorageData } from '../../storage/config/mock-data';
import { mockTemplateData } from '../../templates/config/mock-data';
import { instanceTypeOptions, StorageModeValueMap } from '../config';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
import InstanceTypeFormItem from './instance-type';
import StorageVolume from './storage-volume';
import TemplateFormItem from './template';
interface InstanceFormProps {
ref?: any;
@@ -13,10 +27,89 @@ interface InstanceFormProps {
onFinish: (values: FormData) => Promise<void>;
}
const TABKeysMap = {
BASIC: 'basic',
INSTANCE_TYPE: 'instanceType',
TEMPLATE: 'template',
STORAGE: 'storage'
};
const requiredFields = {
[TABKeysMap.BASIC]: {
sort: 1,
fields: ['name']
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
fields: ['instance_type_id']
},
[TABKeysMap.TEMPLATE]: {
sort: 3,
fields: ['template_id', 'gpu_count']
},
[TABKeysMap.STORAGE]: {
sort: 4,
fields: ['storage_mode', 'storage_id', 'local_storage_size_gb']
}
};
const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
const scrollTabsRef = useRef<any>(null);
const { getScrollElementScrollableHeight } = useWrapperContext();
const {
activeKey,
collapseKeys,
handleActiveChange,
handleOnCollapseChange,
updateActiveKey
} = useScrollActiveChange({
initalActiveKeys: [TABKeysMap.BASIC],
initialCollapseKeys: [
TABKeysMap.INSTANCE_TYPE,
TABKeysMap.TEMPLATE,
TABKeysMap.STORAGE
]
});
const segmentOptions = [
{
value: TABKeysMap.BASIC,
label: '基础信息',
icon: <IconFont type="icon-basic" />,
field: 'name'
},
{
value: TABKeysMap.INSTANCE_TYPE,
label: '实例类型',
icon: <IconFont type="icon-gpu1" />,
field: 'instanceType'
},
{
value: TABKeysMap.TEMPLATE,
label: '实例模板',
icon: <IconFont type="icon-model" />,
field: 'template'
},
{
value: TABKeysMap.STORAGE,
label: '存储卷',
icon: <IconFont type="icon-storage-outlined" />,
field: 'storage'
}
];
const onTargetChange = (key: string) => {
scrollTabsRef.current?.handleTargetChange(key);
};
const { handleOnFinishFailed } = useFinishFailed({
requiredFields,
onTargetChange,
updateActiveKey
});
useEffect(() => {
if (!open) {
@@ -28,16 +121,33 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
form.setFieldsValue({
name: currentData.name,
description: currentData.description,
instance_type: currentData.instance_type,
instance_type_id: currentData.instance_type_id,
template_id: currentData.template_id,
image: currentData.image,
gpu_count: currentData.gpu_count,
replicas: currentData.replicas
replicas: currentData.replicas,
storage_mode: currentData.storage_mode,
storage_id: currentData.storage_id,
local_storage_size_gb: currentData.local_storage_size_gb
});
return;
}
const defaultInstanceType = instanceTypeOptions[0];
const defaultTemplate = mockTemplateData[0];
const defaultStorage = mockStorageData[0];
form.setFieldsValue({
instance_type: defaultInstanceType?.name,
instance_type_id: defaultInstanceType?.id,
template_id: defaultTemplate?.id,
image: defaultTemplate?.image,
gpu_count: 1,
replicas: 1
replicas: 1,
storage_mode: StorageModeValueMap.Existing,
storage_id: defaultStorage?.id,
local_storage_size_gb: 50
});
}, [action, currentData, form, open]);
@@ -51,14 +161,53 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}));
return (
<Form
name="gpuServiceInstanceForm"
form={form}
onFinish={onFinish}
preserve={false}
<ScrollSpyTabs
ref={scrollTabsRef}
defaultTarget={TABKeysMap.BASIC}
segmentOptions={segmentOptions}
activeKey={activeKey}
setActiveKey={handleActiveChange}
segmentedTop={{
top: 0,
offsetTop: 96
}}
getScrollElementScrollableHeight={getScrollElementScrollableHeight}
>
<Basic />
</Form>
<Form
name="gpuServiceInstanceForm"
form={form}
onFinish={onFinish}
onFinishFailed={handleOnFinishFailed}
preserve={false}
>
<Basic />
<CollapsePanel
activeKey={collapseKeys}
accordion={false}
onChange={handleOnCollapseChange}
items={[
{
key: TABKeysMap.INSTANCE_TYPE,
label: '实例类型',
forceRender: true,
children: <InstanceTypeFormItem />
},
{
key: TABKeysMap.TEMPLATE,
label: '实例模板',
forceRender: true,
children: <TemplateFormItem />
},
{
key: TABKeysMap.STORAGE,
label: '存储卷',
forceRender: true,
children: <StorageVolume />
}
]}
/>
</Form>
</ScrollSpyTabs>
);
}
);
@@ -0,0 +1,91 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { SearchOutlined } from '@ant-design/icons';
import { Empty, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import InstanceTypeList from '../components/instance-type-list';
import { instanceTypeOptions } from '../config';
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface InstanceTypeOverlayProps {
open: boolean;
value?: number;
onCancel: () => void;
onChange?: (value: number) => void;
}
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
open,
value,
onCancel,
onChange
}) => {
const [keyword, setKeyword] = useState('');
const filteredOptions = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return instanceTypeOptions;
}
return instanceTypeOptions.filter((item) => {
return [
item.name,
String(item.gpu_count),
String(item.vram),
String(item.ram),
String(item.vCPU)
].some((text) => text.toLowerCase().includes(currentKeyword));
});
}, [keyword]);
const handleChange = (id: number) => {
onChange?.(id);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例类型"
open={open}
width={600}
onCancel={onCancel}
footer={false}
getContainer={getOverlayContainer}
>
<DrawerBody>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索名称、GPU、显存、内存或 vCPU"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{filteredOptions.length > 0 ? (
<InstanceTypeList
value={value}
dataList={filteredOptions}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default InstanceTypeOverlay;
@@ -0,0 +1,199 @@
import {
AutoTooltip,
Input as CInput,
InputNumber as CInputNumber
} from '@gpustack/core-ui';
import { Button, Flex, Form, Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { instanceTypeOptions, InstanceTypeStatusValueMap } from '../config';
import { FormData } from '../config/types';
import InstanceTypeOverlay from './instance-type-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const SelectedCard = styled.div`
display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
`;
const SummaryTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
`;
const SummaryMeta = styled.div`
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface InstanceTypePickerProps {
value?: number;
onChange?: (value: number) => void;
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value,
onChange
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return instanceTypeOptions.find((item) => item.id === value);
}, [value]);
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<Flex gap={16}>
<AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例类型'}
</AutoTooltip>
<Tag
style={{
fontWeight: 400,
color: 'var(--ant-color-text-tertiary)'
}}
>
{selected?.gpu_count}
</Tag>
</Flex>
{/* {selected && (
<Tag color="success" style={{ margin: 0 }}>
可用
</Tag>
)} */}
</SummaryTitle>
<SummaryMeta>
<span> {selected?.vram ?? '-'} GiB</span>
<Flex gap={16}>
<span> {selected?.ram ?? '-'} GiB</span>
<span>CPU {selected?.vCPU ?? '-'}</span>
</Flex>
</SummaryMeta>
</div>
<Button onClick={() => setOpen(true)}></Button>
</SelectedCard>
<InstanceTypeOverlay
open={open}
value={value}
onCancel={() => setOpen(false)}
onChange={onChange}
/>
</>
);
};
const InstanceTypeFormItem = () => {
const form = Form.useFormInstance<FormData>();
const instanceTypeId = Form.useWatch('instance_type_id', form);
const selectedInstanceType = useMemo(() => {
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
}, [instanceTypeId]);
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
useEffect(() => {
if (!instanceTypeId) {
const defaultType = instanceTypeOptions.find(
(item) => item.status === InstanceTypeStatusValueMap.Available
);
if (defaultType) {
form.setFieldValue('instance_type_id', defaultType.id);
}
return;
}
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
if (currentGpuCount > maxGpuCount) {
form.setFieldValue('gpu_count', maxGpuCount);
}
if (currentGpuCount < 1) {
form.setFieldValue('gpu_count', 1);
}
if (selectedInstanceType) {
form.setFieldValue('instance_type', selectedInstanceType.name);
}
}, [form, instanceTypeId, maxGpuCount, selectedInstanceType]);
return (
<>
<FieldBlock data-field="instanceType">
<Form.Item<FormData>
name="instance_type_id"
rules={[
{
required: true,
message: '请选择实例类型'
}
]}
>
<InstanceTypePicker />
</Form.Item>
</FieldBlock>
<Form.Item<FormData>
name="gpu_count"
rules={[
{
required: true,
message: '请输入 GPU 数量'
},
{
validator: (_, value) => {
if (value > maxGpuCount) {
return Promise.reject(
new Error(`当前实例类型最多支持 ${maxGpuCount} 个 GPU`)
);
}
return Promise.resolve();
}
}
]}
>
<CInputNumber
min={1}
max={maxGpuCount}
precision={0}
label={`GPU 数量`}
required
/>
</Form.Item>
<Form.Item<FormData> name="instance_type" hidden>
<CInput.Input />
</Form.Item>
</>
);
};
export const useSelectedInstanceType = () => {
const form = Form.useFormInstance<FormData>();
const instanceTypeId = Form.useWatch('instance_type_id', form);
return useMemo(() => {
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
}, [instanceTypeId]);
};
export default InstanceTypeFormItem;
@@ -0,0 +1,118 @@
import { InputNumber as CInputNumber, Input, Select } from '@gpustack/core-ui';
import { Button, Flex, Form, Radio } from 'antd';
import styled from 'styled-components';
import { StorageStatusValueMap } from '../../storage/config';
import { mockStorageData } from '../../storage/config/mock-data';
import { StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const StorageHeader = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-bottom: 12px;
`;
const StorageVolume = () => {
const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storage_mode', form);
return (
<FieldBlock data-field="storage">
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}
>
<Form.Item<FormData>
name="storage_mode"
style={{ marginBottom: 12 }}
rules={[
{
required: true,
message: '请选择存储卷'
}
]}
>
<Radio.Group
options={[
{
label: '持久存储',
value: StorageModeValueMap.Existing
},
{ label: '临时存储', value: StorageModeValueMap.Temporary }
]}
/>
</Form.Item>
<Button type="link" size="small" style={{ marginBottom: 6 }}>
</Button>
</Flex>
{storageMode === StorageModeValueMap.Existing && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="storage_id"
rules={[
{
required: true,
message: '请选择预存储卷'
}
]}
>
<Select
label="持久卷"
required
options={mockStorageData.map((item) => ({
label: `${item.name} / ${item.capacity_gb ?? '-'} GB`,
value: item.id,
disabled: item.status !== StorageStatusValueMap.Available
}))}
/>
</Form.Item>
</div>
</div>
)}
{storageMode === StorageModeValueMap.Temporary && (
<Form.Item<FormData>
name="local_storage_size_gb"
rules={[
{
required: true,
message: '请输入本地临时存储容量'
}
]}
>
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
</Form.Item>
)}
{/* 挂载路径 */}
<Form.Item<FormData>
name="mount_path"
rules={[
{
required: true,
message: '请输入存储挂载路径'
}
]}
>
<Input.Input
label="挂载路径"
required
placeholder="挂载路径须以 / 开头"
/>
</Form.Item>
</FieldBlock>
);
};
export default StorageVolume;
@@ -0,0 +1,193 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import { Button, Empty, Flex, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data';
import { ListItem as TemplateItem } from '../../templates/config/types';
const TemplateGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const TemplateContent = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
color: var(--ant-color-text-secondary);
.name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
}
.info {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px;
color: var(--ant-color-text-tertiary);
}
.value {
color: var(--ant-color-text);
}
.icon {
color: var(--ant-color-text-quaternary);
}
`;
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
dataList?: TemplateItem[];
}
interface TemplateOverlayProps {
open: boolean;
value?: number;
onCancel: () => void;
onChange?: (value: number) => void;
onCreate?: () => void;
}
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value,
onChange,
dataList = mockTemplateData
}) => {
return (
<TemplateGrid>
{dataList.map((item: TemplateItem) => (
<TemplateCard
key={item.id}
clickable
ghost
hoverable
height={102}
active={value === item.id}
disabled={item.status !== 'enabled'}
onClick={() => onChange?.(item.id)}
>
<TemplateContent>
<div className="name">
<AutoTooltip ghost minWidth={20}>
{item.name}
</AutoTooltip>
{/* <Tag color={item.status === 'enabled' ? 'success' : 'default'}>
{item.status === 'enabled' ? '启用' : '停用'}
</Tag> */}
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-model" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{item.image || '-'}</span>
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-storage-outlined" /> :
</span>
<span className="value">
{item.volume_size_gb ?? '-'} GB {item.volume_mount_path || '-'}
</span>
</div>
</TemplateContent>
</TemplateCard>
))}
</TemplateGrid>
);
};
const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
open,
value,
onCancel,
onChange,
onCreate
}) => {
const [keyword, setKeyword] = useState('');
const filteredTemplates = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return mockTemplateData;
}
return mockTemplateData.filter((item) => {
return [
item.name,
item.image,
item.vendor,
item.volume_mount_path,
String(item.volume_size_gb ?? '')
].some((text) => (text || '').toLowerCase().includes(currentKeyword));
});
}, [keyword]);
const handleChange = (id: number) => {
onChange?.(id);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例模板"
open={open}
width={600}
onCancel={onCancel}
getContainer={getOverlayContainer}
footer={false}
>
<DrawerBody>
<Flex gap={8}>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索模板名称、镜像、厂商或挂载路径"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
</Button>
</Flex>
{filteredTemplates.length > 0 ? (
<TemplateSelector
value={value}
dataList={filteredTemplates}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default TemplateOverlay;
@@ -0,0 +1,209 @@
import { PageAction } from '@/config';
import { EditOutlined } from '@ant-design/icons';
import { AutoTooltip, Input as CInput } from '@gpustack/core-ui';
import { Button, Flex, Form, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import {
createGPUServiceTemplate,
updateGPUServiceTemplate
} from '../../templates/apis';
import AddTemplateModal from '../../templates/components/add-modal';
import { mockTemplateData } from '../../templates/config/mock-data';
import {
FormData as TemplateFormData,
ListItem as TemplateListItem
} from '../../templates/config/types';
import useCreateTemplate from '../../templates/hooks/use-create-template';
import { FormData } from '../config/types';
import { useSelectedInstanceType } from './instance-type';
import TemplateOverlay from './template-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const SelectedCard = styled.div`
display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
`;
const SummaryTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
`;
const SummaryMeta = styled.div`
display: grid;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
onCreate?: () => void;
onEdit?: (row: TemplateListItem) => void;
}
const TemplatePicker: React.FC<TemplateSelectorProps> = ({
value,
onChange,
onCreate,
onEdit
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return mockTemplateData.find((item) => item.id === value);
}, [value]);
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例模板'}
</AutoTooltip>
{/* {selected && (
<Tag
color={selected.status === 'enabled' ? 'success' : 'default'}
style={{ margin: 0 }}
>
{selected.status === 'enabled' ? '启用' : '停用'}
</Tag>
)} */}
</SummaryTitle>
<SummaryMeta>
<AutoTooltip ghost minWidth={20}>
<span>: {selected?.image || '-'}</span>
</AutoTooltip>
<span>
: {selected?.volume_size_gb ?? '-'} GB /{' '}
{selected?.volume_mount_path || '-'}
</span>
</SummaryMeta>
</div>
<Flex gap={8}>
<Button
type="text"
icon={<EditOutlined />}
disabled={!selected}
onClick={() => selected && onEdit?.(selected)}
>
</Button>
<Button onClick={() => setOpen(true)}></Button>
</Flex>
</SelectedCard>
<TemplateOverlay
open={open}
value={value}
onCancel={() => setOpen(false)}
onChange={onChange}
onCreate={() => {
// setOpen(false);
onCreate?.();
}}
/>
</>
);
};
const TemplateFormItem = () => {
const form = Form.useFormInstance<FormData>();
const templateId = Form.useWatch('template_id', form);
const selectedInstanceType = useSelectedInstanceType();
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate();
const selectedTemplate = useMemo(() => {
return mockTemplateData.find((item) => item.id === templateId);
}, [templateId]);
useEffect(() => {
if (!templateId) {
form.setFieldValue('template_id', mockTemplateData[0]?.id);
return;
}
if (selectedTemplate) {
form.setFieldValue('image', selectedTemplate.image);
}
}, [form, selectedTemplate, templateId]);
const handleAddTemplate = () => {
openTemplateModal(PageAction.CREATE, '添加实例模板');
};
const handleEditTemplate = (row: TemplateListItem) => {
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
};
const handleTemplateModalOk = async (data: TemplateFormData) => {
try {
if (openTemplateModalStatus.action === PageAction.EDIT) {
await updateGPUServiceTemplate({
id: openTemplateModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceTemplate({ data });
}
closeTemplateModal();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
return (
<div>
<FieldBlock data-field="template">
<Form.Item<FormData>
name="template_id"
rules={[
{
required: true,
message: '请选择实例模板'
}
]}
>
<TemplatePicker
onCreate={handleAddTemplate}
onEdit={handleEditTemplate}
/>
</Form.Item>
</FieldBlock>
<Form.Item<FormData> name="image" hidden>
<CInput.Input />
</Form.Item>
<AddTemplateModal
action={openTemplateModalStatus.action}
open={openTemplateModalStatus.open}
title={openTemplateModalStatus.title}
currentData={openTemplateModalStatus.currentData}
onCancel={closeTemplateModal}
onOk={handleTemplateModalOk}
/>
</div>
);
};
export default TemplateFormItem;
@@ -1,13 +1,13 @@
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { Tag } from 'antd';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
InstanceStatusLabelMap,
InstanceStatusValueMap,
rowActionList
rowActionList,
status
} from '../config';
import { ListItem } from '../config/types';
@@ -16,13 +16,6 @@ interface ColumnsHookProps {
sortOrder: string[];
}
const statusColorMap: Record<string, string> = {
[InstanceStatusValueMap.Running]: 'success',
[InstanceStatusValueMap.Pending]: 'processing',
[InstanceStatusValueMap.Stopped]: 'default',
[InstanceStatusValueMap.Failed]: 'error'
};
const useInstancesColumns = ({
handleSelect,
sortOrder
@@ -30,7 +23,7 @@ const useInstancesColumns = ({
return useMemo(() => {
return [
{
title: '实例名称',
title: '名称',
dataIndex: 'name',
key: 'name',
sorter: tableSorter(1),
@@ -48,57 +41,24 @@ const useInstancesColumns = ({
dataIndex: 'status',
key: 'status',
sorter: tableSorter(2),
render: (status: string) => {
const value = status || InstanceStatusValueMap.Pending;
render: (statusValue: string) => {
const value = statusValue || InstanceStatusValueMap.Ready;
return (
<Tag
color={statusColorMap[value] || 'default'}
style={{ marginRight: 0 }}
>
{InstanceStatusLabelMap[value] || value}
</Tag>
<StatusTag
statusValue={{
status: status[value],
text: InstanceStatusLabelMap[value] || value
}}
></StatusTag>
);
}
},
{
title: '镜像',
dataIndex: 'image',
key: 'image',
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{text || '-'}
</AutoTooltip>
)
},
{
title: 'GPU 数量',
dataIndex: 'gpu_count',
key: 'gpu_count',
title: '实例类型',
dataIndex: 'instance_type',
key: 'instance_type',
sorter: tableSorter(3),
render: (value: number) => value ?? '-'
},
{
title: '副本数',
dataIndex: 'replicas',
key: 'replicas',
sorter: tableSorter(4),
render: (value: number) => value ?? '-'
},
{
title: '访问端点',
dataIndex: 'endpoint',
key: 'endpoint',
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{text || '-'}
</AutoTooltip>
)
render: (value: string) => value ?? '-'
},
{
title: '创建时间',
+102 -44
View File
@@ -2,12 +2,20 @@ import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import {
BaseSelect,
DeleteModal,
FilterBar,
IconFont,
NoResult
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import _ from 'lodash';
import { useState } from 'react';
import PageBox from '../../_components/page-box';
import { useEffect, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import {
createGPUServiceInstance,
deleteGPUServiceInstance,
@@ -31,6 +39,7 @@ const GPUService: React.FC = () => {
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
@@ -41,6 +50,12 @@ const GPUService: React.FC = () => {
API: GPU_SERVICE_INSTANCES_API,
contentForDelete: 'GPU 实例'
});
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const intl = useIntl();
const [openAddModalStatus, setOpenAddModalStatus] = useState<{
action: PageActionType;
@@ -54,10 +69,24 @@ const GPUService: React.FC = () => {
currentData: null
});
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
if (clusters.length > 0) {
handleQueryChange({
cluster_id: clusters[0].id,
page: 1
});
}
});
return () => {
cancelClusterRequest();
};
}, []);
const handleAddInstance = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: '创建 GPU 实例',
title: '添加 GPU 实例',
open: true,
currentData: null
});
@@ -108,6 +137,13 @@ const GPUService: React.FC = () => {
}
});
const handleClusterChange = (value: number) => {
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
@@ -115,13 +151,13 @@ const GPUService: React.FC = () => {
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-layers" />}
image={<IconFont type="icon-instances-outlined" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText="未找到匹配的 GPU 实例"
title="暂无 GPU 实例"
subTitle="创建一个 GPU 实例后会显示在这里"
onClick={handleAddInstance}
buttonText="创建"
buttonText="立即添加"
/>
);
};
@@ -132,44 +168,66 @@ const GPUService: React.FC = () => {
});
return (
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
buttonText="创建"
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAddInstance}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.instances' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
}}
/>
</ConfigProvider>
</PageBox>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={clusterList}
onChange={handleClusterChange}
style={{ minWidth: 120, fontWeight: 500 }}
></BaseSelect>
</Flex>
}
>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={clusterList}
select={{ showSearch: true }}
selectHolder="按集群过滤"
buttonText="添加 GPU 实例"
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAddInstance}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
<AddModal
open={openAddModalStatus.open}
action={openAddModalStatus.action}
@@ -179,7 +237,7 @@ const GPUService: React.FC = () => {
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
</PageContainerInner>
);
};
@@ -0,0 +1,64 @@
import { request } from '@umijs/max';
import { mockStorageData } from '../config/mock-data';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_STORAGE_API = '/gpu-service-storage';
export async function queryGPUServiceStorage(
params: Global.SearchParams,
options?: any
) {
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_API, {
// method: 'GET',
// params,
// cancelToken: options?.token
// });
const page = params.page || 1;
const perPage = params.perPage || 10;
const search = params.search?.toLowerCase();
const clusterId = params.cluster_id;
const filteredData = mockStorageData.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.PageResponse<ListItem>;
}
export async function createGPUServiceStorage(params: { data: FormData }) {
// return request<ListItem>(GPU_SERVICE_STORAGE_API, {
// method: 'POST',
// data: params.data
// });
return true;
}
export async function updateGPUServiceStorage(params: {
id: number;
data: FormData;
}) {
// return request<ListItem>(`${GPU_SERVICE_STORAGE_API}/${params.id}`, {
// method: 'PUT',
// data: params.data
// });
return true;
}
export async function deleteGPUServiceStorage(id: number) {
return request(`${GPU_SERVICE_STORAGE_API}/${id}`, {
method: 'DELETE'
});
}
@@ -0,0 +1,72 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms';
type AddModalProps = {
title: string;
action: PageActionType;
open: boolean;
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
};
const AddModal: React.FC<AddModalProps> = ({
title,
action,
open,
onOk,
data,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({
...values
});
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={600}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceStorageForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddModal;
@@ -0,0 +1,73 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
export const StorageTypeValueMap = {
Local: 'local',
Shared: 'shared',
Object: 'object'
};
export const StorageTypeLabelMap: Record<string, string> = {
[StorageTypeValueMap.Local]: '默认存储',
[StorageTypeValueMap.Shared]: '默认存储',
[StorageTypeValueMap.Object]: '对象存储'
};
export const StorageAccessModeValueMap = {
ReadWriteOnce: 'ReadWriteOnce',
ReadOnlyMany: 'ReadOnlyMany',
ReadWriteMany: 'ReadWriteMany'
};
export const StorageAccessModeOptions = [
{
label: 'ReadWriteOnce',
value: StorageAccessModeValueMap.ReadWriteOnce
},
{
label: 'ReadWriteMany',
value: StorageAccessModeValueMap.ReadWriteMany
},
{
label: 'ReadOnlyMany',
value: StorageAccessModeValueMap.ReadOnlyMany
}
];
export const StorageStatusValueMap = {
Available: 'Available',
Bound: 'Bound',
Released: 'Released',
Failed: 'Failed'
};
export const StorageStatusLabelMap: Record<string, string> = {
[StorageStatusValueMap.Available]: 'Available',
[StorageStatusValueMap.Bound]: 'Bound',
[StorageStatusValueMap.Released]: 'Released',
[StorageStatusValueMap.Failed]: 'Failed'
};
export const status: Record<string, StatusType> = {
[StorageStatusValueMap.Available]: StatusMaps.success,
[StorageStatusValueMap.Bound]: StatusMaps.transitioning,
[StorageStatusValueMap.Released]: StatusMaps.inactive,
[StorageStatusValueMap.Failed]: StatusMaps.error
};
export const rowActionList = [
{
label: '编辑',
key: 'edit',
icon: icons.EditOutlined
},
{
label: '删除',
key: 'delete',
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
@@ -0,0 +1,69 @@
import { StorageStatusValueMap, StorageTypeValueMap } from '.';
import { ListItem } from './types';
export const mockStorageData: ListItem[] = [
{
id: 1,
name: 'local-nvme-cache',
type: StorageTypeValueMap.Local,
capacity_gb: 500,
mount_path: '/mnt/nvme',
access_modes: ['ReadWriteOnce'],
parameters: {
path: '/mnt/nvme'
},
status: StorageStatusValueMap.Available,
cluster_id: 1,
description: 'Local NVMe cache for hot model files',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
name: 'shared-model-store',
type: StorageTypeValueMap.Shared,
capacity_gb: 2048,
mount_path: '/models',
access_modes: ['ReadWriteMany'],
parameters: {
storageClassName: 'nfs-client'
},
status: StorageStatusValueMap.Bound,
cluster_id: 1,
description: 'Shared storage for model weights',
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
name: 'object-dataset-bucket',
type: StorageTypeValueMap.Object,
capacity_gb: 10240,
mount_path: '/datasets',
access_modes: ['ReadOnlyMany'],
parameters: {
bucket: 'datasets'
},
status: StorageStatusValueMap.Released,
cluster_id: 2,
description: 'Object storage mounted for datasets',
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
},
{
id: 4,
name: 'failed-shared-cache',
type: StorageTypeValueMap.Shared,
capacity_gb: 1024,
mount_path: '/failed-cache',
access_modes: ['ReadWriteMany'],
parameters: {
storageClassName: 'nfs-client'
},
status: StorageStatusValueMap.Failed,
cluster_id: 2,
description: 'Shared cache waiting for storage backend recovery',
created_at: '2026-04-04T10:00:00Z',
updated_at: '2026-04-13T10:00:00Z'
}
];
@@ -0,0 +1,17 @@
export interface FormData {
name: string;
description?: string;
type?: string;
capacity_gb?: number;
mount_path?: string;
access_modes?: string[];
parameters?: Record<string, any>;
status?: string;
cluster_id?: number;
}
export interface ListItem extends FormData {
id: number;
created_at?: string;
updated_at?: string;
}
@@ -0,0 +1,81 @@
import {
Input as CInput,
InputNumber as CInputNumber,
Select as SealSelect
} from '@gpustack/core-ui';
import { Form } from 'antd';
import { StorageAccessModeOptions } from '../config';
import { FormData } from '../config/types';
const Basic = () => {
const form = Form.useFormInstance<FormData>();
const parameters = Form.useWatch('parameters', form);
const handleParametersChange = (value: Record<string, any>) => {
form.setFieldValue('parameters', value);
};
return (
<>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: '请输入存储名称'
}
]}
>
<CInput.Input label="名称" required />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="type"
rules={[
{
required: true,
message: '请选择存储类型'
}
]}
>
<SealSelect label="存储类型" required options={[]}></SealSelect>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="capacity_gb"
rules={[
{
required: true,
message: '请输入容量'
}
]}
>
<CInputNumber min={1} precision={0} label="容量 (GB)" required />
</Form.Item>
</div>
</div>
<Form.Item<FormData> name="access_modes">
<SealSelect
label="存储卷访问方式"
allowClear
options={StorageAccessModeOptions}
></SealSelect>
</Form.Item>
{/* <Form.Item<FormData> name="parameters">
<LabelSelector
label="存储卷配置参数"
labels={parameters || {}}
btnText="添加参数"
onChange={handleParametersChange}
/>
</Form.Item> */}
<Form.Item<FormData> name="description">
<CInput.TextArea label="描述" scaleSize />
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,71 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { StorageStatusValueMap } from '../config';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
interface StorageFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name,
description: currentData.description,
type: currentData.type,
capacity_gb: currentData.capacity_gb,
mount_path: currentData.mount_path,
access_modes: currentData.access_modes,
parameters: currentData.parameters,
status: currentData.status
});
return;
}
form.setFieldsValue({
type: '默认存储',
parameters: {},
status: StorageStatusValueMap.Available
});
}, [action, currentData, form, open]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
return (
<Form
name="gpuServiceStorageForm"
form={form}
onFinish={onFinish}
preserve={false}
>
<Basic />
</Form>
);
}
);
export default GPUServiceStorageForm;
@@ -0,0 +1,113 @@
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
rowActionList,
status,
StorageStatusLabelMap,
StorageStatusValueMap,
StorageTypeLabelMap
} from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
}
const useStorageColumns = ({
handleSelect,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
return useMemo(() => {
return [
{
title: '名称',
dataIndex: 'name',
key: 'name',
sorter: tableSorter(1),
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{text}</span>
</AutoTooltip>
)
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
sorter: tableSorter(2),
render: (value: string) => StorageTypeLabelMap[value] || value || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
sorter: tableSorter(3),
render: (statusValue: string) => {
const value = statusValue || StorageStatusValueMap.Available;
return (
<StatusTag
statusValue={{
status: status[value],
text: StorageStatusLabelMap[value] || value
}}
/>
);
}
},
{
title: '容量 (GB)',
dataIndex: 'capacity_gb',
key: 'capacity_gb',
sorter: tableSorter(4),
render: (value: number) => value ?? '-'
},
// {
// title: '挂载路径',
// dataIndex: 'mount_path',
// key: 'mount_path',
// ellipsis: {
// showTitle: false
// },
// render: (text: string) => (
// <AutoTooltip ghost minWidth={20}>
// {text || '-'}
// </AutoTooltip>
// )
// },
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
sorter: tableSorter(5),
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
</AutoTooltip>
)
},
{
title: '操作',
key: 'operation',
dataIndex: 'operation',
render: (_text, record) => (
<DropdownButtons
items={rowActionList}
onSelect={(val) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, sortOrder]);
};
export default useStorageColumns;
+241 -2
View File
@@ -1,7 +1,246 @@
import PageBox from '../../_components/page-box';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import {
BaseSelect,
DeleteModal,
FilterBar,
IconFont,
NoResult
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import {
createGPUServiceStorage,
deleteGPUServiceStorage,
GPU_SERVICE_STORAGE_API,
queryGPUServiceStorage,
updateGPUServiceStorage
} from './apis';
import AddModal from './components/add-modal';
import { FormData, ListItem } from './config/types';
import useStorageColumns from './hooks/use-storage-columns';
const GPUServiceStorage: React.FC = () => {
return <PageBox> GPU Service Storage Page</PageBox>;
const {
dataSource,
rowSelection,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Storage,
fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage,
watch: false,
API: GPU_SERVICE_STORAGE_API,
contentForDelete: '存储'
});
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const intl = useIntl();
const [openAddModalStatus, setOpenAddModalStatus] = useState<{
action: PageActionType;
open: boolean;
title: string;
currentData?: ListItem | null;
}>({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
if (clusters.length > 0) {
handleQueryChange({
cluster_id: clusters[0].id,
page: 1
});
}
});
return () => {
cancelClusterRequest();
};
}, []);
const handleAddStorage = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: '添加存储',
open: true,
currentData: null
});
};
const handleEditStorage = (row: ListItem) => {
setOpenAddModalStatus({
action: PageAction.EDIT,
title: '编辑存储',
open: true,
currentData: row
});
};
const closeModal = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
};
const handleModalOk = async (data: FormData) => {
try {
if (openAddModalStatus.action === PageAction.EDIT) {
await updateGPUServiceStorage({
id: openAddModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceStorage({ data });
}
fetchData();
closeModal();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') {
handleEditStorage(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
}
});
const handleClusterChange = (value: number) => {
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText="未找到匹配的存储"
title="暂无存储"
subTitle="创建一个存储后会显示在这里"
onClick={handleAddStorage}
buttonText="立即添加"
/>
);
};
const columns = useStorageColumns({
handleSelect,
sortOrder
});
return (
<>
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.storage' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
}}
/>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={clusterList}
style={{ minWidth: 120, fontWeight: 500 }}
onChange={handleClusterChange}
></BaseSelect>
</Flex>
}
>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={clusterList}
select={{ showSearch: true }}
selectHolder="按集群过滤"
buttonText="添加存储"
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAddStorage}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageContainerInner>
<AddModal
open={openAddModalStatus.open}
action={openAddModalStatus.action}
title={openAddModalStatus.title}
data={openAddModalStatus.currentData}
onCancel={closeModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
);
};
export default GPUServiceStorage;
@@ -0,0 +1,64 @@
import { request } from '@umijs/max';
import { mockTemplateData } from '../config/mock-data';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_TEMPLATES_API = '/gpu-service-templates';
export async function queryGPUServiceTemplates(
params: Global.SearchParams,
options?: any
) {
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_TEMPLATES_API, {
// method: 'GET',
// params,
// cancelToken: options?.token
// });
const page = params.page || 1;
const perPage = params.perPage || 24;
const search = params.search?.toLowerCase();
const vendor = params.vendor;
const filteredData = mockTemplateData.filter((item) => {
const matchSearch = search
? item.name.toLowerCase().includes(search)
: true;
const matchVendor = vendor ? item.vendor === vendor : true;
return matchSearch && matchVendor;
});
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.PageResponse<ListItem>;
}
export async function createGPUServiceTemplate(params: { data: FormData }) {
// return request<ListItem>(GPU_SERVICE_TEMPLATES_API, {
// method: 'POST',
// data: params.data
// });
return true;
}
export async function updateGPUServiceTemplate(params: {
id: number;
data: FormData;
}) {
// return request<ListItem>(`${GPU_SERVICE_TEMPLATES_API}/${params.id}`, {
// method: 'PUT',
// data: params.data
// });
return true;
}
export async function deleteGPUServiceTemplate(id: number) {
return request(`${GPU_SERVICE_TEMPLATES_API}/${id}`, {
method: 'DELETE'
});
}
@@ -0,0 +1,72 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServiceTemplateForm from '../forms';
type AddModalProps = {
title: string;
action: PageActionType;
open: boolean;
currentData?: ListItem | null;
onOk: (values: FormData) => void;
onCancel: () => void;
};
const AddModal: React.FC<AddModalProps> = ({
title,
action,
open,
currentData,
onOk,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({
...values
});
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={600}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceTemplateForm
ref={form}
action={action}
currentData={currentData}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddModal;
@@ -0,0 +1,208 @@
import ascendLogo from '@/assets/logo/ascend.png';
import CambriconPNG from '@/assets/logo/cambricon.png';
import hygonPNG from '@/assets/logo/hygon.png';
import iluvatarLogo from '@/assets/logo/Iluvatar.png';
import metaxLogo from '@/assets/logo/metax.png';
import mooreLogo from '@/assets/logo/moore-logo.png';
import nvidiaLogo from '@/assets/logo/nvidia.png';
import theadLogoEN from '@/assets/logo/t-head-en.png';
import theadLogoZH from '@/assets/logo/t-head-zh.png';
import { GPUDriverMap, GPUsConfigs } from '@/pages/resources/config/gpu-driver';
import {
AutoTooltip,
DropdownActions,
IconFont,
TemplateCard
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import styled from 'styled-components';
import { templateActions, TemplateStatusValueMap } from '../config';
import { ListItem } from '../config/types';
const StyledCard = styled(TemplateCard)`
&:hover {
.operations {
background-color: var(--ant-color-fill-tertiary);
border-radius: var(--ant-border-radius-lg);
}
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
width: 100%;
`;
const VendorLogo = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
max-width: 88px;
height: 18px;
.logo-img {
max-width: 100%;
object-fit: contain;
}
.amd-logo {
font-size: 28px;
line-height: 1;
color: var(--ant-color-text);
}
`;
const CardName = styled.div`
font-weight: 500;
font-size: 14px;
display: flex;
align-items: center;
color: var(--ant-color-text);
margin-bottom: 8px;
gap: 8px;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 12px;
gap: 8px;
color: var(--ant-color-text-secondary);
`;
const InfoItem = styled.div`
display: grid;
width: 100%;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
color: var(--ant-color-text-tertiary);
.icon {
color: var(--ant-color-text-quaternary);
}
.value {
color: var(--ant-color-text);
}
`;
interface TemplateCardProps {
data: ListItem;
onSelect?: (item: { action: string; data: ListItem }) => void;
}
const vendorLogoMap: Record<string, { src: string; height: number }> = {
[GPUDriverMap.NVIDIA]: { src: nvidiaLogo, height: 16 },
[GPUDriverMap.ASCEND]: { src: ascendLogo, height: 18 },
[GPUDriverMap.HYGON]: { src: hygonPNG, height: 16 },
[GPUDriverMap.MOORE_THREADS]: { src: mooreLogo, height: 18 },
[GPUDriverMap.ILUVATAR]: { src: iluvatarLogo, height: 18 },
[GPUDriverMap.CAMBRICON]: { src: CambriconPNG, height: 18 },
[GPUDriverMap.METAX]: { src: metaxLogo, height: 18 }
};
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl();
const vendorLabel = data.vendor
? GPUsConfigs[data.vendor]?.label || data.vendor
: '-';
const renderVendor = () => {
if (data.vendor === GPUDriverMap.AMD) {
return (
<VendorLogo title={vendorLabel}>
<IconFont className="amd-logo" type="icon-amd-logo" />
</VendorLogo>
);
}
const logo =
data.vendor === GPUDriverMap.THEAD
? {
src: intl.locale === 'zh-CN' ? theadLogoZH : theadLogoEN,
height: 18
}
: vendorLogoMap[data.vendor || ''];
if (!logo) {
return vendorLabel;
}
return (
<VendorLogo title={vendorLabel}>
<img
alt={vendorLabel}
className="logo-img"
src={logo.src}
style={{ height: logo.height }}
/>
</VendorLogo>
);
};
const handleOnSelect = (item: any) => {
onSelect?.({ action: item.key, data });
};
const handleonClickAction = (e: React.MouseEvent) => {
e.stopPropagation();
};
const renderActions = () => {
return (
<span onClick={handleonClickAction} className="operations">
<DropdownActions
menu={{
items: templateActions,
onClick: handleOnSelect
}}
>
<Button
icon={<IconFont type="icon-more" />}
size="small"
type="text"
/>
</DropdownActions>
</span>
);
};
const status = data.status || TemplateStatusValueMap.Enabled;
return (
<StyledCard
clickable={false}
hoverable={true}
disabled={false}
height={126}
ghost
header={
<Header>
{renderVendor()}
{renderActions()}
</Header>
}
>
<Content>
<CardName>
<AutoTooltip ghost minWidth={20}>
{data.name}
</AutoTooltip>
</CardName>
<InfoItem>
<span>
<IconFont className="icon" type="icon-model" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{data.image || '-'}</span>
</AutoTooltip>
</InfoItem>
</Content>
</StyledCard>
);
};
export default TemplateCardItem;
@@ -0,0 +1,92 @@
import {
InfiniteScroller,
ResizeContainer,
TemplateCardSkeleton,
useScrollerContext
} from '@gpustack/core-ui';
import { Spin } from 'antd';
import React from 'react';
import backendListCss from '../../../backends/styles/backend-list.less';
import { ListItem } from '../config/types';
import TemplateCard from './template-card';
interface TemplateListProps {
dataList: ListItem[];
loading: boolean;
isFirst: boolean;
onSelect?: (item: { action: string; data: ListItem }) => void;
}
const ListSkeleton: React.FC<{
loading: boolean;
isFirst: boolean;
}> = ({ loading, isFirst }) => {
return (
<div>
{loading && (
<div className={backendListCss.SpinWrapper}>
<Spin
spinning={loading}
size="middle"
style={{
width: '100%'
}}
classNames={{
root: 'skelton-wrapper'
}}
>
{isFirst && (
<div className={backendListCss.SkeletonWrapper}>
<TemplateCardSkeleton
skeletonProps={{
title: false
}}
skeletonStyle={{
display: 'flex',
flexDirection: 'column',
gap: 24
}}
/>
</div>
)}
</Spin>
</div>
)}
</div>
);
};
const TemplateCardList: React.FC<TemplateListProps> = ({
dataList,
loading,
isFirst,
onSelect
}) => {
const {
total,
current,
loading: contextLoading,
refresh,
throttleDelay
} = useScrollerContext();
return (
<InfiniteScroller
total={total}
current={current}
loading={contextLoading}
refresh={refresh}
throttleDelay={throttleDelay}
>
<ResizeContainer
defaultSpan={8}
resizable={true}
dataList={dataList}
renderItem={(item) => <TemplateCard data={item} onSelect={onSelect} />}
/>
<ListSkeleton loading={loading} isFirst={isFirst} />
</InfiniteScroller>
);
};
export default TemplateCardList;
@@ -0,0 +1,25 @@
import { icons } from '@gpustack/core-ui';
export const TemplateStatusValueMap = {
Enabled: 'enabled',
Disabled: 'disabled'
};
export const TemplateStatusLabelMap: Record<string, string> = {
[TemplateStatusValueMap.Enabled]: '启用',
[TemplateStatusValueMap.Disabled]: '禁用'
};
export const templateActions = [
{
label: '编辑',
key: 'edit',
icon: icons.EditOutlined
},
{
label: '删除',
key: 'delete',
icon: icons.DeleteOutlined,
danger: true
}
];
@@ -0,0 +1,154 @@
import { ListItem } from './types';
export const mockTemplateData: ListItem[] = [
{
id: 1,
name: 'Ubuntu CUDA Dev',
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
vendor: 'cuda',
run_command: '/bin/bash',
boot_disk_size_gb: 30,
volume_size_gb: 100,
volume_mount_path: '/workspace',
ports: [
{
protocol: 'tcp',
value: 22
},
{
protocol: 'udp',
value: 8888
}
],
env: {
NVIDIA_VISIBLE_DEVICES: 'all'
},
gpu_count: 1,
replicas: 1,
status: 'enabled',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
name: 'PyTorch Training',
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
vendor: 'cuda',
run_command: 'python train.py',
boot_disk_size_gb: 50,
volume_size_gb: 200,
volume_mount_path: '/data',
ports: [
{
protocol: 'tcp',
value: 6006
}
],
env: {
PYTHONUNBUFFERED: '1'
},
gpu_count: 2,
replicas: 1,
status: 'enabled',
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
name: 'ROCm Notebook',
image: 'rocm/pytorch:latest',
vendor: 'rocm',
run_command: 'jupyter lab --ip=0.0.0.0 --allow-root',
boot_disk_size_gb: 40,
volume_size_gb: 120,
volume_mount_path: '/notebooks',
ports: [
{
protocol: 'http',
value: 8888
}
],
env: {
HSA_OVERRIDE_GFX_VERSION: '10.3.0'
},
gpu_count: 1,
replicas: 1,
status: 'enabled',
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
},
{
id: 4,
name: 'Ascend MindIE',
image: 'ascend/mindie:latest',
vendor: 'cann',
run_command: '/usr/local/Ascend/mindie/latest/bin/mindieservice_daemon',
boot_disk_size_gb: 60,
volume_size_gb: 160,
volume_mount_path: '/models',
ports: [
{
protocol: 'http',
value: 1025
}
],
env: {
ASCEND_VISIBLE_DEVICES: '0'
},
gpu_count: 1,
replicas: 1,
status: 'enabled',
created_at: '2026-04-04T10:00:00Z',
updated_at: '2026-04-13T10:00:00Z'
},
{
id: 5,
name: 'CPU Utility',
image: 'ubuntu:22.04',
vendor: 'cuda',
run_command: 'sleep infinity',
boot_disk_size_gb: 20,
volume_size_gb: 50,
volume_mount_path: '/mnt/data',
ports: [
{
protocol: 'tcp',
value: 22
}
],
env: {},
gpu_count: 0,
replicas: 1,
status: 'disabled',
created_at: '2026-04-05T10:00:00Z',
updated_at: '2026-04-14T10:00:00Z'
},
{
id: 6,
name: 'Inference Server',
image: 'vllm/vllm-openai:latest',
vendor: 'cuda',
run_command: 'python -m vllm.entrypoints.openai.api_server',
boot_disk_size_gb: 80,
volume_size_gb: 300,
volume_mount_path: '/models',
ports: [
{
protocol: 'udp',
value: 8000
},
{
protocol: 'tcp',
value: 8080
}
],
env: {
VLLM_WORKER_MULTIPROC_METHOD: 'spawn'
},
gpu_count: 4,
replicas: 2,
status: 'enabled',
created_at: '2026-04-06T10:00:00Z',
updated_at: '2026-04-15T10:00:00Z'
}
];
@@ -0,0 +1,27 @@
export interface PortItem {
protocol: 'udp' | 'tcp';
value?: number;
}
export interface FormData {
name: string;
image?: string;
vendor?: string;
image_pull_policy?: string;
run_command?: string;
boot_disk_size_gb?: number;
volume_size_gb?: number;
volume_mount_path?: string;
ports?: PortItem[];
env?: Record<string, any>;
}
export interface ListItem extends FormData {
id: number;
description?: string;
gpu_count?: number;
replicas?: number;
status?: string;
created_at?: string;
updated_at?: string;
}
@@ -0,0 +1,111 @@
import { GPUsConfigs } from '@/pages/resources/config/gpu-driver';
import {
Input as CInput,
InputNumber as CInputNumber,
LabelSelector,
Select as SealSelect,
Textarea
} from '@gpustack/core-ui';
import { Form, Select } from 'antd';
import { FormData } from '../config/types';
import Ports from './ports';
const Basic = () => {
const form = Form.useFormInstance<FormData>();
const envs = Form.useWatch('env', form);
const gpuVendorOptions = Object.values(GPUsConfigs);
const handleEnvChange = (labels: Record<string, any>) => {
form.setFieldValue('env', labels);
};
return (
<>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: '请输入模板名称'
}
]}
>
<CInput.Input label="名称" required />
</Form.Item>
<Form.Item<FormData>
name="vendor"
rules={[
{
required: true,
message: '请选择适用设备厂商'
}
]}
>
<SealSelect label="适用设备厂商" required>
{gpuVendorOptions.map((item) => (
<Select.Option value={item.value} key={item.value}>
{item.label}
</Select.Option>
))}
</SealSelect>
</Form.Item>
<Form.Item<FormData>
name="image"
rules={[
{
required: true,
message: '请输入容器镜像'
}
]}
>
<CInput.Input label="容器镜像" required />
</Form.Item>
{/* <Form.Item<FormData> name="image_pull_policy">
<SealSelect
allowClear
label="镜像拉取策略"
options={[
{ label: 'IfNotPresent', value: 'IfNotPresent' },
{ label: 'Always', value: 'Always' },
{ label: 'Never', value: 'Never' }
]}
></SealSelect>
</Form.Item> */}
<Form.Item<FormData> name="run_command">
<Textarea
label="容器启动命令"
placeholder={'例如:/bin/bash -c "your command"'}
trim={false}
alwaysFocus
scaleSize
/>
</Form.Item>
<Form.Item<FormData> name="boot_disk_size_gb">
<CInputNumber min={1} precision={0} label="容器启动盘大小 (GB)" />
</Form.Item>
{/* <div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name="volume_size_gb">
<CInputNumber min={1} precision={0} label="存储卷大小 (GB)" />
</Form.Item>
</div>
<div style={{ flex: 2 }}>
<Form.Item<FormData> name="volume_mount_path">
<CInput.Input label="存储卷挂载路径" placeholder="例如:/data" />
</Form.Item>
</div>
</div> */}
<Ports />
<Form.Item<FormData> name="env">
<LabelSelector
label="环境变量"
labels={envs || {}}
btnText="添加变量"
onChange={handleEnvChange}
/>
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,85 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
interface TemplateFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name,
image: currentData.image,
vendor: currentData.vendor,
image_pull_policy: currentData.image_pull_policy,
run_command: currentData.run_command,
boot_disk_size_gb: currentData.boot_disk_size_gb,
volume_size_gb: currentData.volume_size_gb,
volume_mount_path: currentData.volume_mount_path,
ports: currentData.ports || [],
env: currentData.env || {}
});
return;
}
form.setFieldsValue({
ports: [
{
protocol: 'tcp',
value: 22
}
],
env: {}
});
}, [action, currentData, form, open]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
return (
<Form
name="gpuServiceTemplateForm"
form={form}
onFinish={onFinish}
preserve={false}
initialValues={{
boot_disk_size_gb: 30,
ports: [
{
protocol: 'udp',
value: 22
}
]
}}
>
<Basic />
</Form>
);
}
);
export default GPUServiceTemplateForm;
@@ -0,0 +1,133 @@
import {
InputNumber as CInputNumber,
MetadataList,
Select as SealSelect
} from '@gpustack/core-ui';
import { Form } from 'antd';
import { FormData, PortItem as PortItemType } from '../config/types';
type PortProtocol = PortItemType['protocol'];
const protocolOptions = [
{
label: 'UDP',
value: 'udp'
},
{
label: 'TCP',
value: 'tcp'
}
];
interface PortItemProps {
item: PortItemType;
index: number;
onChange: (item: PortItemType) => void;
}
const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
return (
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
<div style={{ flex: 1 }}>
<SealSelect
value={item.protocol}
options={protocolOptions}
onChange={(value) => {
onChange({
...item,
protocol: value as PortProtocol
});
}}
style={{ width: '100%' }}
></SealSelect>
</div>
<div style={{ flex: 2 }}>
<CInputNumber
min={1}
max={65535}
disabled={index === 0}
precision={0}
value={item.value}
onChange={(value) => {
onChange({
...item,
value: typeof value === 'number' ? value : undefined
});
}}
style={{ width: '100%' }}
/>
</div>
</div>
);
};
const Ports: React.FC = () => {
const form = Form.useFormInstance<FormData>();
const ports = Form.useWatch('ports', form) || [];
const updatePorts = (list: PortItemType[]) => {
form.setFieldValue('ports', list);
};
const handleAdd = () => {
updatePorts([
...ports,
{
protocol: 'tcp'
}
]);
};
const handleDelete = (index: number) => {
const newList = [...ports];
newList.splice(index, 1);
updatePorts(newList);
};
const handleChange = (index: number, item: PortItemType) => {
const newList = [...ports];
newList[index] = item;
updatePorts(newList);
};
return (
<Form.Item<FormData>
name="ports"
rules={[
{
validator: async (_, value) => {
if (!value?.length) {
return Promise.resolve();
}
const hasInvalidPort = value.some(
(item: PortItemType) => !item.protocol || !item.value
);
if (hasInvalidPort) {
return Promise.reject(new Error('请填写完整的端口配置'));
}
return Promise.resolve();
}
}
]}
>
<MetadataList
dataList={ports}
btnText="添加端口"
label="端口"
onAdd={handleAdd}
onDelete={handleDelete}
>
{(item, index) => (
<PortItem
key={index}
index={index}
item={item}
onChange={(data) => handleChange(index, data)}
/>
)}
</MetadataList>
</Form.Item>
);
};
export default Ports;
@@ -0,0 +1,48 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreateTemplate = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: undefined,
title: ''
});
const openModal = (action: PageActionType, title: string, row?: ListItem) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: undefined,
title: ''
});
restoreScrollHeight();
};
return {
openTemplateModalStatus: openModalStatus,
openTemplateModal: openModal,
closeTemplateModal: closeModal
};
};
export default useCreateTemplate;
+164 -1
View File
@@ -1,7 +1,170 @@
import { PageAction } from '@/config';
import useTableFetch from '@/hooks/use-table-fetch';
import { GPUsConfigs } from '@/pages/resources/config/gpu-driver';
import {
DeleteModal,
FilterBar,
IconFont,
InfiniteScrollerProvider,
NoResult
} from '@gpustack/core-ui';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import _ from 'lodash';
import PageBox from '../../_components/page-box';
import {
createGPUServiceTemplate,
deleteGPUServiceTemplate,
GPU_SERVICE_TEMPLATES_API,
queryGPUServiceTemplates,
updateGPUServiceTemplate
} from './apis';
import AddModal from './components/add-modal';
import TemplateCardList from './components/template-list';
import { FormData, ListItem } from './config/types';
import useCreateTemplate from './hooks/use-create-template';
const GPUServiceTemplates: React.FC = () => {
return <PageBox> GPU Service Templates Page</PageBox>;
const {
dataSource,
rowSelection,
queryParams,
modalRef,
handleQueryChange,
fetchData,
handleDelete,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
fetchAPI: queryGPUServiceTemplates,
deleteAPI: deleteGPUServiceTemplate,
API: GPU_SERVICE_TEMPLATES_API,
watch: false,
isInfiniteScroll: true,
contentForDelete: 'GPU 实例模板',
defaultQueryParams: {
perPage: 24
}
});
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate();
const handleAddTemplate = () => {
openTemplateModal(PageAction.CREATE, '添加实例模板');
};
const handleEditTemplate = (row: ListItem) => {
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
};
const handleModalOk = async (data: FormData) => {
try {
if (openTemplateModalStatus.action === PageAction.EDIT) {
await updateGPUServiceTemplate({
id: openTemplateModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceTemplate({ data });
}
closeTemplateModal();
handleSearch();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
const gpuVendorOptions = Object.values(GPUsConfigs).map((item) => ({
label: item.label,
value: item.value
}));
const handleFilterByVendor = (value: string) => {
handleQueryChange({
vendor: value,
page: 1
});
};
const handleOnSelect = (item: { action: string; data: ListItem }) => {
if (item.action === 'edit') {
handleEditTemplate(item.data);
return;
}
if (item.action === 'delete') {
handleDelete({ ...item.data, name: item.data.name });
}
};
const loadMore = useMemoizedFn((nextPage: number) => {
fetchData({
query: {
...queryParams,
page: nextPage
},
loadmore: true
});
});
return (
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
widths={{
input: 230
}}
inputHolder="按名称过滤"
selectHolder="按厂商过滤"
buttonText="添加实例模板"
handleClickPrimary={handleAddTemplate}
handleSearch={handleSearch}
handleSelectChange={handleFilterByVendor}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
showSelect={true}
selectOptions={gpuVendorOptions}
/>
<InfiniteScrollerProvider
value={{
total: dataSource.totalPage,
current: queryParams.page!,
loading: dataSource.loading,
refresh: loadMore,
throttleDelay: 300
}}
>
<TemplateCardList
dataList={dataSource.dataList}
loading={dataSource.loading}
isFirst={!dataSource.loadend}
onSelect={handleOnSelect}
/>
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-instance-template-filled" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText="未找到匹配的实例模板"
title="暂无实例模板"
subTitle="创建一个实例模板后会显示在这里"
onClick={handleAddTemplate}
buttonText="立即添加"
/>
</InfiniteScrollerProvider>
<AddModal
action={openTemplateModalStatus.action}
open={openTemplateModalStatus.open}
title={openTemplateModalStatus.title}
currentData={openTemplateModalStatus.currentData}
onCancel={closeTemplateModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</PageBox>
);
};
export default GPUServiceTemplates;
@@ -0,0 +1,36 @@
import { INPUT_WIDTH } from '@/constants';
import { Textarea } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd';
import React from 'react';
interface PublicKeyFormData {
public_key?: string;
}
const PublicKey: React.FC = () => {
const intl = useIntl();
const [form] = Form.useForm<PublicKeyFormData>();
return (
<Form style={{ width: '524px' }} name="publicKeyForm" form={form}>
<Form.Item<PublicKeyFormData> name="public_key">
<Textarea
label="SSH 公钥"
placeholder="将您的 SSH 公钥粘贴到此处"
trim={false}
alwaysFocus
autoSize={{ minRows: 4, maxRows: 8 }}
style={{ width: INPUT_WIDTH.default }}
></Textarea>
</Form.Item>
<Button type="primary" style={{ width: 120, marginTop: 100 }}>
{intl.formatMessage({ id: 'common.button.save' })}
</Button>
</Form>
);
};
PublicKey.displayName = 'PublicKey';
export default PublicKey;
+11
View File
@@ -6,6 +6,7 @@ import styled from 'styled-components';
import PageBox from '../_components/page-box';
import Appearance from './components/appearance';
import ModifyPasswordn from './components/modify-password';
import PublicKey from './components/public-key';
const Wrapper = styled.div`
.ant-page-header-heading {
@@ -26,6 +27,11 @@ const Profile: React.FC = () => {
const items: TabsProps['items'] = useMemo(() => {
if (initialState?.currentUser?.source !== 'Local') {
return [
{
key: 'public-key',
label: 'SSH Public Key',
children: <PublicKey />
},
{
key: 'appearance',
label: intl.formatMessage({ id: 'common.appearance' }),
@@ -39,6 +45,11 @@ const Profile: React.FC = () => {
label: intl.formatMessage({ id: 'users.form.updatepassword' }),
children: <ModifyPasswordn />
},
{
key: 'public-key',
label: 'SSH 公钥',
children: <PublicKey />
},
{
key: 'appearance',
label: intl.formatMessage({ id: 'common.appearance' }),