diff --git a/.gitignore b/.gitignore index 150a4847..3fb808ec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ .swc .DS_Store .idea -.claude \ No newline at end of file +.claude diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..5543a06a --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,3 @@ +### Create form table list + +### Create a form diff --git a/config/routes.ts b/config/routes.ts index 4ad99185..9c4fe8d4 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -179,6 +179,47 @@ const baseRoutes = [ } ] }, + { + name: 'gpuService', + path: '/gpu-service', + key: 'gpuService', + routes: [ + { + path: '/gpu-service', + redirect: '/gpu-service/list' + }, + { + name: 'instances', + path: '/gpu-service/list', + key: 'gpuServiceList', + icon: 'icon-instances-outlined', + selectedIcon: 'icon-instances-filled', + defaultIcon: 'icon-instances-outlined', + access: 'canSeeAdmin', + component: './gpu-service/instances' + }, + { + name: 'templates', + path: '/gpu-service/templates', + key: 'gpuServiceTemplates', + icon: 'icon-instance-template-outlined', + selectedIcon: 'icon-instance-template-filled', + defaultIcon: 'icon-instance-template-outlined', + access: 'canSeeAdmin', + component: './gpu-service/templates' + }, + { + name: 'storage', + path: '/gpu-service/storage', + key: 'gpuServiceStorage', + icon: 'icon-storage-outlined', + selectedIcon: 'icon-storage-filled', + defaultIcon: 'icon-storage-outlined', + access: 'canSeeAdmin', + component: './gpu-service/storage' + } + ] + }, { name: 'resources', path: '/resources', diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts index 0605e5fa..f030ebac 100644 --- a/src/locales/en-US/menu.ts +++ b/src/locales/en-US/menu.ts @@ -40,5 +40,9 @@ export default { 'menu.clusterManagement.credentials': 'Cloud Credentials', 'menu.clusterManagement.clusterDetail': 'Cluster Detail', 'menu.clusterManagement.clusterCreate': 'Create Cluster', - 'menu.resources.backendsList': 'Inference Backends' + 'menu.resources.backendsList': 'Inference Backends', + 'menu.gpuService': 'GPU Service', + 'menu.gpuService.instances': 'GPU Instances', + 'menu.gpuService.templates': 'Instance Templates', + 'menu.gpuService.storage': 'Storage Management' }; diff --git a/src/locales/ja-JP/menu.ts b/src/locales/ja-JP/menu.ts index e7d6fe68..6fdc7e5c 100644 --- a/src/locales/ja-JP/menu.ts +++ b/src/locales/ja-JP/menu.ts @@ -40,7 +40,11 @@ export default { 'menu.models.userModels': 'My Models', 'menu.clusterManagement.clusterDetail': 'Cluster Detail', 'menu.clusterManagement.clusterCreate': 'Create Cluster', - 'menu.resources.backendsList': 'Inference Backends' + 'menu.resources.backendsList': 'Inference Backends', + 'menu.gpuService': 'GPU Service', + 'menu.gpuService.instances': 'GPU Instances', + 'menu.gpuService.templates': 'Instance Templates', + 'menu.gpuService.storage': 'Storage Management' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/ru-RU/menu.ts b/src/locales/ru-RU/menu.ts index 708ac869..14716d3b 100644 --- a/src/locales/ru-RU/menu.ts +++ b/src/locales/ru-RU/menu.ts @@ -40,7 +40,11 @@ export default { 'menu.clusterManagement.clusterDetail': 'Детали кластера', 'menu.clusterManagement.clusterCreate': 'Создать кластер', 'menu.resources.backendsList': 'Бэкенды запуска', - 'menu.settings': 'Settings' + 'menu.settings': 'Settings', + 'menu.gpuService': 'GPU Service', + 'menu.gpuService.instances': 'GPU Instances', + 'menu.gpuService.templates': 'Instance Templates', + 'menu.gpuService.storage': 'Storage Management' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/tr-TR/menu.ts b/src/locales/tr-TR/menu.ts index a75540be..206489ed 100644 --- a/src/locales/tr-TR/menu.ts +++ b/src/locales/tr-TR/menu.ts @@ -40,7 +40,11 @@ export default { 'menu.clusterManagement.clusterCreate': 'Küme Oluştur', 'menu.resources.backendsList': 'Çıkarım Altyapıları', 'menu.models.instances': 'Instances', - 'menu.settings': 'Settings' + 'menu.settings': 'Settings', + 'menu.gpuService': 'GPU Service', + 'menu.gpuService.instances': 'GPU Instances', + 'menu.gpuService.templates': 'Instance Templates', + 'menu.gpuService.storage': 'Storage Management' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts index 27ba73de..27d4cbd5 100644 --- a/src/locales/zh-CN/menu.ts +++ b/src/locales/zh-CN/menu.ts @@ -40,5 +40,9 @@ export default { 'menu.clusterManagement.credentials': '云凭证', 'menu.clusterManagement.clusterDetail': '集群详情', 'menu.clusterManagement.clusterCreate': '创建集群', - 'menu.resources.backendsList': '推理后端' + 'menu.resources.backendsList': '推理后端', + 'menu.gpuService': 'GPU 服务', + 'menu.gpuService.instances': 'GPU 实例', + 'menu.gpuService.templates': '实例模板', + 'menu.gpuService.storage': '存储管理' }; diff --git a/src/pages/gpu-service/instances/apis/index.ts b/src/pages/gpu-service/instances/apis/index.ts new file mode 100644 index 00000000..d7804daa --- /dev/null +++ b/src/pages/gpu-service/instances/apis/index.ts @@ -0,0 +1,38 @@ +import { request } from '@umijs/max'; +import { FormData, ListItem } from '../config/types'; + +export const GPU_SERVICE_INSTANCES_API = '/gpu-service-instances'; + +export async function queryGPUServiceInstances( + params: Global.SearchParams, + options?: any +) { + return request>(GPU_SERVICE_INSTANCES_API, { + method: 'GET', + params, + cancelToken: options?.token + }); +} + +export async function createGPUServiceInstance(params: { data: FormData }) { + return request(GPU_SERVICE_INSTANCES_API, { + method: 'POST', + data: params.data + }); +} + +export async function updateGPUServiceInstance(params: { + id: number; + data: FormData; +}) { + return request(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, { + method: 'PUT', + data: params.data + }); +} + +export async function deleteGPUServiceInstance(id: number) { + return request(`${GPU_SERVICE_INSTANCES_API}/${id}`, { + method: 'DELETE' + }); +} diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx new file mode 100644 index 00000000..38530f5e --- /dev/null +++ b/src/pages/gpu-service/instances/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 GPUServiceInstanceForm 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/instances/config/index.ts b/src/pages/gpu-service/instances/config/index.ts new file mode 100644 index 00000000..3b02537a --- /dev/null +++ b/src/pages/gpu-service/instances/config/index.ts @@ -0,0 +1,31 @@ +import { icons } from '@gpustack/core-ui'; + +export const InstanceStatusValueMap = { + Running: 'running', + Pending: 'pending', + Stopped: 'stopped', + Failed: 'failed' +}; + +export const InstanceStatusLabelMap: Record = { + [InstanceStatusValueMap.Running]: '运行中', + [InstanceStatusValueMap.Pending]: '等待中', + [InstanceStatusValueMap.Stopped]: '已停止', + [InstanceStatusValueMap.Failed]: '失败' +}; + +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/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts new file mode 100644 index 00000000..9c75f59d --- /dev/null +++ b/src/pages/gpu-service/instances/config/types.ts @@ -0,0 +1,15 @@ +export interface FormData { + name: string; + description?: string; + image?: string; + gpu_count?: number; + replicas?: number; +} + +export interface ListItem extends FormData { + id: number; + status?: string; + endpoint?: string; + created_at?: string; + updated_at?: string; +} diff --git a/src/pages/gpu-service/instances/forms/basic.tsx b/src/pages/gpu-service/instances/forms/basic.tsx new file mode 100644 index 00000000..adfe7292 --- /dev/null +++ b/src/pages/gpu-service/instances/forms/basic.tsx @@ -0,0 +1,68 @@ +import { + Input as CInput, + InputNumber as CInputNumber +} from '@gpustack/core-ui'; +import { Form } from 'antd'; +import { FormData } from '../config/types'; + +const Basic = () => { + return ( + <> + + name="name" + rules={[ + { + required: true, + message: '请输入实例名称' + } + ]} + > + + + + name="image" + rules={[ + { + required: true, + message: '请输入镜像' + } + ]} + > + + +
+
+ + name="gpu_count" + rules={[ + { + required: true, + message: '请输入 GPU 数量' + } + ]} + > + + +
+
+ + name="replicas" + rules={[ + { + required: true, + message: '请输入副本数' + } + ]} + > + + +
+
+ name="description"> + + + + ); +}; + +export default Basic; diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx new file mode 100644 index 00000000..acd7bf48 --- /dev/null +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -0,0 +1,66 @@ +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; +import { Form } from 'antd'; +import { forwardRef, useEffect, useImperativeHandle } from 'react'; +import { FormData, ListItem } from '../config/types'; +import Basic from './basic'; + +interface InstanceFormProps { + ref?: any; + open: boolean; + action: PageActionType; + currentData?: ListItem | null; + onFinish: (values: FormData) => Promise; +} + +const GPUServiceInstanceForm: 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, + image: currentData.image, + gpu_count: currentData.gpu_count, + replicas: currentData.replicas + }); + return; + } + + form.setFieldsValue({ + gpu_count: 1, + replicas: 1 + }); + }, [action, currentData, form, open]); + + useImperativeHandle(ref, () => ({ + submit: () => { + form.submit(); + }, + resetFields: () => { + form.resetFields(); + } + })); + + return ( +
+ + + ); + } +); + +export default GPUServiceInstanceForm; diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx new file mode 100644 index 00000000..21e4e41b --- /dev/null +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -0,0 +1,132 @@ +import { tableSorter } from '@/config/settings'; +import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui'; +import { Tag } from 'antd'; +import type { ColumnsType } from 'antd/lib/table'; +import dayjs from 'dayjs'; +import { useMemo } from 'react'; +import { + InstanceStatusLabelMap, + InstanceStatusValueMap, + rowActionList +} from '../config'; +import { ListItem } from '../config/types'; + +interface ColumnsHookProps { + handleSelect: (val: string, record: ListItem) => void; + sortOrder: string[]; +} + +const statusColorMap: Record = { + [InstanceStatusValueMap.Running]: 'success', + [InstanceStatusValueMap.Pending]: 'processing', + [InstanceStatusValueMap.Stopped]: 'default', + [InstanceStatusValueMap.Failed]: 'error' +}; + +const useInstancesColumns = ({ + handleSelect, + sortOrder +}: ColumnsHookProps): ColumnsType => { + return useMemo(() => { + return [ + { + title: '实例名称', + dataIndex: 'name', + key: 'name', + sorter: tableSorter(1), + ellipsis: { + showTitle: false + }, + render: (text: string) => ( + + {text} + + ) + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + sorter: tableSorter(2), + render: (status: string) => { + const value = status || InstanceStatusValueMap.Pending; + return ( + + {InstanceStatusLabelMap[value] || value} + + ); + } + }, + { + title: '镜像', + dataIndex: 'image', + key: 'image', + ellipsis: { + showTitle: false + }, + render: (text: string) => ( + + {text || '-'} + + ) + }, + { + title: 'GPU 数量', + dataIndex: 'gpu_count', + key: 'gpu_count', + 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 || '-'} + + ) + }, + { + 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 useInstancesColumns; diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx new file mode 100644 index 00000000..2f17e7c6 --- /dev/null +++ b/src/pages/gpu-service/instances/index.tsx @@ -0,0 +1,186 @@ +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 { useMemoizedFn } from 'ahooks'; +import { ConfigProvider, message, Table } from 'antd'; +import _ from 'lodash'; +import { useState } from 'react'; +import PageBox from '../../_components/page-box'; +import { + createGPUServiceInstance, + deleteGPUServiceInstance, + GPU_SERVICE_INSTANCES_API, + queryGPUServiceInstances, + updateGPUServiceInstance +} from './apis'; +import AddModal from './components/add-modal'; +import { FormData, ListItem } from './config/types'; +import useInstancesColumns from './hooks/use-instances-columns'; + +const GPUService: React.FC = () => { + const { + dataSource, + rowSelection, + queryParams, + sortOrder, + modalRef, + handleDelete, + handleDeleteBatch, + fetchData, + handlePageChange, + handleTableChange, + handleSearch, + handleNameChange + } = useTableFetch({ + key: PaginationKey.Instances, + fetchAPI: queryGPUServiceInstances, + deleteAPI: deleteGPUServiceInstance, + watch: false, + API: GPU_SERVICE_INSTANCES_API, + contentForDelete: 'GPU 实例' + }); + + const [openAddModalStatus, setOpenAddModalStatus] = useState<{ + action: PageActionType; + open: boolean; + title: string; + currentData?: ListItem | null; + }>({ + action: PageAction.CREATE, + title: '', + open: false, + currentData: null + }); + + const handleAddInstance = () => { + setOpenAddModalStatus({ + action: PageAction.CREATE, + title: '创建 GPU 实例', + open: true, + currentData: null + }); + }; + + const handleEditInstance = (row: ListItem) => { + setOpenAddModalStatus({ + action: PageAction.EDIT, + title: '编辑 GPU 实例', + 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 updateGPUServiceInstance({ + id: openAddModalStatus.currentData!.id, + data + }); + } else { + await createGPUServiceInstance({ data }); + } + + fetchData(); + closeModal(); + message.success('操作成功'); + } catch (error) { + message.error('操作失败'); + } + }; + + const handleSelect = useMemoizedFn((val: string, row: ListItem) => { + if (val === 'edit') { + handleEditInstance(row); + } else if (val === 'delete') { + handleDelete({ ...row, name: row.name }); + } + }); + + const renderEmpty = (type?: string) => { + if (type !== 'Table') return; + return ( + } + filters={_.omit(queryParams, ['sort_by'])} + noFoundText="未找到匹配的 GPU 实例" + title="暂无 GPU 实例" + subTitle="创建一个 GPU 实例后会显示在这里" + onClick={handleAddInstance} + buttonText="创建" + /> + ); + }; + + const columns = useInstancesColumns({ + handleSelect, + sortOrder + }); + + return ( + <> + + + + + + + + + + ); +}; + +export default GPUService; diff --git a/src/pages/gpu-service/storage/index.tsx b/src/pages/gpu-service/storage/index.tsx new file mode 100644 index 00000000..871cc19e --- /dev/null +++ b/src/pages/gpu-service/storage/index.tsx @@ -0,0 +1,7 @@ +import PageBox from '../../_components/page-box'; + +const GPUServiceStorage: React.FC = () => { + return GPU Service Storage Page; +}; + +export default GPUServiceStorage; diff --git a/src/pages/gpu-service/templates/index.tsx b/src/pages/gpu-service/templates/index.tsx new file mode 100644 index 00000000..1c5264e4 --- /dev/null +++ b/src/pages/gpu-service/templates/index.tsx @@ -0,0 +1,7 @@ +import PageBox from '../../_components/page-box'; + +const GPUServiceTemplates: React.FC = () => { + return GPU Service Templates Page; +}; + +export default GPUServiceTemplates;