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',
|
||||
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',
|
||||
path: '/gpu-service/templates',
|
||||
|
||||
@@ -134,6 +134,19 @@ export default {
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'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.table.count': 'Count',
|
||||
'gpuservice.instance.disk.system': 'System Disk',
|
||||
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
'menu.models.backendsList': 'Inference Backends',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||
'menu.gpuService.templates': 'Instance Templates',
|
||||
'menu.gpuService.storage': 'Storage',
|
||||
'menu.gpuService.storageTypes': 'Storage Types',
|
||||
|
||||
@@ -68,6 +68,10 @@ export default {
|
||||
'noresult.gpuservice.storage.title': 'No Storage',
|
||||
'noresult.gpuservice.storage.subTitle': 'No storage has been added yet.',
|
||||
'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.subTitle':
|
||||
'No storage types have been added yet.',
|
||||
|
||||
@@ -133,6 +133,19 @@ export default {
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'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.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': 'システムディスク',
|
||||
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
'menu.models.backendsList': 'Inference Backends',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||
'menu.gpuService.templates': 'Instance Templates',
|
||||
'menu.gpuService.storage': 'Storage',
|
||||
'menu.gpuService.storageTypes': 'ストレージタイプ',
|
||||
|
||||
@@ -70,6 +70,10 @@ export default {
|
||||
'noresult.gpuservice.storage.subTitle':
|
||||
'ストレージはまだ追加されていません。',
|
||||
'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.subTitle':
|
||||
'ストレージタイプはまだ追加されていません。',
|
||||
|
||||
@@ -132,6 +132,19 @@ export default {
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'ОС',
|
||||
'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.table.count': 'Количество',
|
||||
'gpuservice.instance.disk.system': 'Системный диск',
|
||||
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
'menu.settings': 'Settings',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||
'menu.gpuService.templates': 'Instance Templates',
|
||||
'menu.gpuService.storage': 'Storage',
|
||||
'menu.gpuService.storageTypes': 'Типы хранилищ',
|
||||
|
||||
@@ -69,6 +69,10 @@ export default {
|
||||
'noresult.gpuservice.storage.title': 'Нет хранилищ',
|
||||
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
|
||||
'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.subTitle': 'Типы хранилищ ещё не добавлены.',
|
||||
'noresult.gpuservice.storageType.nofound':
|
||||
|
||||
@@ -128,6 +128,19 @@ export default {
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'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.table.count': 'Sayı',
|
||||
'gpuservice.instance.disk.system': 'Sistem Diski',
|
||||
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
'menu.settings': 'Settings',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
'menu.gpuService.instanceTypes': 'Instance Types',
|
||||
'menu.gpuService.templates': 'Instance Templates',
|
||||
'menu.gpuService.storage': 'Storage',
|
||||
'menu.gpuService.storageTypes': 'Depolama Türleri',
|
||||
|
||||
@@ -67,6 +67,10 @@ export default {
|
||||
'noresult.gpuservice.storage.title': 'Depolama Yok',
|
||||
'noresult.gpuservice.storage.subTitle': 'Henüz depolama eklenmedi.',
|
||||
'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.subTitle': 'Henüz depolama türü eklenmedi.',
|
||||
'noresult.gpuservice.storageType.nofound':
|
||||
|
||||
@@ -121,6 +121,19 @@ export default {
|
||||
'gpuservice.instance.ram': '内存',
|
||||
'gpuservice.instance.os': '系统',
|
||||
'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.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': '系统盘',
|
||||
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
'menu.models.backendsList': '推理后端',
|
||||
'menu.gpuService': 'GPU 服务',
|
||||
'menu.gpuService.instances': 'GPU 实例',
|
||||
'menu.gpuService.instanceTypes': '实例类型',
|
||||
'menu.gpuService.templates': '实例模板',
|
||||
'menu.gpuService.storage': '存储',
|
||||
'menu.gpuService.storageTypes': '存储类型',
|
||||
|
||||
@@ -63,6 +63,9 @@ export default {
|
||||
'noresult.gpuservice.storage.title': '暂无存储',
|
||||
'noresult.gpuservice.storage.subTitle': '尚未添加任何存储。',
|
||||
'noresult.gpuservice.storage.nofound': '未找到匹配的存储',
|
||||
'noresult.gpuservice.instanceType.title': '暂无实例类型',
|
||||
'noresult.gpuservice.instanceType.subTitle': '创建一个实例类型以开始使用',
|
||||
'noresult.gpuservice.instanceType.nofound': '未找到实例类型',
|
||||
'noresult.gpuservice.storageType.title': '暂无存储类型',
|
||||
'noresult.gpuservice.storageType.subTitle': '尚未添加任何存储类型。',
|
||||
'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