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