diff --git a/DESIGN.md b/DESIGN.md index 5543a06a..806376d9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 = { + [XxxStatusValueMap.Running]: 'Running', + [XxxStatusValueMap.Pending]: 'Pending', + [XxxStatusValueMap.Failed]: 'Failed' +}; + +export const status: Record = { + [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 + +``` + +`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. diff --git a/config/routes.ts b/config/routes.ts index 9c4fe8d4..d0760cce 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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', diff --git a/src/assets/logo/pytorch-light.png b/src/assets/logo/pytorch-light.png new file mode 100644 index 00000000..05eb2619 Binary files /dev/null and b/src/assets/logo/pytorch-light.png differ diff --git a/src/assets/logo/pytorch.png b/src/assets/logo/pytorch.png new file mode 100644 index 00000000..d0bb1678 Binary files /dev/null and b/src/assets/logo/pytorch.png differ diff --git a/src/config/settings.ts b/src/config/settings.ts index 60b89106..f47fdec6 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -40,6 +40,7 @@ export const PaginationKey = { Benchmarks: 'Benchmarks', GPUs: 'GPUs', ModelFiles: 'ModelFiles', + Storage: 'Storage', Users: 'Users', APIKeys: 'APIKeys', Credentials: 'Credentials', diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 95ae85a1..1c9b9ccf 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -64,7 +64,9 @@ const NO_CONTAINER_PAGES = [ 'clusterCreate', 'benchmarkDetail', 'deployment', - 'video' + 'video', + 'instances', + 'storage' ]; const CHECK_RESOURCE_PATH = [ diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts index f030ebac..1cafc921 100644 --- a/src/locales/en-US/menu.ts +++ b/src/locales/en-US/menu.ts @@ -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' }; diff --git a/src/locales/ja-JP/menu.ts b/src/locales/ja-JP/menu.ts index 6fdc7e5c..01ccf72a 100644 --- a/src/locales/ja-JP/menu.ts +++ b/src/locales/ja-JP/menu.ts @@ -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) ========== diff --git a/src/locales/ru-RU/menu.ts b/src/locales/ru-RU/menu.ts index 14716d3b..e6ede7b0 100644 --- a/src/locales/ru-RU/menu.ts +++ b/src/locales/ru-RU/menu.ts @@ -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) ========== diff --git a/src/locales/tr-TR/menu.ts b/src/locales/tr-TR/menu.ts index 206489ed..10d30c09 100644 --- a/src/locales/tr-TR/menu.ts +++ b/src/locales/tr-TR/menu.ts @@ -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) ========== diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts index 27d4cbd5..8b93ccbd 100644 --- a/src/locales/zh-CN/menu.ts +++ b/src/locales/zh-CN/menu.ts @@ -44,5 +44,5 @@ export default { 'menu.gpuService': 'GPU 服务', 'menu.gpuService.instances': 'GPU 实例', 'menu.gpuService.templates': '实例模板', - 'menu.gpuService.storage': '存储管理' + 'menu.gpuService.storage': '存储' }; diff --git a/src/pages/_components/form-drawer.tsx b/src/pages/_components/form-drawer.tsx index e27f16d8..168f5cad 100644 --- a/src/pages/_components/form-drawer.tsx +++ b/src/pages/_components/form-drawer.tsx @@ -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 = ({ title, @@ -26,7 +28,8 @@ const FormDrawer: React.FC = ({ children, width = 600, subTitle, - footer + footer, + push }) => { return ( = ({ closable: false }} keyboard={false} + push={push} styles={{ wrapper: { width } }} diff --git a/src/pages/_components/form-overlay-view.module.less b/src/pages/_components/form-overlay-view.module.less new file mode 100644 index 00000000..2ca2dcba --- /dev/null +++ b/src/pages/_components/form-overlay-view.module.less @@ -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; +} diff --git a/src/pages/_components/form-overlay-view.tsx b/src/pages/_components/form-overlay-view.tsx new file mode 100644 index 00000000..7ab4de89 --- /dev/null +++ b/src/pages/_components/form-overlay-view.tsx @@ -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 = ({ + title, + open, + onCancel, + onSubmit, + children, + subTitle, + footer, + width = 600, + className, + style, + getContainer +}) => { + const [container, setContainer] = React.useState(null); + + React.useEffect(() => { + if (!open) { + setContainer(null); + return; + } + + setContainer(getContainer?.() ?? null); + }, [getContainer, open]); + + if (!open) { + return null; + } + + if (!container) { + return null; + } + + return createPortal( +
+
+
+ + {children} + +
, + container + ); +}; + +export default FormOverlayView; diff --git a/src/pages/gpu-service/instances/apis/index.ts b/src/pages/gpu-service/instances/apis/index.ts index d7804daa..cc7b763f 100644 --- a/src/pages/gpu-service/instances/apis/index.ts +++ b/src/pages/gpu-service/instances/apis/index.ts @@ -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>(GPU_SERVICE_INSTANCES_API, { - method: 'GET', - params, - cancelToken: options?.token + // return request>(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; } export async function createGPUServiceInstance(params: { data: FormData }) { - return request(GPU_SERVICE_INSTANCES_API, { - method: 'POST', - data: params.data - }); + // return request(GPU_SERVICE_INSTANCES_API, { + // method: 'POST', + // data: params.data + // }); + return true; } export async function updateGPUServiceInstance(params: { id: number; data: FormData; }) { - return request(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, { - method: 'PUT', - data: params.data - }); + // return request(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, { + // method: 'PUT', + // data: params.data + // }); + return true; } export async function deleteGPUServiceInstance(id: number) { diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index 38530f5e..07d7cdaa 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -46,6 +46,7 @@ const AddModal: React.FC = ({ onCancel={handleCancel} onSubmit={handleSubmit} width={600} + push={{ distance: 24 }} footer={ void; + dataList?: InstanceItem[]; +} + +const InstanceTypeList: React.FC = ({ + value, + onChange, + dataList = instanceTypeOptions +}) => { + const handleSelect = (item: InstanceItem) => { + if (item.status !== InstanceTypeStatusValueMap.Available) { + return; + } + + onChange?.(item.id); + }; + + return ( + + {dataList.map((item) => { + const disabled = item.status !== InstanceTypeStatusValueMap.Available; + return ( + handleSelect(item)} + > + + + + {item.name} + + + + 库存 {item.gpu_count} + + + + 显存 {item.vram} GiB + + 内存 {item.ram} GiB + CPU {item.vCPU} + + + + ); + })} + + ); +}; + +export default InstanceTypeList; diff --git a/src/pages/gpu-service/instances/config/index.ts b/src/pages/gpu-service/instances/config/index.ts index 3b02537a..6ef3f853 100644 --- a/src/pages/gpu-service/instances/config/index.ts +++ b/src/pages/gpu-service/instances/config/index.ts @@ -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 = { - [InstanceStatusValueMap.Running]: '运行中', - [InstanceStatusValueMap.Pending]: '等待中', - [InstanceStatusValueMap.Stopped]: '已停止', - [InstanceStatusValueMap.Failed]: '失败' + [InstanceStatusValueMap.Ready]: 'Ready', + [InstanceStatusValueMap.Pending]: 'Pending', + [InstanceStatusValueMap.Error]: 'Error' +}; + +export const status: Record = { + [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' +}; diff --git a/src/pages/gpu-service/instances/config/mock-data.ts b/src/pages/gpu-service/instances/config/mock-data.ts new file mode 100644 index 00000000..17a41934 --- /dev/null +++ b/src/pages/gpu-service/instances/config/mock-data.ts @@ -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' + } +]; diff --git a/src/pages/gpu-service/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts index 9c75f59d..b3b51a33 100644 --- a/src/pages/gpu-service/instances/config/types.ts +++ b/src/pages/gpu-service/instances/config/types.ts @@ -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 +} diff --git a/src/pages/gpu-service/instances/forms/basic.tsx b/src/pages/gpu-service/instances/forms/basic.tsx index adfe7292..41d7058b 100644 --- a/src/pages/gpu-service/instances/forms/basic.tsx +++ b/src/pages/gpu-service/instances/forms/basic.tsx @@ -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 ( - <> +
name="name" rules={[ @@ -19,49 +16,10 @@ const Basic = () => { > - - name="image" - rules={[ - { - required: true, - message: '请输入镜像' - } - ]} - > - - -
-
- - name="gpu_count" - rules={[ - { - required: true, - message: '请输入 GPU 数量' - } - ]} - > - - -
-
- - name="replicas" - rules={[ - { - required: true, - message: '请输入副本数' - } - ]} - > - - -
-
name="description"> - +
); }; diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index acd7bf48..81b57a0c 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -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; } +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 = forwardRef( (props, ref) => { const { action, currentData, open, onFinish } = props; const [form] = Form.useForm(); + const scrollTabsRef = useRef(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: , + field: 'name' + }, + { + value: TABKeysMap.INSTANCE_TYPE, + label: '实例类型', + icon: , + field: 'instanceType' + }, + { + value: TABKeysMap.TEMPLATE, + label: '实例模板', + icon: , + field: 'template' + }, + { + value: TABKeysMap.STORAGE, + label: '存储卷', + icon: , + 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 = 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 = forwardRef( })); return ( -
- - +
+ + + }, + { + key: TABKeysMap.TEMPLATE, + label: '实例模板', + forceRender: true, + children: + }, + { + key: TABKeysMap.STORAGE, + label: '存储卷', + forceRender: true, + children: + } + ]} + /> + + ); } ); diff --git a/src/pages/gpu-service/instances/forms/instance-type-overlay.tsx b/src/pages/gpu-service/instances/forms/instance-type-overlay.tsx new file mode 100644 index 00000000..d1098523 --- /dev/null +++ b/src/pages/gpu-service/instances/forms/instance-type-overlay.tsx @@ -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 = ({ + 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( + '.ant-layout-content' + ); + + return containers[containers.length - 1] ?? null; + }, []); + + return ( + + + } + placeholder="搜索名称、GPU、显存、内存或 vCPU" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + {filteredOptions.length > 0 ? ( + + ) : ( + + )} + + + ); +}; + +export default InstanceTypeOverlay; diff --git a/src/pages/gpu-service/instances/forms/instance-type.tsx b/src/pages/gpu-service/instances/forms/instance-type.tsx new file mode 100644 index 00000000..717837a7 --- /dev/null +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -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 = ({ + value, + onChange +}) => { + const [open, setOpen] = useState(false); + + const selected = useMemo(() => { + return instanceTypeOptions.find((item) => item.id === value); + }, [value]); + + return ( + <> + +
+ + + + {selected?.name || '请选择实例类型'} + + + 库存 {selected?.gpu_count} + + + {/* {selected && ( + + 可用 + + )} */} + + + 显存 {selected?.vram ?? '-'} GiB + + 内存 {selected?.ram ?? '-'} GiB + CPU {selected?.vCPU ?? '-'} + + +
+ +
+ setOpen(false)} + onChange={onChange} + /> + + ); +}; + +const InstanceTypeFormItem = () => { + const form = Form.useFormInstance(); + 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 ( + <> + + + name="instance_type_id" + rules={[ + { + required: true, + message: '请选择实例类型' + } + ]} + > + + + + + name="gpu_count" + rules={[ + { + required: true, + message: '请输入 GPU 数量' + }, + { + validator: (_, value) => { + if (value > maxGpuCount) { + return Promise.reject( + new Error(`当前实例类型最多支持 ${maxGpuCount} 个 GPU`) + ); + } + return Promise.resolve(); + } + } + ]} + > + + + name="instance_type" hidden> + + + + ); +}; + +export const useSelectedInstanceType = () => { + const form = Form.useFormInstance(); + const instanceTypeId = Form.useWatch('instance_type_id', form); + + return useMemo(() => { + return instanceTypeOptions.find((item) => item.id === instanceTypeId); + }, [instanceTypeId]); +}; + +export default InstanceTypeFormItem; diff --git a/src/pages/gpu-service/instances/forms/storage-volume.tsx b/src/pages/gpu-service/instances/forms/storage-volume.tsx new file mode 100644 index 00000000..245cc1ae --- /dev/null +++ b/src/pages/gpu-service/instances/forms/storage-volume.tsx @@ -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(); + const storageMode = Form.useWatch('storage_mode', form); + + return ( + + + + name="storage_mode" + style={{ marginBottom: 12 }} + rules={[ + { + required: true, + message: '请选择存储卷' + } + ]} + > + + + + + + {storageMode === StorageModeValueMap.Existing && ( +
+
+ + name="storage_id" + rules={[ + { + required: true, + message: '请选择预存储卷' + } + ]} + > + } + placeholder="搜索模板名称、镜像、厂商或挂载路径" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + + + {filteredTemplates.length > 0 ? ( + + ) : ( + + )} + + + ); +}; + +export default TemplateOverlay; diff --git a/src/pages/gpu-service/instances/forms/template.tsx b/src/pages/gpu-service/instances/forms/template.tsx new file mode 100644 index 00000000..f0f85909 --- /dev/null +++ b/src/pages/gpu-service/instances/forms/template.tsx @@ -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 = ({ + value, + onChange, + onCreate, + onEdit +}) => { + const [open, setOpen] = useState(false); + + const selected = useMemo(() => { + return mockTemplateData.find((item) => item.id === value); + }, [value]); + + return ( + <> + +
+ + + {selected?.name || '请选择实例模板'} + + {/* {selected && ( + + {selected.status === 'enabled' ? '启用' : '停用'} + + )} */} + + + + 镜像: {selected?.image || '-'} + + + 存储: {selected?.volume_size_gb ?? '-'} GB /{' '} + {selected?.volume_mount_path || '-'} + + +
+ + + + +
+ setOpen(false)} + onChange={onChange} + onCreate={() => { + // setOpen(false); + onCreate?.(); + }} + /> + + ); +}; + +const TemplateFormItem = () => { + const form = Form.useFormInstance(); + 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 ( +
+ + + name="template_id" + rules={[ + { + required: true, + message: '请选择实例模板' + } + ]} + > + + + + + name="image" hidden> + + + + +
+ ); +}; + +export default TemplateFormItem; diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index 21e4e41b..844f6bfd 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -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 = { - [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 ( - - {InstanceStatusLabelMap[value] || value} - + ); } }, { - title: '镜像', - dataIndex: 'image', - key: 'image', - ellipsis: { - showTitle: false - }, - render: (text: string) => ( - - {text || '-'} - - ) - }, - { - 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) => ( - - {text || '-'} - - ) + render: (value: string) => value ?? '-' }, { title: '创建时间', diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index 2f17e7c6..c9fdc163 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -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({ @@ -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={} + image={} filters={_.omit(queryParams, ['sort_by'])} noFoundText="未找到匹配的 GPU 实例" title="暂无 GPU 实例" subTitle="创建一个 GPU 实例后会显示在这里" onClick={handleAddInstance} - buttonText="创建" + buttonText="立即添加" /> ); }; @@ -132,44 +168,66 @@ const GPUService: React.FC = () => { }); return ( - <> - - - - + {intl.formatMessage({ id: 'menu.gpuService.instances' })} + - - + + + } + > + + +
+ { onOk={handleModalOk} /> - + ); }; diff --git a/src/pages/gpu-service/storage/apis/index.ts b/src/pages/gpu-service/storage/apis/index.ts new file mode 100644 index 00000000..6af6aecb --- /dev/null +++ b/src/pages/gpu-service/storage/apis/index.ts @@ -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>(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; +} + +export async function createGPUServiceStorage(params: { data: FormData }) { + // return request(GPU_SERVICE_STORAGE_API, { + // method: 'POST', + // data: params.data + // }); + return true; +} + +export async function updateGPUServiceStorage(params: { + id: number; + data: FormData; +}) { + // return request(`${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' + }); +} diff --git a/src/pages/gpu-service/storage/components/add-modal.tsx b/src/pages/gpu-service/storage/components/add-modal.tsx new file mode 100644 index 00000000..91158ce6 --- /dev/null +++ b/src/pages/gpu-service/storage/components/add-modal.tsx @@ -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 = ({ + title, + action, + open, + onOk, + data, + onCancel +}) => { + const form = useRef(null); + + const handleSubmit = () => { + form.current?.submit(); + }; + + const handleCancel = () => { + form.current?.resetFields(); + onCancel(); + }; + + const onFinish = async (values: FormData) => { + onOk({ + ...values + }); + }; + + return ( + + } + > + + + ); +}; + +export default AddModal; diff --git a/src/pages/gpu-service/storage/config/index.ts b/src/pages/gpu-service/storage/config/index.ts new file mode 100644 index 00000000..dcae4324 --- /dev/null +++ b/src/pages/gpu-service/storage/config/index.ts @@ -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 = { + [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 = { + [StorageStatusValueMap.Available]: 'Available', + [StorageStatusValueMap.Bound]: 'Bound', + [StorageStatusValueMap.Released]: 'Released', + [StorageStatusValueMap.Failed]: 'Failed' +}; + +export const status: Record = { + [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 + } + } +]; diff --git a/src/pages/gpu-service/storage/config/mock-data.ts b/src/pages/gpu-service/storage/config/mock-data.ts new file mode 100644 index 00000000..ffc4fd8c --- /dev/null +++ b/src/pages/gpu-service/storage/config/mock-data.ts @@ -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' + } +]; diff --git a/src/pages/gpu-service/storage/config/types.ts b/src/pages/gpu-service/storage/config/types.ts new file mode 100644 index 00000000..e67f5317 --- /dev/null +++ b/src/pages/gpu-service/storage/config/types.ts @@ -0,0 +1,17 @@ +export interface FormData { + name: string; + description?: string; + type?: string; + capacity_gb?: number; + mount_path?: string; + access_modes?: string[]; + parameters?: Record; + status?: string; + cluster_id?: number; +} + +export interface ListItem extends FormData { + id: number; + created_at?: string; + updated_at?: string; +} diff --git a/src/pages/gpu-service/storage/forms/basic.tsx b/src/pages/gpu-service/storage/forms/basic.tsx new file mode 100644 index 00000000..ba713957 --- /dev/null +++ b/src/pages/gpu-service/storage/forms/basic.tsx @@ -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(); + const parameters = Form.useWatch('parameters', form); + + const handleParametersChange = (value: Record) => { + form.setFieldValue('parameters', value); + }; + + return ( + <> + + name="name" + rules={[ + { + required: true, + message: '请输入存储名称' + } + ]} + > + + +
+
+ + name="type" + rules={[ + { + required: true, + message: '请选择存储类型' + } + ]} + > + + +
+
+ + name="capacity_gb" + rules={[ + { + required: true, + message: '请输入容量' + } + ]} + > + + +
+
+ name="access_modes"> + + + {/* name="parameters"> + + */} + name="description"> + + + + ); +}; + +export default Basic; diff --git a/src/pages/gpu-service/storage/forms/index.tsx b/src/pages/gpu-service/storage/forms/index.tsx new file mode 100644 index 00000000..c72ea56b --- /dev/null +++ b/src/pages/gpu-service/storage/forms/index.tsx @@ -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; +} + +const GPUServiceStorageForm: React.FC = forwardRef( + (props, ref) => { + const { action, currentData, open, onFinish } = props; + const [form] = Form.useForm(); + + 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 ( +
+ + + ); + } +); + +export default GPUServiceStorageForm; diff --git a/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx new file mode 100644 index 00000000..a06dc430 --- /dev/null +++ b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx @@ -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 => { + return useMemo(() => { + return [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + sorter: tableSorter(1), + ellipsis: { + showTitle: false + }, + render: (text: string) => ( + + {text} + + ) + }, + { + 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 ( + + ); + } + }, + { + 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) => ( + // + // {text || '-'} + // + // ) + // }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + sorter: tableSorter(5), + ellipsis: { + showTitle: false + }, + render: (text: string) => ( + + {text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'} + + ) + }, + { + title: '操作', + key: 'operation', + dataIndex: 'operation', + render: (_text, record) => ( + handleSelect(val, record)} + /> + ) + } + ]; + }, [handleSelect, sortOrder]); +}; + +export default useStorageColumns; diff --git a/src/pages/gpu-service/storage/index.tsx b/src/pages/gpu-service/storage/index.tsx index 871cc19e..c4e6d421 100644 --- a/src/pages/gpu-service/storage/index.tsx +++ b/src/pages/gpu-service/storage/index.tsx @@ -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 GPU Service Storage Page; + const { + dataSource, + rowSelection, + queryParams, + sortOrder, + modalRef, + handleDelete, + handleDeleteBatch, + fetchData, + handlePageChange, + handleTableChange, + handleQueryChange, + handleSearch, + handleNameChange + } = useTableFetch({ + 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 ( + } + filters={_.omit(queryParams, ['sort_by'])} + noFoundText="未找到匹配的存储" + title="暂无存储" + subTitle="创建一个存储后会显示在这里" + onClick={handleAddStorage} + buttonText="立即添加" + /> + ); + }; + + const columns = useStorageColumns({ + handleSelect, + sortOrder + }); + + return ( + <> + + {intl.formatMessage({ id: 'menu.gpuService.storage' })} + + + + } + > + + +
+ + + + + + ); }; export default GPUServiceStorage; diff --git a/src/pages/gpu-service/templates/apis/index.ts b/src/pages/gpu-service/templates/apis/index.ts new file mode 100644 index 00000000..baf6e12f --- /dev/null +++ b/src/pages/gpu-service/templates/apis/index.ts @@ -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>(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; +} + +export async function createGPUServiceTemplate(params: { data: FormData }) { + // return request(GPU_SERVICE_TEMPLATES_API, { + // method: 'POST', + // data: params.data + // }); + return true; +} + +export async function updateGPUServiceTemplate(params: { + id: number; + data: FormData; +}) { + // return request(`${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' + }); +} diff --git a/src/pages/gpu-service/templates/components/add-modal.tsx b/src/pages/gpu-service/templates/components/add-modal.tsx new file mode 100644 index 00000000..f99bc157 --- /dev/null +++ b/src/pages/gpu-service/templates/components/add-modal.tsx @@ -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 = ({ + title, + action, + open, + currentData, + onOk, + onCancel +}) => { + const form = useRef(null); + + const handleSubmit = () => { + form.current?.submit(); + }; + + const handleCancel = () => { + form.current?.resetFields(); + onCancel(); + }; + + const onFinish = async (values: FormData) => { + onOk({ + ...values + }); + }; + + return ( + + } + > + + + ); +}; + +export default AddModal; diff --git a/src/pages/gpu-service/templates/components/template-card.tsx b/src/pages/gpu-service/templates/components/template-card.tsx new file mode 100644 index 00000000..2135ffaa --- /dev/null +++ b/src/pages/gpu-service/templates/components/template-card.tsx @@ -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 = { + [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 = ({ data, onSelect }) => { + const intl = useIntl(); + const vendorLabel = data.vendor + ? GPUsConfigs[data.vendor]?.label || data.vendor + : '-'; + + const renderVendor = () => { + if (data.vendor === GPUDriverMap.AMD) { + return ( + + + + ); + } + + const logo = + data.vendor === GPUDriverMap.THEAD + ? { + src: intl.locale === 'zh-CN' ? theadLogoZH : theadLogoEN, + height: 18 + } + : vendorLogoMap[data.vendor || '']; + + if (!logo) { + return vendorLabel; + } + + return ( + + {vendorLabel} + + ); + }; + + const handleOnSelect = (item: any) => { + onSelect?.({ action: item.key, data }); + }; + + const handleonClickAction = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const renderActions = () => { + return ( + + + + + ); +}; + +PublicKey.displayName = 'PublicKey'; + +export default PublicKey; diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 8ece3d0a..ffd47a99 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -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: + }, { key: 'appearance', label: intl.formatMessage({ id: 'common.appearance' }), @@ -39,6 +45,11 @@ const Profile: React.FC = () => { label: intl.formatMessage({ id: 'users.form.updatepassword' }), children: }, + { + key: 'public-key', + label: 'SSH 公钥', + children: + }, { key: 'appearance', label: intl.formatMessage({ id: 'common.appearance' }),