feat(gpu-service): instance-type activate/deactivate, displayName, unified flavor display

- add activate/deactivate row actions (phase-driven) with new PUT APIs
- add displayName field; list shows displayName || name
- rename list OS column title to Platform
- share a two-line flavor renderer between the create dropdown and the list
- normalize 'CPU Only' label to 'CPU-only'
This commit is contained in:
jialin
2026-07-22 15:52:17 +08:00
parent 2763594ae3
commit 3fff1c10f2
17 changed files with 322 additions and 223 deletions
+6 -2
View File
@@ -134,12 +134,16 @@ 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': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type', 'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor', 'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor', 'gpuservice.instanceType.flavor.required':
'Please select an instance type flavor',
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute', 'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute', 'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'gpuservice.instanceType.product': 'Product', 'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU', 'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU', 'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
+6 -2
View File
@@ -133,12 +133,16 @@ 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': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type', 'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor', 'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor', 'gpuservice.instanceType.flavor.required':
'Please select an instance type flavor',
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute', 'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute', 'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'gpuservice.instanceType.product': 'Product', 'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU', 'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU', 'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
+6 -2
View File
@@ -132,12 +132,16 @@ 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': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type', 'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor', 'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor', 'gpuservice.instanceType.flavor.required':
'Please select an instance type flavor',
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute', 'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute', 'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'gpuservice.instanceType.product': 'Product', 'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU', 'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU', 'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
+6 -2
View File
@@ -128,12 +128,16 @@ 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': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type', 'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor', 'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor', 'gpuservice.instanceType.flavor.required':
'Please select an instance type flavor',
'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute', 'gpuservice.instanceType.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute', 'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'gpuservice.instanceType.product': 'Product', 'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU', 'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU', 'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
+4 -1
View File
@@ -121,12 +121,15 @@ 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': 'GPU 实例类型',
'gpuservice.instanceType.add': '添加实例类型', 'gpuservice.instanceType.add': '添加实例类型',
'gpuservice.instanceType.flavor': '规格', 'gpuservice.instanceType.flavor': '规格',
'gpuservice.instanceType.flavor.required': '请选择实例类型规格', 'gpuservice.instanceType.flavor.required': '请选择实例类型规格',
'gpuservice.instanceType.flavor.gpuGroup': 'GPU 算力', 'gpuservice.instanceType.flavor.gpuGroup': 'GPU 算力',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU 算力', 'gpuservice.instanceType.flavor.cpuGroup': 'CPU 算力',
'gpuservice.instanceType.activate': '启用',
'gpuservice.instanceType.deactivate': '停用',
'gpuservice.instanceType.platform': '平台',
'gpuservice.instanceType.product': '商品', 'gpuservice.instanceType.product': '商品',
'gpuservice.instanceType.unitCpu': '单位 CPU', 'gpuservice.instanceType.unitCpu': '单位 CPU',
'gpuservice.instanceType.unitCpu.tip': '每 GPU 对应多少 CPU', 'gpuservice.instanceType.unitCpu.tip': '每 GPU 对应多少 CPU',
@@ -52,3 +52,25 @@ export async function deleteGPUInstanceType(params: {
params: { cluster_id: params.cluster_id } params: { cluster_id: params.cluster_id }
}); });
} }
// PUT /gpu-instance-types/{name}/enactive?cluster_id — activate an instance type.
export async function activateGPUInstanceType(params: {
name: string;
cluster_id: number;
}) {
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}/activate`, {
method: 'PUT',
params: { cluster_id: params.cluster_id }
});
}
// PUT /gpu-instance-types/{name}/deactive?cluster_id — deactivate an instance type.
export async function deactivateGPUInstanceType(params: {
name: string;
cluster_id: number;
}) {
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}/deactivate`, {
method: 'PUT',
params: { cluster_id: params.cluster_id }
});
}
@@ -0,0 +1,128 @@
import { AutoTooltip, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import { formatMemoryDisplay } from '../../instances/config';
import { manufactureColorMap } from '../../templates/config';
import { formatManufacturer } from '../../utils';
// The subset of a flavor / instance-type spec the flavor display reads. Both
// FlavorItem.spec and InstanceTypeSpec structurally satisfy it, so the create
// drawer's dropdown and the management list share the same renderers.
interface FlavorSpecLike {
manufacturer?: string | null;
product?: string | null;
memory?: string | null;
sliceable?: boolean;
acceleratable?: boolean;
}
// A flavor's title mirrors the flavor card: a generic (no product, no/`generic`
// manufacturer, non-acceleratable) flavor reads as "CPU-only".
export const getFlavorTitle = (
spec: FlavorSpecLike = {},
fallbackName?: string | null
) => {
const manufacturer = spec.manufacturer || '';
const isCpuOnly =
!spec.acceleratable &&
!spec.product &&
(!manufacturer || manufacturer.toLowerCase() === 'generic');
return isCpuOnly ? 'CPU-only' : spec.product || fallbackName || '-';
};
// Secondary line, dot-separated: manufacturer · memory · sliceable. memory and
// sliceable apply to accelerator (GPU) flavors only; sliceable stays a tag.
// Returns null when a (generic) flavor has nothing to show.
export const FlavorMeta: React.FC<{ spec?: FlavorSpecLike }> = ({
spec = {}
}) => {
const intl = useIntl();
const manufacturer = spec.manufacturer || '';
const color = manufactureColorMap[manufacturer] ?? 'purple';
const memory = spec.acceleratable
? formatMemoryDisplay(spec.memory ?? undefined)
: '';
const pieces: React.ReactNode[] = [];
if (manufacturer) {
pieces.push(
<ThemeTag
key="vendor"
color={color}
style={{ fontWeight: 400, marginInlineEnd: 0 }}
>
{formatManufacturer(manufacturer)}
</ThemeTag>
);
}
if (memory) {
pieces.push(<span key="memory">{memory}</span>);
}
if (spec.acceleratable && spec.sliceable) {
pieces.push(
<ThemeTag
key="sliceable"
color="geekblue"
style={{ fontWeight: 400, marginInlineEnd: 0 }}
>
{intl.formatMessage({ id: 'gpuservice.instance.sliceable' })}
</ThemeTag>
);
}
if (!pieces.length) return null;
return (
<Flex
align="center"
gap={8}
style={{
minWidth: 0,
color: 'var(--ant-color-text-tertiary)',
fontSize: 12
}}
>
{pieces.flatMap((piece, index) =>
index === 0
? [piece]
: [
<span
key={`dot-${index}`}
style={{ color: 'var(--ant-color-text-quaternary)' }}
>
·
</span>,
piece
]
)}
</Flex>
);
};
// Two-line flavor display: title on top, meta row below. Shared by the create
// drawer's dropdown option and the management list's flavor cell.
export const FlavorOption: React.FC<{
spec?: FlavorSpecLike;
fallbackName?: string | null;
maxWidth?: number | string;
}> = ({ spec = {}, fallbackName, maxWidth = '100%' }) => (
<Flex vertical gap={4} style={{ minWidth: 0, padding: '2px 0' }}>
<AutoTooltip ghost minWidth={20} maxWidth={maxWidth}>
{getFlavorTitle(spec, fallbackName)}
</AutoTooltip>
<FlavorMeta spec={spec} />
</Flex>
);
// Single-line flavor display: title then meta inline. Used for the collapsed
// selected value in the create drawer's Select.
export const FlavorSelected: React.FC<{
spec?: FlavorSpecLike;
fallbackName?: string | null;
}> = ({ spec = {}, fallbackName }) => (
<Flex align="center" gap={8} style={{ minWidth: 0 }}>
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{getFlavorTitle(spec, fallbackName)}
</AutoTooltip>
<FlavorMeta spec={spec} />
</Flex>
);
@@ -8,24 +8,24 @@ export const GPU_INSTANCE_TYPE_OS = 'linux';
export const InstanceTypePhaseValueMap = { export const InstanceTypePhaseValueMap = {
Active: 'Active', Active: 'Active',
Inactive: 'Inactive', Inactive: 'Inactive',
Preparing: 'Preparing' Draining: 'Draining'
}; };
export const InstanceTypePhaseLabelMap: Record<string, string> = { export const InstanceTypePhaseLabelMap: Record<string, string> = {
[InstanceTypePhaseValueMap.Active]: 'Active', [InstanceTypePhaseValueMap.Active]: 'Active',
[InstanceTypePhaseValueMap.Inactive]: 'Inactive', [InstanceTypePhaseValueMap.Inactive]: 'Inactive',
[InstanceTypePhaseValueMap.Preparing]: 'Preparing' [InstanceTypePhaseValueMap.Draining]: 'Draining'
}; };
export const status: Record<string, StatusType> = { export const status: Record<string, StatusType> = {
[InstanceTypePhaseValueMap.Active]: StatusMaps.success, [InstanceTypePhaseValueMap.Active]: StatusMaps.success,
[InstanceTypePhaseValueMap.Inactive]: StatusMaps.inactive, [InstanceTypePhaseValueMap.Inactive]: StatusMaps.inactive,
[InstanceTypePhaseValueMap.Preparing]: StatusMaps.transitioning [InstanceTypePhaseValueMap.Draining]: StatusMaps.transitioning
}; };
export const ArchOptions = [ export const ArchOptions = [
{ label: 'amd64', value: 'amd64' }, { label: 'AMD64', value: 'amd64' },
{ label: 'arm64', value: 'arm64' } { label: 'ARM64', value: 'arm64' }
]; ];
// ``icon`` is narrowed to ``any`` so the inferred type doesn't reach into // ``icon`` is narrowed to ``any`` so the inferred type doesn't reach into
@@ -4,6 +4,7 @@ export interface UnitResources {
} }
export interface InstanceTypeSpec { export interface InstanceTypeSpec {
displayName?: string | null;
manufacturer?: string | null; manufacturer?: string | null;
product?: string | null; product?: string | null;
family?: string | null; family?: string | null;
@@ -54,6 +55,7 @@ export interface FlavorItem {
export interface FormData { export interface FormData {
name: string; name: string;
spec: { spec: {
displayName?: string | null;
acceleratorGroup?: string | null; acceleratorGroup?: string | null;
generalGroup?: string | null; generalGroup?: string | null;
acceleratable?: boolean; acceleratable?: boolean;
@@ -1,18 +1,18 @@
import { validateLabelNameRegxFor63 } from '@/config'; import { validateLabelNameRegxFor63 } from '@/config';
import { import {
AutoTooltip,
Input as CInput, Input as CInput,
InputNumber, InputNumber,
Select as SealSelect, Select as SealSelect,
ThemeTag,
useAppUtils useAppUtils
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd'; import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { formatMemoryDisplay } from '../../instances/config'; import {
import { manufactureColorMap } from '../../templates/config'; FlavorOption,
import { formatManufacturer } from '../../utils'; FlavorSelected,
getFlavorTitle
} from '../components/flavor-display';
import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config'; import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config';
import { FlavorItem, FormData } from '../config/types'; import { FlavorItem, FormData } from '../config/types';
import styles from '../styles/instance-types.module.less'; import styles from '../styles/instance-types.module.less';
@@ -39,18 +39,6 @@ interface InstanceTypeFormProps {
onFinishFailed?: (errorInfo: any) => void; onFinishFailed?: (errorInfo: any) => void;
} }
// A flavor's title mirrors the flavor card: a generic (no product, no/`generic`
// manufacturer, non-acceleratable) flavor reads as "CPU-only".
const getFlavorTitle = (flavor: FlavorItem) => {
const spec = flavor.spec || {};
const manufacturer = spec.manufacturer || '';
const isCpuOnly =
!spec.acceleratable &&
!spec.product &&
(!manufacturer || manufacturer.toLowerCase() === 'generic');
return isCpuOnly ? 'CPU-only' : spec.product || flavor.name || '-';
};
const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef( const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
(props, ref) => { (props, ref) => {
const { const {
@@ -97,102 +85,19 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
} }
})); }));
// A flavor's dropdown / selected label shows the same info as the flavor
// Secondary line, dot-separated: manufacturer · memory · sliceable. memory
// and sliceable apply to accelerator (GPU) flavors only; sliceable stays a
// tag. Returns null when a (generic) flavor has nothing to show.
const renderFlavorMeta = (flavor: FlavorItem) => {
const spec = flavor.spec || {};
const manufacturer = spec.manufacturer || '';
const color = manufactureColorMap[manufacturer] ?? 'purple';
const memory = spec.acceleratable
? formatMemoryDisplay(spec.memory ?? undefined)
: '';
const pieces: React.ReactNode[] = [];
if (manufacturer) {
pieces.push(
<ThemeTag
key="vendor"
color={color}
style={{ fontWeight: 400, marginInlineEnd: 0 }}
>
{formatManufacturer(manufacturer)}
</ThemeTag>
);
}
if (memory) {
pieces.push(<span key="memory">{memory}</span>);
}
if (spec.acceleratable && spec.sliceable) {
pieces.push(
<ThemeTag
key="sliceable"
color="geekblue"
style={{ fontWeight: 400, marginInlineEnd: 0 }}
>
{intl.formatMessage({ id: 'gpuservice.instance.sliceable' })}
</ThemeTag>
);
}
if (!pieces.length) return null;
return (
<Flex
align="center"
gap={8}
style={{
minWidth: 0,
color: 'var(--ant-color-text-tertiary)',
fontSize: 12
}}
>
{pieces.flatMap((piece, index) =>
index === 0
? [piece]
: [
<span
key={`dot-${index}`}
style={{ color: 'var(--ant-color-text-quaternary)' }}
>
·
</span>,
piece
]
)}
</Flex>
);
};
// Dropdown item: two lines — name on top, meta row below.
const renderFlavorOption = (flavor: FlavorItem) => (
<Flex vertical gap={4} style={{ minWidth: 0, padding: '2px 0' }}>
<AutoTooltip ghost minWidth={20} maxWidth={'100%'}>
{getFlavorTitle(flavor)}
</AutoTooltip>
{renderFlavorMeta(flavor)}
</Flex>
);
// Collapsed selected value: single line — name then meta inline.
const renderFlavorSelected = (flavor: FlavorItem) => (
<Flex align="center" gap={8} style={{ minWidth: 0 }}>
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{getFlavorTitle(flavor)}
</AutoTooltip>
{renderFlavorMeta(flavor)}
</Flex>
);
// Split flavors into two groups: CPU compute (generic) and GPU compute // Split flavors into two groups: CPU compute (generic) and GPU compute
// (accelerator). Groups render as labeled sections in the dropdown. // (accelerator). Groups render as labeled sections in the dropdown.
const toFlavorOption = (flavor: FlavorItem) => ({ const toFlavorOption = (flavor: FlavorItem) => ({
value: flavor.name, value: flavor.name,
label: getFlavorTitle(flavor), label: getFlavorTitle(flavor.spec, flavor.name),
flavor flavor
}); });
const cpuFlavors = flavorList.filter((flavor) => !flavor.spec?.acceleratable); const cpuFlavors = flavorList.filter(
const gpuFlavors = flavorList.filter((flavor) => flavor.spec?.acceleratable); (flavor) => !flavor.spec?.acceleratable
);
const gpuFlavors = flavorList.filter(
(flavor) => flavor.spec?.acceleratable
);
const flavorOptions = [ const flavorOptions = [
cpuFlavors.length && { cpuFlavors.length && {
label: intl.formatMessage({ label: intl.formatMessage({
@@ -218,6 +123,7 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
await onFinish({ await onFinish({
name: values.name, name: values.name,
spec: { spec: {
displayName: values.spec?.displayName?.trim() || null,
acceleratorGroup: selectedFlavor?.spec?.acceleratorGroup ?? null, acceleratorGroup: selectedFlavor?.spec?.acceleratorGroup ?? null,
generalGroup: selectedFlavor?.spec?.generalGroup ?? null, generalGroup: selectedFlavor?.spec?.generalGroup ?? null,
acceleratable: selectedFlavor?.spec?.acceleratable ?? false, acceleratable: selectedFlavor?.spec?.acceleratable ?? false,
@@ -259,6 +165,25 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData>
name={['spec', 'displayName']}
rules={[
{
max: 63,
message: intl.formatMessage({
id: 'gpuservice.template.displayName.max'
})
}
]}
>
<CInput.Input
trim={false}
label={intl.formatMessage({
id: 'gpuservice.template.displayName'
})}
/>
</Form.Item>
<Form.Item> <Form.Item>
<SealSelect <SealSelect
label={intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })} label={intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
@@ -274,14 +199,19 @@ const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
flavorList.find((flavor) => flavor.name === val) ?? null flavorList.find((flavor) => flavor.name === val) ?? null
) )
} }
optionRender={(option: any) => optionRender={(option: any) => {
renderFlavorOption(option.data.flavor) const flavor: FlavorItem = option.data.flavor;
} return (
<FlavorOption spec={flavor.spec} fallbackName={flavor.name} />
);
}}
labelRender={({ value }) => { labelRender={({ value }) => {
const flavor = flavorList.find((item) => item.name === value); const flavor = flavorList.find((item) => item.name === value);
return flavor return flavor ? (
? renderFlavorSelected(flavor) <FlavorSelected spec={flavor.spec} fallbackName={flavor.name} />
: ((value ?? '') as React.ReactNode); ) : (
((value ?? '') as React.ReactNode)
);
}} }}
/> />
</Form.Item> </Form.Item>
@@ -3,22 +3,20 @@ import {
AutoTooltip, AutoTooltip,
DropdownButtons, DropdownButtons,
icons, icons,
StatusTag, StatusTag
ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Space, Tooltip } from 'antd'; import { Space, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatMemoryDisplay } from '../../instances/config'; import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import { manufactureColorMap } from '../../templates/config'; import { FlavorOption } from '../components/flavor-display';
import { import {
ceilMilliToCore, InstanceTypePhaseLabelMap,
formatManufacturer, InstanceTypePhaseValueMap,
parseQuantityToGi status as phaseStatusMap
} from '../../utils'; } from '../config';
import { InstanceTypePhaseLabelMap, status as phaseStatusMap } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
@@ -26,16 +24,36 @@ interface ColumnsHookProps {
} }
// DropdownButtons reads `locale` / `props` at runtime; its `items` prop is // DropdownButtons reads `locale` / `props` at runtime; its `items` prop is
// typed as antd's MenuProps['items'], so cast the config to satisfy it. // typed as antd's MenuProps['items'], so cast the config to satisfy it. The
const rowActions = [ // activate / deactivate action is chosen from the row's current phase: Active
{ // types can be deactivated, Inactive ones activated (none while Preparing).
const buildRowActions = (record: ListItem) => {
const phase = record.status?.phase;
const actions: any[] = [];
if (phase === InstanceTypePhaseValueMap.Active) {
actions.push({
label: 'gpuservice.instanceType.deactivate',
key: 'deactivate',
locale: true,
icon: icons.Disabled
});
} else if (phase === InstanceTypePhaseValueMap.Inactive) {
actions.push({
label: 'gpuservice.instanceType.activate',
key: 'activate',
locale: true,
icon: icons.Charger
});
}
actions.push({
label: 'common.button.delete', label: 'common.button.delete',
key: 'delete', key: 'delete',
locale: true, locale: true,
icon: icons.DeleteOutlined, icon: icons.DeleteOutlined,
props: { danger: true } props: { danger: true }
} });
] as any; return actions;
};
// Column header with an info tooltip (used for the per-GPU resource columns). // Column header with an info tooltip (used for the per-GPU resource columns).
const TitleWithTip: React.FC<{ title: string; tip: string }> = ({ const TitleWithTip: React.FC<{ title: string; tip: string }> = ({
@@ -64,75 +82,31 @@ const useInstanceTypeColumns = ({
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
ellipsis: { showTitle: false }, ellipsis: { showTitle: false },
render: (text: string) => ( // Prefer the friendly display name, fall back to the resource name.
<AutoTooltip ghost minWidth={20} maxWidth={200} title={text}> render: (text: string, record: ListItem) => {
<span className="text-primary">{text || '-'}</span> const label = record.spec?.displayName || text;
</AutoTooltip> return (
) <AutoTooltip ghost minWidth={20} maxWidth={200} title={label}>
<span className="text-primary">{label || '-'}</span>
</AutoTooltip>
);
}
}, },
{ {
title: intl.formatMessage({ id: 'gpuservice.instanceType.product' }), // Flavor cell mirrors the create drawer's dropdown: product name on
// top, manufacturer · memory · sliceable on the meta line below.
title: intl.formatMessage({ id: 'gpuservice.instanceType.flavor' }),
dataIndex: ['spec', 'product'], dataIndex: ['spec', 'product'],
key: 'product', key: 'product',
ellipsis: { showTitle: false }, ellipsis: { showTitle: false },
render: (text: string, record: ListItem) => ( render: (_text: string, record: ListItem) => (
<Flex <FlavorOption
gap={4} spec={record.spec}
align="center" fallbackName={record.name}
style={{ maxWidth: '100%', minWidth: 0 }} maxWidth={200}
> />
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{text || '-'}
</AutoTooltip>
{record.spec?.sliceable && (
<ThemeTag
color="geekblue"
style={{ fontWeight: 400, flexShrink: 0 }}
>
{intl.formatMessage({ id: 'gpuservice.instance.sliceable' })}
</ThemeTag>
)}
</Flex>
) )
}, },
{
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: ( title: (
<TitleWithTip <TitleWithTip
@@ -191,7 +165,7 @@ const useInstanceTypeColumns = ({
} }
}, },
{ {
title: intl.formatMessage({ id: 'gpuservice.instance.os' }), title: intl.formatMessage({ id: 'gpuservice.instanceType.platform' }),
key: 'os', key: 'os',
ellipsis: { showTitle: false }, ellipsis: { showTitle: false },
render: (_text, record: ListItem) => { render: (_text, record: ListItem) => {
@@ -202,9 +176,9 @@ const useInstanceTypeColumns = ({
<AutoTooltip <AutoTooltip
ghost ghost
maxWidth={240} maxWidth={240}
title={arch ? `${os} (${arch})` : os} title={arch ? `${os}/${arch}` : os}
> >
{arch ? `${os} (${arch})` : os} {arch ? `${os}/${arch}` : os}
</AutoTooltip> </AutoTooltip>
); );
} }
@@ -234,7 +208,7 @@ const useInstanceTypeColumns = ({
ellipsis: { showTitle: false }, ellipsis: { showTitle: false },
render: (_text, record: ListItem) => ( render: (_text, record: ListItem) => (
<DropdownButtons <DropdownButtons
items={rowActions} items={buildRowActions(record)}
onSelect={(val: string) => handleSelect(val, record)} onSelect={(val: string) => handleSelect(val, record)}
/> />
) )
+25 -1
View File
@@ -12,7 +12,11 @@ import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, Table, message } from 'antd'; import { ConfigProvider, Divider, Flex, Table, message } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import PageBox, { HeaderLeft } from '../../_components/page-box'; import PageBox, { HeaderLeft } from '../../_components/page-box';
import { deleteGPUInstanceType } from './apis'; import {
activateGPUInstanceType,
deactivateGPUInstanceType,
deleteGPUInstanceType
} from './apis';
import AddInstanceTypeModal from './components/add-instance-type-modal'; import AddInstanceTypeModal from './components/add-instance-type-modal';
import { FormData, ListItem } from './config/types'; import { FormData, ListItem } from './config/types';
import useCreateInstanceTypeModal from './hooks/use-create-instance-type-modal'; import useCreateInstanceTypeModal from './hooks/use-create-instance-type-modal';
@@ -120,9 +124,29 @@ const GPUServiceInstanceTypes: React.FC = () => {
}); });
}); });
const handleToggleActive = useMemoizedFn(
async (record: ListItem, activate: boolean) => {
if (clusterId == null) return;
try {
const action = activate
? activateGPUInstanceType
: deactivateGPUInstanceType;
await action({ name: record.name, cluster_id: clusterId });
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchInstanceTypes(clusterId);
} catch (error) {
// handled by the request interceptor
}
}
);
const handleSelect = useMemoizedFn((val: string, record: ListItem) => { const handleSelect = useMemoizedFn((val: string, record: ListItem) => {
if (val === 'delete') { if (val === 'delete') {
handleDelete(record); handleDelete(record);
} else if (val === 'activate') {
handleToggleActive(record, true);
} else if (val === 'deactivate') {
handleToggleActive(record, false);
} }
}); });
@@ -115,7 +115,7 @@ function getInstanceDerived(item: InstanceTypeItemModel) {
acceleratable, acceleratable,
isGPU: acceleratable, isGPU: acceleratable,
manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu', // GPU manufacturer or 'cpu' for non-acceleratable types manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu', // GPU manufacturer or 'cpu' for non-acceleratable types
displayName: acceleratable ? spec.product || item.name : 'CPU Only', displayName: acceleratable ? spec.product || item.name : 'CPU-only',
ramUnit: spec.unitResourcesParsed?.ram?.value, ramUnit: spec.unitResourcesParsed?.ram?.value,
os: _.capitalize(spec.os) || '', os: _.capitalize(spec.os) || '',
arch: spec.arch, arch: spec.arch,
@@ -131,7 +131,7 @@ export const renderInstanceType = (
// Types breakdown only wants CPU + RAM, for example. // Types breakdown only wants CPU + RAM, for example.
categories?: SpecCategory[]; categories?: SpecCategory[];
// Override the primary label (default: derived "<product> x <count>" / // Override the primary label (default: derived "<product> x <count>" /
// "CPU Only"). The Instance Types breakdown keeps its plain product name. // "CPU-only"). The Instance Types breakdown keeps its plain product name.
title?: string; title?: string;
} }
) => { ) => {
@@ -148,7 +148,7 @@ export const renderInstanceType = (
? isSliced ? isSliced
? `${description.product} (${sliceMemoryPercentage}%)` ? `${description.product} (${sliceMemoryPercentage}%)`
: `${description.product} x ${accelerator}` : `${description.product} x ${accelerator}`
: 'CPU Only'); : 'CPU-only');
const volume = (record.spec as any)?.volume; const volume = (record.spec as any)?.volume;
// Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring // Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring
@@ -251,7 +251,7 @@ const mibToGiQuantity = (mib?: number): string | undefined =>
export interface InstanceTypeMiB { export interface InstanceTypeMiB {
name?: string; name?: string;
product?: string; product?: string;
// accelerator (GPU card) count; 0/undefined → CPU-only ("CPU Only"). // accelerator (GPU card) count; 0/undefined → CPU-only.
gpuCount?: number; gpuCount?: number;
// Per-card values. // Per-card values.
unitCpuMilli?: number; unitCpuMilli?: number;
+2 -2
View File
@@ -93,7 +93,7 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
unit_memory_mib?: number; unit_memory_mib?: number;
vram_mib?: number; vram_mib?: number;
// Instance totals (requested cpu/ram) — the real size, so CPU instance types // Instance totals (requested cpu/ram) — the real size, so CPU instance types
// show "CPU Only · 2 vCPU · 4 GB" instead of just the per-unit spec. // show "CPU-only · 2 vCPU · 4 GB" instead of just the per-unit spec.
cpu_milli?: number; cpu_milli?: number;
memory_mib?: number; memory_mib?: number;
// Per-instance rows also carry the card count + ephemeral disk so the // Per-instance rows also carry the card count + ephemeral disk so the
@@ -382,7 +382,7 @@ function flattenItem(
if (it.creator_deleted != null) flat.user_deleted = !!it.creator_deleted; if (it.creator_deleted != null) flat.user_deleted = !!it.creator_deleted;
// Instance-type grouped trend: the series label (``group``) defaults to the // Instance-type grouped trend: the series label (``group``) defaults to the
// raw flavor slug. Instance Types are grouped by actual shape, so label each // raw flavor slug. Instance Types are grouped by actual shape, so label each
// series by that shape — "<product> x <cards>" / "CPU Only · 3 vCPU · 6 GB" — // series by that shape — "<product> x <cards>" / "CPU-only · 3 vCPU · 6 GB" —
// matching the table and keeping every shape a distinct series (#5700). // matching the table and keeping every shape a distinct series (#5700).
// ``groupBy`` is the unmapped frontend dimension; the instance-type axis is // ``groupBy`` is the unmapped frontend dimension; the instance-type axis is
// ``gpu_type`` (→ backend ``instance_type`` via GROUP_BY_MAP). // ``gpu_type`` (→ backend ``instance_type`` via GROUP_BY_MAP).
@@ -39,7 +39,7 @@ const useInstancesColumns = (groupKey: GroupKey) => {
} }
]; ];
// Instance Types breakdown: the pretty product name (or flavor slug for // Instance Types breakdown: the pretty product name (or flavor slug for
// older rows; "CPU Only" when no GPU cards) plus a CPU + RAM spec popover — // older rows; "CPU-only" when no GPU cards) plus a CPU + RAM spec popover —
// rendered through the canonical renderer so the formatting matches the GPU // rendered through the canonical renderer so the formatting matches the GPU
// Instances list, but limited to the CPU/RAM categories. // Instances list, but limited to the CPU/RAM categories.
const instanceTypeColType = { const instanceTypeColType = {
@@ -64,7 +64,7 @@ const useInstancesColumns = (groupKey: GroupKey) => {
intl, intl,
categories: ['cpu', 'ram'], categories: ['cpu', 'ram'],
// Each row is one shape: GPU "<product> x <cards>", CPU // Each row is one shape: GPU "<product> x <cards>", CPU
// "CPU Only · <spec>". // "CPU-only · <spec>".
title: instanceTypeSeriesLabel(row) title: instanceTypeSeriesLabel(row)
} }
); );
@@ -95,7 +95,7 @@ const useInstancesColumns = (groupKey: GroupKey) => {
persistentMib: row.persistent_mib persistentMib: row.persistent_mib
}), }),
// Label by shape directly (consistent with the Instance Types // Label by shape directly (consistent with the Instance Types
// column); avoids renderInstanceType's "CPU Only" fallback when a // column); avoids renderInstanceType's "CPU-only" fallback when a
// GPU row has vram but a missing/zero gpu_count. // GPU row has vram but a missing/zero gpu_count.
{ intl, title: instanceTypeSeriesLabel(row) } { intl, title: instanceTypeSeriesLabel(row) }
); );
@@ -32,16 +32,16 @@ export const formatCpuSpec = (
return parts.join(' · '); return parts.join(' · ');
}; };
// CPU instance-type label: "CPU Only" plus its real size when known, e.g. // CPU instance-type label: "CPU-only" plus its real size when known, e.g.
// "CPU Only · 2 vCPU · 4 GB". Used by both the table column and the trend // "CPU-only · 2 vCPU · 4 GB". Used by both the table column and the trend
// legend so they read identically. // legend so they read identically.
export const cpuOnlyLabel = (row?: Partial<ResourceBreakdownItem>): string => { export const cpuOnlyLabel = (row?: Partial<ResourceBreakdownItem>): string => {
const spec = formatCpuSpec(row?.cpu_milli, row?.memory_mib); const spec = formatCpuSpec(row?.cpu_milli, row?.memory_mib);
return spec ? `CPU Only · ${spec}` : 'CPU Only'; return spec ? `CPU-only · ${spec}` : 'CPU-only';
}; };
// Instance Types are grouped by actual shape, so each row is one concrete // Instance Types are grouped by actual shape, so each row is one concrete
// type: a GPU shows "<product> x <cards>", a CPU shows "CPU Only · <spec>". // type: a GPU shows "<product> x <cards>", a CPU shows "CPU-only · <spec>".
// One label for the table column and the trend legend so they read the same // One label for the table column and the trend legend so they read the same
// and each shape is a distinct series. (" x " matches the GPU Instances list.) // and each shape is a distinct series. (" x " matches the GPU Instances list.)
export const instanceTypeSeriesLabel = ( export const instanceTypeSeriesLabel = (