From 7ea7f6666d1c1008bd67decc9e622926a6979a8e Mon Sep 17 00:00:00 2001 From: jialin Date: Fri, 24 Apr 2026 17:14:29 +0800 Subject: [PATCH] feat: templates, storage --- DESIGN.md | 47 +++- config/routes.ts | 4 +- src/assets/logo/pytorch-light.png | Bin 0 -> 7117 bytes src/assets/logo/pytorch.png | Bin 0 -> 26590 bytes src/config/settings.ts | 1 + src/layouts/index.tsx | 4 +- src/locales/en-US/menu.ts | 2 +- src/locales/ja-JP/menu.ts | 2 +- src/locales/ru-RU/menu.ts | 2 +- src/locales/tr-TR/menu.ts | 2 +- src/locales/zh-CN/menu.ts | 2 +- src/pages/_components/form-drawer.tsx | 6 +- .../_components/form-overlay-view.module.less | 59 +++++ src/pages/_components/form-overlay-view.tsx | 90 +++++++ src/pages/gpu-service/instances/apis/index.ts | 50 +++- .../instances/components/add-modal.tsx | 1 + .../components/instance-type-list.tsx | 103 ++++++++ .../gpu-service/instances/config/index.ts | 61 ++++- .../gpu-service/instances/config/mock-data.ts | 59 +++++ .../gpu-service/instances/config/types.ts | 18 ++ .../gpu-service/instances/forms/basic.tsx | 48 +--- .../gpu-service/instances/forms/index.tsx | 169 +++++++++++- .../instances/forms/instance-type-overlay.tsx | 91 +++++++ .../instances/forms/instance-type.tsx | 199 ++++++++++++++ .../instances/forms/storage-volume.tsx | 118 +++++++++ .../instances/forms/template-overlay.tsx | 193 ++++++++++++++ .../gpu-service/instances/forms/template.tsx | 209 +++++++++++++++ .../instances/hooks/use-instances-columns.tsx | 72 ++---- src/pages/gpu-service/instances/index.tsx | 146 +++++++---- src/pages/gpu-service/storage/apis/index.ts | 64 +++++ .../storage/components/add-modal.tsx | 72 ++++++ src/pages/gpu-service/storage/config/index.ts | 73 ++++++ .../gpu-service/storage/config/mock-data.ts | 69 +++++ src/pages/gpu-service/storage/config/types.ts | 17 ++ src/pages/gpu-service/storage/forms/basic.tsx | 81 ++++++ src/pages/gpu-service/storage/forms/index.tsx | 71 +++++ .../storage/hooks/use-storage-columns.tsx | 113 ++++++++ src/pages/gpu-service/storage/index.tsx | 243 +++++++++++++++++- src/pages/gpu-service/templates/apis/index.ts | 64 +++++ .../templates/components/add-modal.tsx | 72 ++++++ .../templates/components/template-card.tsx | 208 +++++++++++++++ .../templates/components/template-list.tsx | 92 +++++++ .../gpu-service/templates/config/index.tsx | 25 ++ .../gpu-service/templates/config/mock-data.ts | 154 +++++++++++ .../gpu-service/templates/config/types.ts | 27 ++ .../gpu-service/templates/forms/basic.tsx | 111 ++++++++ .../gpu-service/templates/forms/index.tsx | 85 ++++++ .../gpu-service/templates/forms/ports.tsx | 133 ++++++++++ .../templates/hooks/use-create-template.ts | 48 ++++ src/pages/gpu-service/templates/index.tsx | 165 +++++++++++- src/pages/profile/components/public-key.tsx | 36 +++ src/pages/profile/index.tsx | 11 + 52 files changed, 3604 insertions(+), 188 deletions(-) create mode 100644 src/assets/logo/pytorch-light.png create mode 100644 src/assets/logo/pytorch.png create mode 100644 src/pages/_components/form-overlay-view.module.less create mode 100644 src/pages/_components/form-overlay-view.tsx create mode 100644 src/pages/gpu-service/instances/components/instance-type-list.tsx create mode 100644 src/pages/gpu-service/instances/config/mock-data.ts create mode 100644 src/pages/gpu-service/instances/forms/instance-type-overlay.tsx create mode 100644 src/pages/gpu-service/instances/forms/instance-type.tsx create mode 100644 src/pages/gpu-service/instances/forms/storage-volume.tsx create mode 100644 src/pages/gpu-service/instances/forms/template-overlay.tsx create mode 100644 src/pages/gpu-service/instances/forms/template.tsx create mode 100644 src/pages/gpu-service/storage/apis/index.ts create mode 100644 src/pages/gpu-service/storage/components/add-modal.tsx create mode 100644 src/pages/gpu-service/storage/config/index.ts create mode 100644 src/pages/gpu-service/storage/config/mock-data.ts create mode 100644 src/pages/gpu-service/storage/config/types.ts create mode 100644 src/pages/gpu-service/storage/forms/basic.tsx create mode 100644 src/pages/gpu-service/storage/forms/index.tsx create mode 100644 src/pages/gpu-service/storage/hooks/use-storage-columns.tsx create mode 100644 src/pages/gpu-service/templates/apis/index.ts create mode 100644 src/pages/gpu-service/templates/components/add-modal.tsx create mode 100644 src/pages/gpu-service/templates/components/template-card.tsx create mode 100644 src/pages/gpu-service/templates/components/template-list.tsx create mode 100644 src/pages/gpu-service/templates/config/index.tsx create mode 100644 src/pages/gpu-service/templates/config/mock-data.ts create mode 100644 src/pages/gpu-service/templates/config/types.ts create mode 100644 src/pages/gpu-service/templates/forms/basic.tsx create mode 100644 src/pages/gpu-service/templates/forms/index.tsx create mode 100644 src/pages/gpu-service/templates/forms/ports.tsx create mode 100644 src/pages/gpu-service/templates/hooks/use-create-template.ts create mode 100644 src/pages/profile/components/public-key.tsx 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 0000000000000000000000000000000000000000..05eb2619e0a7a487672b5db8d3c2f1f89bd1b0c1 GIT binary patch literal 7117 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 Wz3yrsu>JD~n}&)GyiDoYi~j-~OfK{Q literal 0 HcmV?d00001 diff --git a/src/assets/logo/pytorch.png b/src/assets/logo/pytorch.png new file mode 100644 index 0000000000000000000000000000000000000000..d0bb1678ef91e0964bffc0363aef23969a23bf07 GIT binary patch literal 26590 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�j1fdO7+W#uh+g&P?e6)!C>fAA8{A;_Iu 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 literal 0 HcmV?d00001 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' }),