feat(gpu-service): add GPU Instance Types management page
- Card/table list of instance types per Kubernetes cluster with delete - Create drawer with flavor picker and per-GPU resource form (CPU/RAM/storage) - Fix unit CPU to 1 for non-acceleratable flavors; N/A memory for generic types - Add routes and i18n across all locales
This commit is contained in:
@@ -211,6 +211,16 @@ const baseRoutes = [
|
|||||||
defaultIcon: 'icon-cloud-outlined',
|
defaultIcon: 'icon-cloud-outlined',
|
||||||
component: './gpu-service/instances'
|
component: './gpu-service/instances'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'instanceTypes',
|
||||||
|
path: '/gpu-service/instance-types',
|
||||||
|
key: 'gpuServiceInstanceTypes',
|
||||||
|
icon: 'icon-outline-gpu',
|
||||||
|
access: 'canSeeOrgAdmin',
|
||||||
|
selectedIcon: 'icon-filled-gpu',
|
||||||
|
defaultIcon: 'icon-outline-gpu',
|
||||||
|
component: './gpu-service/instance-types'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'templates',
|
name: 'templates',
|
||||||
path: '/gpu-service/templates',
|
path: '/gpu-service/templates',
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ export default {
|
|||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
'gpuservice.instance.arch': 'Arch',
|
'gpuservice.instance.arch': 'Arch',
|
||||||
|
'gpuservice.instanceType': 'GPU Instance Type',
|
||||||
|
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||||
|
'gpuservice.instanceType.flavor': 'Flavor',
|
||||||
|
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||||
|
'gpuservice.instanceType.product': 'Product',
|
||||||
|
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||||
|
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||||
|
'gpuservice.instanceType.unitRam': 'Unit RAM',
|
||||||
|
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
|
||||||
|
'gpuservice.instanceType.localStorage': 'Storage',
|
||||||
|
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
|
||||||
|
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
|
||||||
|
'gpuservice.instanceType.filter.name': 'Search by name',
|
||||||
'gpuservice.instance.disk': 'Disk',
|
'gpuservice.instance.disk': 'Disk',
|
||||||
'gpuservice.table.count': 'Count',
|
'gpuservice.table.count': 'Count',
|
||||||
'gpuservice.instance.disk.system': 'System Disk',
|
'gpuservice.instance.disk.system': 'System Disk',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
'menu.models.backendsList': 'Inference Backends',
|
'menu.models.backendsList': 'Inference Backends',
|
||||||
'menu.gpuService': 'GPU Service',
|
'menu.gpuService': 'GPU Service',
|
||||||
'menu.gpuService.instances': 'GPU Instances',
|
'menu.gpuService.instances': 'GPU Instances',
|
||||||
|
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||||
'menu.gpuService.templates': 'Instance Templates',
|
'menu.gpuService.templates': 'Instance Templates',
|
||||||
'menu.gpuService.storage': 'Storage',
|
'menu.gpuService.storage': 'Storage',
|
||||||
'menu.gpuService.storageTypes': 'Storage Types',
|
'menu.gpuService.storageTypes': 'Storage Types',
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export default {
|
|||||||
'noresult.gpuservice.storage.title': 'No Storage',
|
'noresult.gpuservice.storage.title': 'No Storage',
|
||||||
'noresult.gpuservice.storage.subTitle': 'No storage has been added yet.',
|
'noresult.gpuservice.storage.subTitle': 'No storage has been added yet.',
|
||||||
'noresult.gpuservice.storage.nofound': 'No matching storage found.',
|
'noresult.gpuservice.storage.nofound': 'No matching storage found.',
|
||||||
|
'noresult.gpuservice.instanceType.title': 'No Instance Types',
|
||||||
|
'noresult.gpuservice.instanceType.subTitle':
|
||||||
|
'Create an instance type to get started',
|
||||||
|
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
|
||||||
'noresult.gpuservice.storageType.title': 'No Storage Types',
|
'noresult.gpuservice.storageType.title': 'No Storage Types',
|
||||||
'noresult.gpuservice.storageType.subTitle':
|
'noresult.gpuservice.storageType.subTitle':
|
||||||
'No storage types have been added yet.',
|
'No storage types have been added yet.',
|
||||||
|
|||||||
@@ -133,6 +133,19 @@ export default {
|
|||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
'gpuservice.instance.arch': 'アーキテクチャ',
|
'gpuservice.instance.arch': 'アーキテクチャ',
|
||||||
|
'gpuservice.instanceType': 'GPU Instance Type',
|
||||||
|
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||||
|
'gpuservice.instanceType.flavor': 'Flavor',
|
||||||
|
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||||
|
'gpuservice.instanceType.product': 'Product',
|
||||||
|
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||||
|
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||||
|
'gpuservice.instanceType.unitRam': 'Unit RAM',
|
||||||
|
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
|
||||||
|
'gpuservice.instanceType.localStorage': 'Storage',
|
||||||
|
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
|
||||||
|
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
|
||||||
|
'gpuservice.instanceType.filter.name': 'Search by name',
|
||||||
'gpuservice.instance.disk': 'ディスク',
|
'gpuservice.instance.disk': 'ディスク',
|
||||||
'gpuservice.table.count': '数量',
|
'gpuservice.table.count': '数量',
|
||||||
'gpuservice.instance.disk.system': 'システムディスク',
|
'gpuservice.instance.disk.system': 'システムディスク',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
'menu.models.backendsList': 'Inference Backends',
|
'menu.models.backendsList': 'Inference Backends',
|
||||||
'menu.gpuService': 'GPU Service',
|
'menu.gpuService': 'GPU Service',
|
||||||
'menu.gpuService.instances': 'GPU Instances',
|
'menu.gpuService.instances': 'GPU Instances',
|
||||||
|
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||||
'menu.gpuService.templates': 'Instance Templates',
|
'menu.gpuService.templates': 'Instance Templates',
|
||||||
'menu.gpuService.storage': 'Storage',
|
'menu.gpuService.storage': 'Storage',
|
||||||
'menu.gpuService.storageTypes': 'ストレージタイプ',
|
'menu.gpuService.storageTypes': 'ストレージタイプ',
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export default {
|
|||||||
'noresult.gpuservice.storage.subTitle':
|
'noresult.gpuservice.storage.subTitle':
|
||||||
'ストレージはまだ追加されていません。',
|
'ストレージはまだ追加されていません。',
|
||||||
'noresult.gpuservice.storage.nofound': '一致するストレージが見つかりません。',
|
'noresult.gpuservice.storage.nofound': '一致するストレージが見つかりません。',
|
||||||
|
'noresult.gpuservice.instanceType.title': 'No Instance Types',
|
||||||
|
'noresult.gpuservice.instanceType.subTitle':
|
||||||
|
'Create an instance type to get started',
|
||||||
|
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
|
||||||
'noresult.gpuservice.storageType.title': 'ストレージタイプなし',
|
'noresult.gpuservice.storageType.title': 'ストレージタイプなし',
|
||||||
'noresult.gpuservice.storageType.subTitle':
|
'noresult.gpuservice.storageType.subTitle':
|
||||||
'ストレージタイプはまだ追加されていません。',
|
'ストレージタイプはまだ追加されていません。',
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ export default {
|
|||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'ОС',
|
'gpuservice.instance.os': 'ОС',
|
||||||
'gpuservice.instance.arch': 'Архитектура',
|
'gpuservice.instance.arch': 'Архитектура',
|
||||||
|
'gpuservice.instanceType': 'GPU Instance Type',
|
||||||
|
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||||
|
'gpuservice.instanceType.flavor': 'Flavor',
|
||||||
|
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||||
|
'gpuservice.instanceType.product': 'Product',
|
||||||
|
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||||
|
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||||
|
'gpuservice.instanceType.unitRam': 'Unit RAM',
|
||||||
|
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
|
||||||
|
'gpuservice.instanceType.localStorage': 'Storage',
|
||||||
|
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
|
||||||
|
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
|
||||||
|
'gpuservice.instanceType.filter.name': 'Search by name',
|
||||||
'gpuservice.instance.disk': 'Диск',
|
'gpuservice.instance.disk': 'Диск',
|
||||||
'gpuservice.table.count': 'Количество',
|
'gpuservice.table.count': 'Количество',
|
||||||
'gpuservice.instance.disk.system': 'Системный диск',
|
'gpuservice.instance.disk.system': 'Системный диск',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
'menu.settings': 'Settings',
|
'menu.settings': 'Settings',
|
||||||
'menu.gpuService': 'GPU Service',
|
'menu.gpuService': 'GPU Service',
|
||||||
'menu.gpuService.instances': 'GPU Instances',
|
'menu.gpuService.instances': 'GPU Instances',
|
||||||
|
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||||
'menu.gpuService.templates': 'Instance Templates',
|
'menu.gpuService.templates': 'Instance Templates',
|
||||||
'menu.gpuService.storage': 'Storage',
|
'menu.gpuService.storage': 'Storage',
|
||||||
'menu.gpuService.storageTypes': 'Типы хранилищ',
|
'menu.gpuService.storageTypes': 'Типы хранилищ',
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export default {
|
|||||||
'noresult.gpuservice.storage.title': 'Нет хранилищ',
|
'noresult.gpuservice.storage.title': 'Нет хранилищ',
|
||||||
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
|
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
|
||||||
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
|
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
|
||||||
|
'noresult.gpuservice.instanceType.title': 'No Instance Types',
|
||||||
|
'noresult.gpuservice.instanceType.subTitle':
|
||||||
|
'Create an instance type to get started',
|
||||||
|
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
|
||||||
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
|
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
|
||||||
'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.',
|
'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.',
|
||||||
'noresult.gpuservice.storageType.nofound':
|
'noresult.gpuservice.storageType.nofound':
|
||||||
|
|||||||
@@ -128,6 +128,19 @@ export default {
|
|||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
'gpuservice.instance.arch': 'Mimari',
|
'gpuservice.instance.arch': 'Mimari',
|
||||||
|
'gpuservice.instanceType': 'GPU Instance Type',
|
||||||
|
'gpuservice.instanceType.add': 'Add Instance Type',
|
||||||
|
'gpuservice.instanceType.flavor': 'Flavor',
|
||||||
|
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
|
||||||
|
'gpuservice.instanceType.product': 'Product',
|
||||||
|
'gpuservice.instanceType.unitCpu': 'Unit CPU',
|
||||||
|
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
|
||||||
|
'gpuservice.instanceType.unitRam': 'Unit RAM',
|
||||||
|
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
|
||||||
|
'gpuservice.instanceType.localStorage': 'Storage',
|
||||||
|
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
|
||||||
|
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
|
||||||
|
'gpuservice.instanceType.filter.name': 'Search by name',
|
||||||
'gpuservice.instance.disk': 'Disk',
|
'gpuservice.instance.disk': 'Disk',
|
||||||
'gpuservice.table.count': 'Sayı',
|
'gpuservice.table.count': 'Sayı',
|
||||||
'gpuservice.instance.disk.system': 'Sistem Diski',
|
'gpuservice.instance.disk.system': 'Sistem Diski',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
'menu.settings': 'Settings',
|
'menu.settings': 'Settings',
|
||||||
'menu.gpuService': 'GPU Service',
|
'menu.gpuService': 'GPU Service',
|
||||||
'menu.gpuService.instances': 'GPU Instances',
|
'menu.gpuService.instances': 'GPU Instances',
|
||||||
|
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||||
'menu.gpuService.templates': 'Instance Templates',
|
'menu.gpuService.templates': 'Instance Templates',
|
||||||
'menu.gpuService.storage': 'Storage',
|
'menu.gpuService.storage': 'Storage',
|
||||||
'menu.gpuService.storageTypes': 'Depolama Türleri',
|
'menu.gpuService.storageTypes': 'Depolama Türleri',
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ export default {
|
|||||||
'noresult.gpuservice.storage.title': 'Depolama Yok',
|
'noresult.gpuservice.storage.title': 'Depolama Yok',
|
||||||
'noresult.gpuservice.storage.subTitle': 'Henüz depolama eklenmedi.',
|
'noresult.gpuservice.storage.subTitle': 'Henüz depolama eklenmedi.',
|
||||||
'noresult.gpuservice.storage.nofound': 'Eşleşen depolama bulunamadı.',
|
'noresult.gpuservice.storage.nofound': 'Eşleşen depolama bulunamadı.',
|
||||||
|
'noresult.gpuservice.instanceType.title': 'No Instance Types',
|
||||||
|
'noresult.gpuservice.instanceType.subTitle':
|
||||||
|
'Create an instance type to get started',
|
||||||
|
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
|
||||||
'noresult.gpuservice.storageType.title': 'Depolama Türü Yok',
|
'noresult.gpuservice.storageType.title': 'Depolama Türü Yok',
|
||||||
'noresult.gpuservice.storageType.subTitle': 'Henüz depolama türü eklenmedi.',
|
'noresult.gpuservice.storageType.subTitle': 'Henüz depolama türü eklenmedi.',
|
||||||
'noresult.gpuservice.storageType.nofound':
|
'noresult.gpuservice.storageType.nofound':
|
||||||
|
|||||||
@@ -121,6 +121,19 @@ export default {
|
|||||||
'gpuservice.instance.ram': '内存',
|
'gpuservice.instance.ram': '内存',
|
||||||
'gpuservice.instance.os': '系统',
|
'gpuservice.instance.os': '系统',
|
||||||
'gpuservice.instance.arch': '架构',
|
'gpuservice.instance.arch': '架构',
|
||||||
|
'gpuservice.instanceType': 'GPU 实例类型',
|
||||||
|
'gpuservice.instanceType.add': '添加实例类型',
|
||||||
|
'gpuservice.instanceType.flavor': '规格',
|
||||||
|
'gpuservice.instanceType.flavor.required': '请选择实例类型规格',
|
||||||
|
'gpuservice.instanceType.product': '商品',
|
||||||
|
'gpuservice.instanceType.unitCpu': '单位 CPU',
|
||||||
|
'gpuservice.instanceType.unitCpu.tip': '每 GPU 对应多少 CPU',
|
||||||
|
'gpuservice.instanceType.unitRam': '单位内存',
|
||||||
|
'gpuservice.instanceType.unitRam.tip': '每 GPU 对应多少内存',
|
||||||
|
'gpuservice.instanceType.localStorage': '存储',
|
||||||
|
'gpuservice.instanceType.localStorage.tip': '最大可用磁盘',
|
||||||
|
'gpuservice.instanceType.notSliceable': '不可切分',
|
||||||
|
'gpuservice.instanceType.filter.name': '按名称搜索',
|
||||||
'gpuservice.instance.disk': '磁盘',
|
'gpuservice.instance.disk': '磁盘',
|
||||||
'gpuservice.table.count': '数量',
|
'gpuservice.table.count': '数量',
|
||||||
'gpuservice.instance.disk.system': '系统盘',
|
'gpuservice.instance.disk.system': '系统盘',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
'menu.models.backendsList': '推理后端',
|
'menu.models.backendsList': '推理后端',
|
||||||
'menu.gpuService': 'GPU 服务',
|
'menu.gpuService': 'GPU 服务',
|
||||||
'menu.gpuService.instances': 'GPU 实例',
|
'menu.gpuService.instances': 'GPU 实例',
|
||||||
|
'menu.gpuService.instanceTypes': '实例类型',
|
||||||
'menu.gpuService.templates': '实例模板',
|
'menu.gpuService.templates': '实例模板',
|
||||||
'menu.gpuService.storage': '存储',
|
'menu.gpuService.storage': '存储',
|
||||||
'menu.gpuService.storageTypes': '存储类型',
|
'menu.gpuService.storageTypes': '存储类型',
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export default {
|
|||||||
'noresult.gpuservice.storage.title': '暂无存储',
|
'noresult.gpuservice.storage.title': '暂无存储',
|
||||||
'noresult.gpuservice.storage.subTitle': '尚未添加任何存储。',
|
'noresult.gpuservice.storage.subTitle': '尚未添加任何存储。',
|
||||||
'noresult.gpuservice.storage.nofound': '未找到匹配的存储',
|
'noresult.gpuservice.storage.nofound': '未找到匹配的存储',
|
||||||
|
'noresult.gpuservice.instanceType.title': '暂无实例类型',
|
||||||
|
'noresult.gpuservice.instanceType.subTitle': '创建一个实例类型以开始使用',
|
||||||
|
'noresult.gpuservice.instanceType.nofound': '未找到实例类型',
|
||||||
'noresult.gpuservice.storageType.title': '暂无存储类型',
|
'noresult.gpuservice.storageType.title': '暂无存储类型',
|
||||||
'noresult.gpuservice.storageType.subTitle': '尚未添加任何存储类型。',
|
'noresult.gpuservice.storageType.subTitle': '尚未添加任何存储类型。',
|
||||||
'noresult.gpuservice.storageType.nofound': '未找到匹配的存储类型',
|
'noresult.gpuservice.storageType.nofound': '未找到匹配的存储类型',
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { request } from '@umijs/max';
|
||||||
|
import { FlavorItem, FormData, ListItem } from '../config/types';
|
||||||
|
|
||||||
|
export const GPU_INSTANCE_TYPES_API = '/gpu-instance-types';
|
||||||
|
|
||||||
|
export const GPU_INSTANCE_TYPE_FLAVORS_API = '/gpu-instance-type-flavors';
|
||||||
|
|
||||||
|
// GET /gpu-instance-types?cluster_id — instance types defined on a cluster.
|
||||||
|
export async function queryGPUInstanceTypes(
|
||||||
|
params: { cluster_id: number },
|
||||||
|
options?: any
|
||||||
|
) {
|
||||||
|
return request<{ items: ListItem[] }>(GPU_INSTANCE_TYPES_API, {
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
cancelToken: options?.token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /gpu-instance-type-flavors?cluster_id — the hardware flavors a new
|
||||||
|
// instance type can be based on.
|
||||||
|
export async function queryGPUInstanceTypeFlavors(
|
||||||
|
params: { cluster_id: number },
|
||||||
|
options?: any
|
||||||
|
) {
|
||||||
|
return request<{ items: FlavorItem[] }>(GPU_INSTANCE_TYPE_FLAVORS_API, {
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
cancelToken: options?.token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /gpu-instance-types?cluster_id (GPUInstanceTypeCreate).
|
||||||
|
export async function createGPUInstanceType(params: {
|
||||||
|
cluster_id: number;
|
||||||
|
data: FormData;
|
||||||
|
}) {
|
||||||
|
return request<ListItem>(GPU_INSTANCE_TYPES_API, {
|
||||||
|
method: 'POST',
|
||||||
|
params: { cluster_id: params.cluster_id },
|
||||||
|
data: params.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /gpu-instance-types/{name}?cluster_id.
|
||||||
|
export async function deleteGPUInstanceType(params: {
|
||||||
|
name: string;
|
||||||
|
cluster_id: number;
|
||||||
|
}) {
|
||||||
|
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
params: { cluster_id: params.cluster_id }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||||
|
import Separator from '@/pages/llmodels/components/separator';
|
||||||
|
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Typography, message } from 'antd';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FlavorItem, FormData } from '../config/types';
|
||||||
|
import GPUServiceInstanceTypeForm from '../forms';
|
||||||
|
import useQueryFlavors from '../services/use-query-flavors';
|
||||||
|
import styles from '../styles/instance-types.module.less';
|
||||||
|
import FlavorList from './flavor-list';
|
||||||
|
|
||||||
|
type AddInstanceTypeModalProps = {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
clusterId?: number;
|
||||||
|
onOk: (values: FormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColTitle: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}> = ({ children, style }) => (
|
||||||
|
<Typography.Title
|
||||||
|
level={3}
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 16,
|
||||||
|
margin: 0,
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
backgroundColor: 'var(--ant-color-bg-elevated)',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography.Title>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AddInstanceTypeModal: React.FC<AddInstanceTypeModalProps> = ({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
clusterId,
|
||||||
|
onOk,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const form = useRef<any>(null);
|
||||||
|
const { loading, guard, run, release } = useSubmitLock();
|
||||||
|
const [selectedFlavor, setSelectedFlavor] = useState<FlavorItem | null>(null);
|
||||||
|
const {
|
||||||
|
dataList: flavorList,
|
||||||
|
loading: flavorLoading,
|
||||||
|
fetchFlavors
|
||||||
|
} = useQueryFlavors();
|
||||||
|
|
||||||
|
// Fetch flavors when the drawer opens and auto-select the first one, so the
|
||||||
|
// form's flavor-derived fields (group / acceleratable) are always set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedFlavor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clusterId) return;
|
||||||
|
const load = async () => {
|
||||||
|
const list = await fetchFlavors(clusterId);
|
||||||
|
setSelectedFlavor(list?.[0] ?? null);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [open, clusterId]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedFlavor) {
|
||||||
|
message.warning(
|
||||||
|
intl.formatMessage({ id: 'gpuservice.instanceType.flavor.required' })
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guard(() => form.current?.submit());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.current?.resetFields();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async (values: FormData) => {
|
||||||
|
await run(() => onOk({ ...values }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GSDrawer
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onClose={handleCancel}
|
||||||
|
destroyOnHidden
|
||||||
|
closeIcon={false}
|
||||||
|
mask={{ closable: false }}
|
||||||
|
keyboard={false}
|
||||||
|
styles={{
|
||||||
|
wrapper: { width: 'min(900px, calc(100vw - 220px))' },
|
||||||
|
body: { overflowY: 'hidden' }
|
||||||
|
}}
|
||||||
|
footer={false}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.colWrapper}>
|
||||||
|
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
|
||||||
|
<div className={styles.panelBody}>
|
||||||
|
<div className={styles.stickyHead}>
|
||||||
|
<ColTitle
|
||||||
|
style={{
|
||||||
|
paddingBottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
|
||||||
|
</ColTitle>
|
||||||
|
</div>
|
||||||
|
<FlavorList
|
||||||
|
value={selectedFlavor?.name}
|
||||||
|
dataList={flavorList}
|
||||||
|
loading={flavorLoading}
|
||||||
|
onChange={setSelectedFlavor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ColumnWrapper>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formWrapper}>
|
||||||
|
<ColumnWrapper
|
||||||
|
styles={{ container: { paddingBlock: 0 } }}
|
||||||
|
footer={
|
||||||
|
<ModalFooter
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
loading={loading}
|
||||||
|
style={{
|
||||||
|
padding: '16px 24px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ColTitle>
|
||||||
|
{intl.formatMessage({ id: 'common.title.config' })}
|
||||||
|
</ColTitle>
|
||||||
|
<GPUServiceInstanceTypeForm
|
||||||
|
ref={form}
|
||||||
|
open={open}
|
||||||
|
selectedFlavor={selectedFlavor}
|
||||||
|
onFinish={onFinish}
|
||||||
|
onFinishFailed={release}
|
||||||
|
/>
|
||||||
|
</ColumnWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GSDrawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddInstanceTypeModal;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
|
||||||
|
import {
|
||||||
|
AutoTooltip,
|
||||||
|
IconFont,
|
||||||
|
TemplateCard,
|
||||||
|
ThemeTag
|
||||||
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Empty, Flex, Spin, Typography } from 'antd';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { formatMemoryDisplay } from '../../instances/config';
|
||||||
|
import { manufactureColorMap } from '../../templates/config';
|
||||||
|
import { FlavorItem } from '../config/types';
|
||||||
|
import styles from '../styles/instance-types.module.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface FlavorListProps {
|
||||||
|
value?: string;
|
||||||
|
dataList: FlavorItem[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChange?: (item: FlavorItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetaItem: React.FC<{
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
value?: React.ReactNode;
|
||||||
|
}> = ({ icon, label, value }) => {
|
||||||
|
return (
|
||||||
|
<span className={styles.metaLabel}>
|
||||||
|
<IconFont className="icon" type={icon} />
|
||||||
|
{label}: <span className={styles.metaValue}>{value ?? '-'}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlavorList: React.FC<FlavorListProps> = ({
|
||||||
|
value,
|
||||||
|
dataList,
|
||||||
|
loading,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (!dataList.length) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Spin spinning size="middle">
|
||||||
|
<Flex vertical gap={16} style={{ minHeight: 200 }}>
|
||||||
|
{_.times(6, (index: number) => (
|
||||||
|
<FileSkeletonRows key={index} counts={2} itemHeight={96} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={16}>
|
||||||
|
{dataList.map((item) => {
|
||||||
|
const spec = item.spec || {};
|
||||||
|
const manufacturer = spec.manufacturer || '';
|
||||||
|
const color = manufactureColorMap[manufacturer] ?? 'purple';
|
||||||
|
// A generic (no product, no/`generic` manufacturer) flavor is shown as
|
||||||
|
// "CPU-only" instead of falling back to the raw flavor name.
|
||||||
|
const isCpuOnly =
|
||||||
|
!spec.acceleratable &&
|
||||||
|
!spec.product &&
|
||||||
|
(!manufacturer || manufacturer.toLowerCase() === 'generic');
|
||||||
|
const title = isCpuOnly ? 'CPU-only' : spec.product || item.name || '-';
|
||||||
|
return (
|
||||||
|
<TemplateCard
|
||||||
|
key={item.name}
|
||||||
|
className={styles.flavorCard}
|
||||||
|
clickable
|
||||||
|
ghost
|
||||||
|
hoverable
|
||||||
|
active={value === item.name}
|
||||||
|
onClick={() => onChange?.(item)}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={12} style={{ width: '100%' }}>
|
||||||
|
<Flex align="center" justify="space-between" gap={8}>
|
||||||
|
<div style={{ minWidth: 0, fontWeight: 500 }}>
|
||||||
|
<AutoTooltip ghost minWidth={20}>
|
||||||
|
<Text>{title}</Text>
|
||||||
|
</AutoTooltip>
|
||||||
|
</div>
|
||||||
|
{manufacturer && (
|
||||||
|
<ThemeTag color={color} style={{ fontWeight: 400 }}>
|
||||||
|
{manufacturer.toUpperCase()}
|
||||||
|
</ThemeTag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{/* Memory / sliceable only apply to accelerator (GPU) flavors;
|
||||||
|
a non-acceleratable (generic) flavor has neither. */}
|
||||||
|
{spec.acceleratable && (
|
||||||
|
<Flex wrap gap={16}>
|
||||||
|
<MetaItem
|
||||||
|
icon="icon-ram-02"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.memory'
|
||||||
|
})}
|
||||||
|
value={formatMemoryDisplay(spec.memory ?? undefined) ?? '-'}
|
||||||
|
/>
|
||||||
|
<MetaItem
|
||||||
|
icon="icon-sliced"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.sliceable'
|
||||||
|
})}
|
||||||
|
value={
|
||||||
|
spec.sliceable
|
||||||
|
? intl.formatMessage({ id: 'common.table.yes' })
|
||||||
|
: intl.formatMessage({ id: 'common.table.no' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</TemplateCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FlavorList;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
AutoTooltip,
|
||||||
|
DropdownActions,
|
||||||
|
IconFont,
|
||||||
|
StatusTag,
|
||||||
|
TemplateCard,
|
||||||
|
ThemeTag
|
||||||
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { formatMemoryDisplay } from '../../instances/config';
|
||||||
|
import { manufactureColorMap } from '../../templates/config';
|
||||||
|
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
|
||||||
|
import {
|
||||||
|
InstanceTypePhaseLabelMap,
|
||||||
|
status as phaseStatusMap,
|
||||||
|
rowActionList
|
||||||
|
} from '../config';
|
||||||
|
import { ListItem } from '../config/types';
|
||||||
|
import styles from '../styles/instance-types.module.less';
|
||||||
|
|
||||||
|
interface InstanceTypeCardProps {
|
||||||
|
data: ListItem;
|
||||||
|
onDelete?: (record: ListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceTypeCard: React.FC<InstanceTypeCardProps> = ({
|
||||||
|
data,
|
||||||
|
onDelete
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const spec = data.spec || {};
|
||||||
|
const unit = spec.unitResources || {};
|
||||||
|
const phase = data.status?.phase || '';
|
||||||
|
const manufacturer = spec.manufacturer || '';
|
||||||
|
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
|
||||||
|
|
||||||
|
const memoryText = formatMemoryDisplay(spec.memory ?? undefined);
|
||||||
|
|
||||||
|
// Base resources, formatted into a single "·"-separated line. Falsy parts
|
||||||
|
// (e.g. a CPU-only type without VRAM) drop out rather than showing "-".
|
||||||
|
const cpuCores = ceilMilliToCore(unit.cpu ?? null)?.cores;
|
||||||
|
const ramGi = parseQuantityToGi(unit.ram ?? null)?.value;
|
||||||
|
const storageGi = parseQuantityToGi(spec.localStorage ?? null)?.value;
|
||||||
|
const osLabel = _.capitalize(spec.os || '');
|
||||||
|
const archLabel = _.toUpper(spec.arch || '');
|
||||||
|
const storageWord = intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.localStorage'
|
||||||
|
});
|
||||||
|
const footerParts = [
|
||||||
|
cpuCores != null ? `${cpuCores} vCPU` : null,
|
||||||
|
ramGi != null ? `${ramGi} GiB RAM` : null,
|
||||||
|
storageGi != null ? `${storageGi} GiB ${storageWord}` : null,
|
||||||
|
osLabel ? `${osLabel}${archLabel ? ` (${archLabel})` : ''}` : null
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const handleAction = (item: any) => {
|
||||||
|
if (item.key === 'delete') {
|
||||||
|
onDelete?.(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateCard
|
||||||
|
className={styles.listCard}
|
||||||
|
clickable={false}
|
||||||
|
hoverable
|
||||||
|
ghost
|
||||||
|
header={
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.product}>
|
||||||
|
<AutoTooltip ghost minWidth={20}>
|
||||||
|
{spec.product || data.name || '-'}
|
||||||
|
</AutoTooltip>
|
||||||
|
</span>
|
||||||
|
<span className={styles.headerRight}>
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownActions
|
||||||
|
menu={{ items: rowActionList, onClick: handleAction }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<IconFont type="icon-more" />}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</DropdownActions>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.hero}>
|
||||||
|
<span className={styles.name}>
|
||||||
|
<AutoTooltip ghost minWidth={20}>
|
||||||
|
{data.name || '-'}
|
||||||
|
</AutoTooltip>
|
||||||
|
</span>
|
||||||
|
<span className={styles.memory}>{memoryText || '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.subline}>
|
||||||
|
{manufacturer && (
|
||||||
|
<ThemeTag color={manufacturerColor} style={{ fontWeight: 400 }}>
|
||||||
|
{manufacturer.toUpperCase()}
|
||||||
|
</ThemeTag>
|
||||||
|
)}
|
||||||
|
{phase ? (
|
||||||
|
<StatusTag
|
||||||
|
statusValue={{
|
||||||
|
status: phaseStatusMap[phase],
|
||||||
|
text: InstanceTypePhaseLabelMap[phase] || phase,
|
||||||
|
message: data.status?.phaseMessage || ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{spec.clockSpeed ? <span>{spec.clockSpeed}</span> : null}
|
||||||
|
<span
|
||||||
|
className={`${styles.tag} ${
|
||||||
|
spec.sliceable ? styles.tagSliceable : styles.tagPlain
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{spec.sliceable
|
||||||
|
? intl.formatMessage({ id: 'gpuservice.instance.sliceable' })
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.notSliceable'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{footerParts.map((part, index) => (
|
||||||
|
<span key={part}>
|
||||||
|
{index > 0 && <span className={styles.dotSep}>·</span>}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TemplateCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InstanceTypeCard;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ResizeContainer } from '@gpustack/core-ui';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import { ListItem } from '../config/types';
|
||||||
|
import InstanceTypeCard from './instance-type-card';
|
||||||
|
|
||||||
|
interface InstanceTypeListProps {
|
||||||
|
dataList: ListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
onDelete?: (record: ListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
|
||||||
|
dataList,
|
||||||
|
loading,
|
||||||
|
onDelete
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading} size="middle">
|
||||||
|
<ResizeContainer
|
||||||
|
defaultSpan={8}
|
||||||
|
resizable
|
||||||
|
dataList={dataList}
|
||||||
|
renderItem={(item: ListItem) => (
|
||||||
|
<InstanceTypeCard data={item} onDelete={onDelete} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InstanceTypeList;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { StatusMaps } from '@/config';
|
||||||
|
import { StatusType } from '@/config/types';
|
||||||
|
import { icons } from '@gpustack/core-ui';
|
||||||
|
|
||||||
|
// os is fixed to lowercase "linux" on the wire; the form only ever shows Linux.
|
||||||
|
export const GPU_INSTANCE_TYPE_OS = 'linux';
|
||||||
|
|
||||||
|
export const InstanceTypePhaseValueMap = {
|
||||||
|
Active: 'Active',
|
||||||
|
Inactive: 'Inactive',
|
||||||
|
Preparing: 'Preparing'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InstanceTypePhaseLabelMap: Record<string, string> = {
|
||||||
|
[InstanceTypePhaseValueMap.Active]: 'Active',
|
||||||
|
[InstanceTypePhaseValueMap.Inactive]: 'Inactive',
|
||||||
|
[InstanceTypePhaseValueMap.Preparing]: 'Preparing'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const status: Record<string, StatusType> = {
|
||||||
|
[InstanceTypePhaseValueMap.Active]: StatusMaps.success,
|
||||||
|
[InstanceTypePhaseValueMap.Inactive]: StatusMaps.inactive,
|
||||||
|
[InstanceTypePhaseValueMap.Preparing]: StatusMaps.transitioning
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArchOptions = [
|
||||||
|
{ label: 'amd64', value: 'amd64' },
|
||||||
|
{ label: 'arm64', value: 'arm64' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ``icon`` is narrowed to ``any`` so the inferred type doesn't reach into
|
||||||
|
// the antd icon component's internal path.
|
||||||
|
export const rowActionList: Array<{
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
locale: boolean;
|
||||||
|
icon: any;
|
||||||
|
danger?: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: 'common.button.delete',
|
||||||
|
key: 'delete',
|
||||||
|
locale: true,
|
||||||
|
icon: icons.DeleteOutlined,
|
||||||
|
danger: true
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface UnitResources {
|
||||||
|
cpu?: string | null;
|
||||||
|
ram?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceTypeSpec {
|
||||||
|
manufacturer?: string | null;
|
||||||
|
product?: string | null;
|
||||||
|
family?: string | null;
|
||||||
|
memory?: string | null;
|
||||||
|
cores?: string | null;
|
||||||
|
clockSpeed?: string | null;
|
||||||
|
sliceable?: boolean;
|
||||||
|
os?: string | null;
|
||||||
|
arch?: string | null;
|
||||||
|
acceleratable?: boolean;
|
||||||
|
acceleratorGroup?: string | null;
|
||||||
|
generalGroup?: string | null;
|
||||||
|
unitResources?: UnitResources | null;
|
||||||
|
localStorage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceTypeStatus {
|
||||||
|
phase?: string | null;
|
||||||
|
phaseMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row shape for the management list (GET /gpu-instance-types).
|
||||||
|
export interface ListItem {
|
||||||
|
name: string;
|
||||||
|
spec: InstanceTypeSpec;
|
||||||
|
status?: InstanceTypeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selectable flavor shown in the create drawer's first column
|
||||||
|
// (GET /gpu-instance-type-flavors). Its acceleratorGroup / generalGroup /
|
||||||
|
// acceleratable are copied into the created instance type.
|
||||||
|
export interface FlavorItem {
|
||||||
|
name: string;
|
||||||
|
spec: {
|
||||||
|
manufacturer?: string | null;
|
||||||
|
product?: string | null;
|
||||||
|
family?: string | null;
|
||||||
|
memory?: string | null;
|
||||||
|
cores?: string | null;
|
||||||
|
sliceable?: boolean;
|
||||||
|
acceleratable?: boolean;
|
||||||
|
acceleratorGroup?: string | null;
|
||||||
|
generalGroup?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body for POST /gpu-instance-types (GPUInstanceTypeCreate).
|
||||||
|
export interface FormData {
|
||||||
|
name: string;
|
||||||
|
spec: {
|
||||||
|
acceleratorGroup?: string | null;
|
||||||
|
generalGroup?: string | null;
|
||||||
|
acceleratable?: boolean;
|
||||||
|
os: string;
|
||||||
|
arch?: string | null;
|
||||||
|
unitResources?: UnitResources;
|
||||||
|
localStorage?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { validateLabelNameRegxFor63 } from '@/config';
|
||||||
|
import {
|
||||||
|
Input as CInput,
|
||||||
|
InputNumber,
|
||||||
|
Select as SealSelect,
|
||||||
|
useAppUtils
|
||||||
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Form } from 'antd';
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||||
|
import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config';
|
||||||
|
import { FlavorItem, FormData } from '../config/types';
|
||||||
|
|
||||||
|
// RAM / storage are entered as a plain number in GB but stored/submitted as a
|
||||||
|
// "Gi" quantity string. These drive the FormItem's submit (`normalize`) and
|
||||||
|
// display (`getValueProps`) conversions.
|
||||||
|
const giNormalize = (value?: number | string | null) =>
|
||||||
|
value ? `${value}Gi` : undefined;
|
||||||
|
const giValueProps = (value?: string | null) => ({
|
||||||
|
value: value ? String(value).replace(/Gi$/i, '') : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
interface InstanceTypeFormProps {
|
||||||
|
ref?: any;
|
||||||
|
open: boolean;
|
||||||
|
// The flavor picked in the drawer's first column. Its acceleratorGroup /
|
||||||
|
// generalGroup / acceleratable are copied into the created instance type.
|
||||||
|
selectedFlavor?: FlavorItem | null;
|
||||||
|
onFinish: (values: FormData) => Promise<void>;
|
||||||
|
onFinishFailed?: (errorInfo: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
|
||||||
|
(props, ref) => {
|
||||||
|
const { open, selectedFlavor, onFinish, onFinishFailed } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
const { getRuleMessage } = useAppUtils();
|
||||||
|
const [form] = Form.useForm<FormData>();
|
||||||
|
|
||||||
|
// A non-acceleratable (generic) flavor has no per-GPU concept, so unit CPU
|
||||||
|
// is fixed to 1 and the field is disabled.
|
||||||
|
const acceleratable = !!selectedFlavor?.spec?.acceleratable;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.resetFields();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setFieldsValue({
|
||||||
|
spec: {
|
||||||
|
arch: ArchOptions[0].value
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
// Force unit CPU to 1 whenever the picked flavor is not acceleratable.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || acceleratable) return;
|
||||||
|
form.setFieldValue(['spec', 'unitResources', 'cpu'], 1);
|
||||||
|
}, [open, acceleratable, form]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submit: () => {
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
resetFields: () => {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleFinish = async (values: FormData) => {
|
||||||
|
// The hardware group / acceleratable flags are not user-editable; they
|
||||||
|
// come from the chosen flavor. os is fixed to lowercase "linux". ram /
|
||||||
|
// localStorage already carry the "Gi" suffix from the FormItem normalize.
|
||||||
|
const cpu = values.spec?.unitResources?.cpu;
|
||||||
|
await onFinish({
|
||||||
|
name: values.name,
|
||||||
|
spec: {
|
||||||
|
acceleratorGroup: selectedFlavor?.spec?.acceleratorGroup ?? null,
|
||||||
|
generalGroup: selectedFlavor?.spec?.generalGroup ?? null,
|
||||||
|
acceleratable: selectedFlavor?.spec?.acceleratable ?? false,
|
||||||
|
os: GPU_INSTANCE_TYPE_OS,
|
||||||
|
arch: values.spec?.arch ?? null,
|
||||||
|
unitResources: {
|
||||||
|
cpu: cpu != null && cpu !== '' ? String(cpu) : null,
|
||||||
|
ram: values.spec?.unitResources?.ram ?? null
|
||||||
|
},
|
||||||
|
localStorage: values.spec?.localStorage ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
name="gpuServiceInstanceTypeForm"
|
||||||
|
form={form}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
onFinishFailed={onFinishFailed}
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name="name"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: getRuleMessage('input', 'common.table.name')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: validateLabelNameRegxFor63,
|
||||||
|
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CInput.Input
|
||||||
|
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<CInput.Input
|
||||||
|
disabled
|
||||||
|
value="Linux"
|
||||||
|
label={intl.formatMessage({ id: 'gpuservice.instance.os' })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'arch']}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: getRuleMessage('select', 'gpuservice.instance.arch')
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SealSelect
|
||||||
|
label={intl.formatMessage({ id: 'gpuservice.instance.arch' })}
|
||||||
|
required
|
||||||
|
options={ArchOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'unitResources', 'cpu']}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: getRuleMessage(
|
||||||
|
'input',
|
||||||
|
'gpuservice.instanceType.unitCpu'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
disabled={!acceleratable}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitCpu'
|
||||||
|
})}
|
||||||
|
description={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitCpu.tip'
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'unitResources', 'ram']}
|
||||||
|
normalize={giNormalize}
|
||||||
|
getValueProps={giValueProps}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: getRuleMessage(
|
||||||
|
'input',
|
||||||
|
'gpuservice.instanceType.unitRam'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
label={`${intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitRam'
|
||||||
|
})} (GB)`}
|
||||||
|
description={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitRam.tip'
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'localStorage']}
|
||||||
|
normalize={giNormalize}
|
||||||
|
getValueProps={giValueProps}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: getRuleMessage(
|
||||||
|
'input',
|
||||||
|
'gpuservice.instanceType.localStorage'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
label={`${intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.localStorage'
|
||||||
|
})} (GB)`}
|
||||||
|
description={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.localStorage.tip'
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GPUServiceInstanceTypeForm;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const useCreateInstanceTypeModal = () => {
|
||||||
|
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
|
||||||
|
const [openModalStatus, setOpenModalStatus] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
title: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const openModal = (title: string) => {
|
||||||
|
setOpenModalStatus({
|
||||||
|
open: true,
|
||||||
|
title
|
||||||
|
});
|
||||||
|
saveScrollHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setOpenModalStatus({
|
||||||
|
open: false,
|
||||||
|
title: ''
|
||||||
|
});
|
||||||
|
restoreScrollHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
openInstanceTypeModalStatus: openModalStatus,
|
||||||
|
openInstanceTypeModal: openModal,
|
||||||
|
closeInstanceTypeModal: closeModal
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCreateInstanceTypeModal;
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
AutoTooltip,
|
||||||
|
DropdownButtons,
|
||||||
|
icons,
|
||||||
|
StatusTag,
|
||||||
|
ThemeTag
|
||||||
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Space, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/lib/table';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatMemoryDisplay } from '../../instances/config';
|
||||||
|
import { manufactureColorMap } from '../../templates/config';
|
||||||
|
import {
|
||||||
|
ceilMilliToCore,
|
||||||
|
formatManufacturer,
|
||||||
|
parseQuantityToGi
|
||||||
|
} from '../../utils';
|
||||||
|
import { InstanceTypePhaseLabelMap, status as phaseStatusMap } from '../config';
|
||||||
|
import { ListItem } from '../config/types';
|
||||||
|
|
||||||
|
interface ColumnsHookProps {
|
||||||
|
handleSelect: (val: string, record: ListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownButtons reads `locale` / `props` at runtime; its `items` prop is
|
||||||
|
// typed as antd's MenuProps['items'], so cast the config to satisfy it.
|
||||||
|
const rowActions = [
|
||||||
|
{
|
||||||
|
label: 'common.button.delete',
|
||||||
|
key: 'delete',
|
||||||
|
locale: true,
|
||||||
|
icon: icons.DeleteOutlined,
|
||||||
|
props: { danger: true }
|
||||||
|
}
|
||||||
|
] as any;
|
||||||
|
|
||||||
|
// Column header with an info tooltip (used for the per-GPU resource columns).
|
||||||
|
const TitleWithTip: React.FC<{ title: string; tip: string }> = ({
|
||||||
|
title,
|
||||||
|
tip
|
||||||
|
}) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{title}</span>
|
||||||
|
<Tooltip title={tip}>
|
||||||
|
<QuestionCircleOutlined
|
||||||
|
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
const useInstanceTypeColumns = ({
|
||||||
|
handleSelect
|
||||||
|
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (text: string) => (
|
||||||
|
<AutoTooltip ghost maxWidth={240} title={text}>
|
||||||
|
<span className="text-primary">{text || '-'}</span>
|
||||||
|
</AutoTooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'gpuservice.instanceType.product' }),
|
||||||
|
dataIndex: ['spec', 'product'],
|
||||||
|
key: 'product',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (text: string) => (
|
||||||
|
<AutoTooltip ghost maxWidth={200}>
|
||||||
|
{text || '-'}
|
||||||
|
</AutoTooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'resources.table.vendor' }),
|
||||||
|
dataIndex: ['spec', 'manufacturer'],
|
||||||
|
key: 'manufacturer',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (value: string) =>
|
||||||
|
value ? (
|
||||||
|
<ThemeTag
|
||||||
|
color={manufactureColorMap[value] ?? 'purple'}
|
||||||
|
style={{ fontWeight: 400, width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{formatManufacturer(value)}
|
||||||
|
</ThemeTag>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'gpuservice.instance.memory' }),
|
||||||
|
dataIndex: ['spec', 'memory'],
|
||||||
|
key: 'memory',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
// Non-acceleratable (generic) types have no VRAM concept → N/A.
|
||||||
|
render: (value: string, record: ListItem) =>
|
||||||
|
record.spec?.acceleratable
|
||||||
|
? formatMemoryDisplay(value ?? undefined) || '-'
|
||||||
|
: 'N/A'
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: intl.formatMessage({ id: 'gpuservice.instance.sliceable' }),
|
||||||
|
// dataIndex: ['spec', 'sliceable'],
|
||||||
|
// key: 'sliceable',
|
||||||
|
// ellipsis: { showTitle: false },
|
||||||
|
// render: (value: boolean) =>
|
||||||
|
// value
|
||||||
|
// ? intl.formatMessage({ id: 'common.table.yes' })
|
||||||
|
// : intl.formatMessage({ id: 'common.table.no' })
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<TitleWithTip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitCpu'
|
||||||
|
})}
|
||||||
|
tip={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitCpu.tip'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
dataIndex: ['spec', 'unitResources', 'cpu'],
|
||||||
|
key: 'cpu',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (value: string) => {
|
||||||
|
const cores = ceilMilliToCore(value ?? null)?.cores;
|
||||||
|
return cores != null ? `${cores} vCPU` : '-';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<TitleWithTip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitRam'
|
||||||
|
})}
|
||||||
|
tip={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.unitRam.tip'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
dataIndex: ['spec', 'unitResources', 'ram'],
|
||||||
|
key: 'ram',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (value: string) => {
|
||||||
|
const gi = parseQuantityToGi(value ?? null)?.value;
|
||||||
|
return gi != null ? `${gi} GB` : '-';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<TitleWithTip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.localStorage'
|
||||||
|
})}
|
||||||
|
tip={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.localStorage.tip'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
dataIndex: ['spec', 'localStorage'],
|
||||||
|
key: 'localStorage',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (value: string) => {
|
||||||
|
const gi = parseQuantityToGi(value ?? null)?.value;
|
||||||
|
return gi != null ? `${gi} GB` : '-';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'gpuservice.instance.os' }),
|
||||||
|
key: 'os',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (_text, record: ListItem) => {
|
||||||
|
const os = _.capitalize(record.spec?.os || '');
|
||||||
|
const arch = _.toUpper(record.spec?.arch || '');
|
||||||
|
if (!os) return '-';
|
||||||
|
return arch ? `${os} (${arch})` : os;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'common.table.status' }),
|
||||||
|
dataIndex: ['status', 'phase'],
|
||||||
|
key: 'status',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (value: string, record: ListItem) =>
|
||||||
|
value ? (
|
||||||
|
<StatusTag
|
||||||
|
statusValue={{
|
||||||
|
status: phaseStatusMap[value],
|
||||||
|
text: InstanceTypePhaseLabelMap[value] || value,
|
||||||
|
message: record.status?.phaseMessage || ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'common.table.operation' }),
|
||||||
|
key: 'operation',
|
||||||
|
dataIndex: 'operation',
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (_text, record: ListItem) => (
|
||||||
|
<DropdownButtons
|
||||||
|
items={rowActions}
|
||||||
|
onSelect={(val: string) => handleSelect(val, record)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [handleSelect, intl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInstanceTypeColumns;
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
||||||
|
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, Table, message } from 'antd';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import PageBox, { HeaderLeft } from '../../_components/page-box';
|
||||||
|
import { deleteGPUInstanceType } from './apis';
|
||||||
|
import AddInstanceTypeModal from './components/add-instance-type-modal';
|
||||||
|
import { FormData, ListItem } from './config/types';
|
||||||
|
import useCreateInstanceTypeModal from './hooks/use-create-instance-type-modal';
|
||||||
|
import useInstanceTypeColumns from './hooks/use-instance-type-columns';
|
||||||
|
import useCreateInstanceType from './services/use-create-instance-type';
|
||||||
|
import useQueryInstanceTypes from './services/use-query-instance-types';
|
||||||
|
|
||||||
|
const GPUServiceInstanceTypes: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const deleteModalRef = useRef<any>(null);
|
||||||
|
const [clusterId, setClusterId] = useState<number | undefined>();
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
clusterList,
|
||||||
|
fetchClusterList,
|
||||||
|
loading: clusterLoading
|
||||||
|
} = useQueryClusterList();
|
||||||
|
const {
|
||||||
|
dataList,
|
||||||
|
loading: instanceTypesLoading,
|
||||||
|
fetchInstanceTypes
|
||||||
|
} = useQueryInstanceTypes();
|
||||||
|
const { fetchData: createInstanceType } = useCreateInstanceType();
|
||||||
|
const {
|
||||||
|
openInstanceTypeModalStatus,
|
||||||
|
openInstanceTypeModal,
|
||||||
|
closeInstanceTypeModal
|
||||||
|
} = useCreateInstanceTypeModal();
|
||||||
|
|
||||||
|
// Only Kubernetes clusters own GPU instance types.
|
||||||
|
const k8sClusters = useMemo(
|
||||||
|
() => clusterList.filter((c) => c.provider === ProviderValueMap.Kubernetes),
|
||||||
|
[clusterList]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch the visible clusters, default to the first Kubernetes one, then load
|
||||||
|
// its instance types. Action-driven: subsequent loads fire from the cluster
|
||||||
|
// picker / refresh, never from an effect dependency.
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
const items = await fetchClusterList({ page: -1 });
|
||||||
|
const firstK8s = (items || []).find(
|
||||||
|
(c: any) => c.provider === ProviderValueMap.Kubernetes
|
||||||
|
);
|
||||||
|
if (firstK8s?.id != null) {
|
||||||
|
setClusterId(firstK8s.id);
|
||||||
|
await fetchInstanceTypes(firstK8s.id);
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClusterChange = useMemoizedFn((value: number) => {
|
||||||
|
setClusterId(value);
|
||||||
|
setKeyword('');
|
||||||
|
fetchInstanceTypes(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = useMemoizedFn(() => {
|
||||||
|
if (clusterId != null) {
|
||||||
|
fetchInstanceTypes(clusterId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNameChange = useMemoizedFn(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAdd = useMemoizedFn(() => {
|
||||||
|
openInstanceTypeModal(
|
||||||
|
intl.formatMessage({ id: 'gpuservice.instanceType.add' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleModalOk = useMemoizedFn(async (data: FormData) => {
|
||||||
|
if (clusterId == null) return;
|
||||||
|
try {
|
||||||
|
await createInstanceType({ cluster_id: clusterId, data });
|
||||||
|
closeInstanceTypeModal();
|
||||||
|
message.success(intl.formatMessage({ id: 'common.message.success' }));
|
||||||
|
fetchInstanceTypes(clusterId);
|
||||||
|
} catch (error) {
|
||||||
|
// handled by the request interceptor
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useMemoizedFn((record: ListItem) => {
|
||||||
|
if (clusterId == null) return;
|
||||||
|
deleteModalRef.current?.show({
|
||||||
|
content: intl.formatMessage({ id: 'gpuservice.instanceType' }),
|
||||||
|
operation: 'common.delete.single.confirm',
|
||||||
|
name: record.name,
|
||||||
|
async onOk() {
|
||||||
|
await deleteGPUInstanceType({
|
||||||
|
name: record.name,
|
||||||
|
cluster_id: clusterId
|
||||||
|
});
|
||||||
|
fetchInstanceTypes(clusterId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelect = useMemoizedFn((val: string, record: ListItem) => {
|
||||||
|
if (val === 'delete') {
|
||||||
|
handleDelete(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useInstanceTypeColumns({ handleSelect });
|
||||||
|
|
||||||
|
const filteredList = useMemo(() => {
|
||||||
|
const trimmed = keyword.trim().toLowerCase();
|
||||||
|
if (!trimmed) return dataList;
|
||||||
|
return dataList.filter((item) => item.name.toLowerCase().includes(trimmed));
|
||||||
|
}, [dataList, keyword]);
|
||||||
|
|
||||||
|
const hasK8sCluster = k8sClusters.length > 0;
|
||||||
|
|
||||||
|
const renderEmpty = (type?: string) => {
|
||||||
|
if (type !== 'Table') return;
|
||||||
|
return (
|
||||||
|
<NoResult
|
||||||
|
loading={instanceTypesLoading || clusterLoading}
|
||||||
|
loadend={loaded}
|
||||||
|
dataSource={filteredList}
|
||||||
|
image={<IconFont type="icon-gpu1" />}
|
||||||
|
filters={keyword ? { search: keyword } : undefined}
|
||||||
|
noFoundText={intl.formatMessage({
|
||||||
|
id: 'noresult.gpuservice.instanceType.nofound'
|
||||||
|
})}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'noresult.gpuservice.instanceType.title'
|
||||||
|
})}
|
||||||
|
subTitle={
|
||||||
|
hasK8sCluster
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'noresult.gpuservice.instanceType.subTitle'
|
||||||
|
})
|
||||||
|
: intl.formatMessage({ id: 'noresult.resources.k8sCluster' })
|
||||||
|
}
|
||||||
|
{...(hasK8sCluster
|
||||||
|
? {
|
||||||
|
onClick: handleAdd,
|
||||||
|
buttonText: intl.formatMessage({ id: 'noresult.button.add' })
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderLeft>
|
||||||
|
<Flex align="center">
|
||||||
|
<span className="font-600">
|
||||||
|
{intl.formatMessage({ id: 'gpuservice.instance.types' })}
|
||||||
|
</span>
|
||||||
|
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
|
||||||
|
<BaseSelect
|
||||||
|
size="small"
|
||||||
|
variant="borderless"
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
options={k8sClusters}
|
||||||
|
value={clusterId}
|
||||||
|
onChange={handleClusterChange}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</HeaderLeft>
|
||||||
|
<PageBox>
|
||||||
|
<FilterBar
|
||||||
|
marginBottom={22}
|
||||||
|
marginTop={30}
|
||||||
|
showSelect={false}
|
||||||
|
inputHolder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.filter.name'
|
||||||
|
})}
|
||||||
|
buttonText={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instanceType.add'
|
||||||
|
})}
|
||||||
|
handleSearch={handleRefresh}
|
||||||
|
handleClickPrimary={hasK8sCluster ? handleAdd : undefined}
|
||||||
|
handleInputChange={handleNameChange}
|
||||||
|
widths={{ input: 300 }}
|
||||||
|
/>
|
||||||
|
<ConfigProvider renderEmpty={renderEmpty}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredList}
|
||||||
|
loading={{
|
||||||
|
spinning: instanceTypesLoading || clusterLoading,
|
||||||
|
size: 'middle'
|
||||||
|
}}
|
||||||
|
rowKey={(record) => record.name}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</PageBox>
|
||||||
|
<AddInstanceTypeModal
|
||||||
|
open={openInstanceTypeModalStatus.open}
|
||||||
|
title={openInstanceTypeModalStatus.title}
|
||||||
|
clusterId={clusterId}
|
||||||
|
onCancel={closeInstanceTypeModal}
|
||||||
|
onOk={handleModalOk}
|
||||||
|
/>
|
||||||
|
<DeleteModal ref={deleteModalRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GPUServiceInstanceTypes;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useQueryData } from '@gpustack/core-ui';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { createGPUInstanceType } from '../apis';
|
||||||
|
import { FormData, ListItem } from '../config/types';
|
||||||
|
|
||||||
|
interface CreateInstanceTypeParams {
|
||||||
|
cluster_id: number;
|
||||||
|
data: FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useCreateInstanceType() {
|
||||||
|
const fetchDetail = useCallback(
|
||||||
|
(params: CreateInstanceTypeParams) =>
|
||||||
|
createGPUInstanceType({
|
||||||
|
cluster_id: params.cluster_id,
|
||||||
|
data: params.data
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
||||||
|
ListItem,
|
||||||
|
CreateInstanceTypeParams
|
||||||
|
>({
|
||||||
|
fetchDetail,
|
||||||
|
key: 'createInstanceType'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailData,
|
||||||
|
loading,
|
||||||
|
cancelRequest,
|
||||||
|
fetchData
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createAxiosToken } from '@/hooks/use-chunk-request';
|
||||||
|
import { useRequest } from 'ahooks';
|
||||||
|
import { CancelTokenSource } from 'axios';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { queryGPUInstanceTypeFlavors } from '../apis';
|
||||||
|
import { FlavorItem } from '../config/types';
|
||||||
|
|
||||||
|
// Cluster-scoped flavors for the create drawer's first column. Fetched when
|
||||||
|
// the drawer opens (and on cluster change), never via an effect dependency.
|
||||||
|
export default function useQueryFlavors() {
|
||||||
|
const tokenRef = useRef<CancelTokenSource | null>(null);
|
||||||
|
const [dataList, setDataList] = useState<FlavorItem[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
runAsync: fetchFlavors,
|
||||||
|
loading,
|
||||||
|
cancel
|
||||||
|
} = useRequest(
|
||||||
|
async (clusterId: number) => {
|
||||||
|
tokenRef.current?.cancel();
|
||||||
|
tokenRef.current = createAxiosToken();
|
||||||
|
const res = await queryGPUInstanceTypeFlavors(
|
||||||
|
{ cluster_id: clusterId },
|
||||||
|
{ token: tokenRef.current.token }
|
||||||
|
);
|
||||||
|
const list = res?.items || [];
|
||||||
|
setDataList(list);
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
onError: (error: any) => {
|
||||||
|
if (error?.message === 'CANCEL_PREVIOUS_REQUEST') return;
|
||||||
|
setDataList([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelRequest = () => {
|
||||||
|
cancel();
|
||||||
|
tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancel();
|
||||||
|
tokenRef.current?.cancel();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataList,
|
||||||
|
loading,
|
||||||
|
fetchFlavors,
|
||||||
|
cancelRequest,
|
||||||
|
setDataList
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createAxiosToken } from '@/hooks/use-chunk-request';
|
||||||
|
import { useRequest } from 'ahooks';
|
||||||
|
import { CancelTokenSource } from 'axios';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { queryGPUInstanceTypes } from '../apis';
|
||||||
|
import { ListItem } from '../config/types';
|
||||||
|
|
||||||
|
// Cluster-scoped instance types for the management list. Action-driven:
|
||||||
|
// call fetchInstanceTypes(clusterId) from the cluster picker / refresh, not
|
||||||
|
// via an effect dependency.
|
||||||
|
export default function useQueryInstanceTypes() {
|
||||||
|
const tokenRef = useRef<CancelTokenSource | null>(null);
|
||||||
|
const [dataList, setDataList] = useState<ListItem[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
runAsync: fetchInstanceTypes,
|
||||||
|
loading,
|
||||||
|
cancel
|
||||||
|
} = useRequest(
|
||||||
|
async (clusterId: number) => {
|
||||||
|
tokenRef.current?.cancel();
|
||||||
|
tokenRef.current = createAxiosToken();
|
||||||
|
const res = await queryGPUInstanceTypes(
|
||||||
|
{ cluster_id: clusterId },
|
||||||
|
{ token: tokenRef.current.token }
|
||||||
|
);
|
||||||
|
const list = res?.items || [];
|
||||||
|
setDataList(list);
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
onError: (error: any) => {
|
||||||
|
// Ignore the synthetic cancel error from switching clusters quickly.
|
||||||
|
if (error?.message === 'CANCEL_PREVIOUS_REQUEST') return;
|
||||||
|
setDataList([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelRequest = () => {
|
||||||
|
cancel();
|
||||||
|
tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancel();
|
||||||
|
tokenRef.current?.cancel();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataList,
|
||||||
|
loading,
|
||||||
|
fetchInstanceTypes,
|
||||||
|
cancelRequest,
|
||||||
|
setDataList
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// ============ create drawer (two-column: flavors | form) ============
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickyHead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: var(--ant-color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorCard {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ list card (Linear-style, minimal) ============
|
||||||
|
.listCard {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- level 1: identity + status + actions ---
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- level 2: hero (model + memory) ---
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: var(--ant-border-radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSliceable {
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
background-color: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagPlain {
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
background-color: var(--ant-color-fill-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- level 3: base resources ---
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 16px 0;
|
||||||
|
background-color: var(--ant-color-border-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotSep {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: var(--ant-color-text-quaternary);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user