([]);
@@ -101,10 +187,6 @@ const DevboxCreatePage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const isEdit = useMemo(() => !!devboxName, []);
- const maxGpuAmount = useMemo(
- () => GpuAmountMarkList[GpuAmountMarkList.length - 1]?.value ?? 4,
- []
- );
useEffect(() => {
if (isEdit) return;
@@ -140,6 +222,44 @@ const DevboxCreatePage = () => {
() => templateListQuery.data?.templateList || [],
[templateListQuery.data?.templateList]
);
+ const gpuInventoryData = useMemo(
+ () => buildGpuInventoryFromPrice(sourcePrice?.gpu),
+ [sourcePrice?.gpu]
+ );
+
+ const findSelectedGpuSpec = useCallback(
+ (gpu?: DevboxEditTypeV2['gpu']) => {
+ if (!gpu) return undefined as
+ | {
+ model: GpuInventoryModel;
+ spec: GpuInventorySpec;
+ }
+ | undefined;
+
+ const selectedModel =
+ (gpu.model && gpuInventoryData.find((item) => item.model === gpu.model)) ||
+ gpuInventoryData.find((item) =>
+ item.specs.some(
+ (spec) =>
+ normalizeText(spec.podConfig?.annotations?.[gpuTypeAnnotationKey]) ===
+ normalizeText(gpu.type)
+ )
+ );
+ if (!selectedModel) return undefined;
+
+ const selectedSpec =
+ selectedModel.specs.find(
+ (spec) => spec.type === gpu.specType && spec.value === gpu.specValue
+ ) ||
+ (gpu.podConfig
+ ? selectedModel.specs.find((spec) => isSamePodConfig(spec.podConfig, gpu.podConfig))
+ : undefined);
+ if (!selectedSpec) return undefined;
+
+ return { model: selectedModel, spec: selectedSpec };
+ },
+ [gpuInventoryData]
+ );
const generateDefaultYamlList = () => generateYamlList(defaultDevboxEditValueV2, env);
@@ -157,31 +277,6 @@ const DevboxCreatePage = () => {
[]
);
- const countGpuInventory = useCallback(
- (type?: string) => {
- if (!type) return 0;
- const gpuItems = sourcePrice?.gpu?.filter((item) => item.annotationType === type) || [];
- const available = gpuItems.reduce((sum, item) => sum + (item.available || 0), 0);
- const total = gpuItems.reduce((sum, item) => sum + (item.count || 0), 0);
-
- if (!isEdit) {
- return available;
- }
-
- const originalGpu = oldDevboxEditData.current?.gpu;
- if (!originalGpu || originalGpu.type !== type) {
- return available;
- }
-
- const calculatedAvailable = available + (originalGpu.amount || 0);
-
- // prevent calculated available from exceeding total inventory
- // this handles cases where backend's available already includes current devbox's resources
- return Math.min(calculatedAvailable, total);
- },
- [isEdit, sourcePrice?.gpu]
- );
-
useEffect(() => {
const subscription = formHook.watch((value) => {
if (value) {
@@ -214,7 +309,6 @@ const DevboxCreatePage = () => {
if (!res) {
return;
}
- oldDevboxEditData.current = res;
formOldYamls.current = generateYamlList(res, env);
crOldYamls.current = generateYamlList(res, env) as DevboxKindsType[];
formHook.reset(res);
@@ -236,19 +330,50 @@ const DevboxCreatePage = () => {
setIsLoading(true);
try {
// gpu inventory check
- if (formData.gpu?.type) {
- if (formData.gpu?.amount > maxGpuAmount) {
- return toast.warning(t('Gpu amount over max Tip', { max: maxGpuAmount }));
+ if ((formData.gpu?.model || formData.gpu?.type) && gpuInventoryData.length > 0) {
+ const selectedGpu = findSelectedGpuSpec(formData.gpu);
+ const selectedSpec = selectedGpu?.spec;
+ const selectedStock = selectedSpec?.stock ?? formData.gpu?.stock ?? 0;
+ const selectedSpecType = selectedSpec?.type ?? formData.gpu?.specType;
+ const selectedPodConfig = selectedSpec?.podConfig ?? formData.gpu?.podConfig;
+ const selectedLimits = selectedPodConfig?.resources?.limits;
+
+ if (!selectedPodConfig || !selectedLimits || Object.keys(selectedLimits).length === 0) {
+ return toast.warning(t('gpu_spec_invalid'));
+ }
+
+ if (selectedStock <= 0) {
+ return toast.warning(
+ t('Gpu under inventory Tip', {
+ gputype: formData.gpu?.model || formData.gpu?.type || 'GPU'
+ })
+ );
}
- const inventory = countGpuInventory(formData.gpu?.type);
- if (formData.gpu?.amount > inventory) {
+ const amount = selectedSpecType === 'GPU' ? Math.max(formData.gpu?.amount || 1, 1) : 1;
+ if (selectedSpecType === 'GPU' && amount > selectedStock) {
return toast.warning(
t('Gpu under inventory Tip', {
- gputype: formData.gpu.type
+ gputype: formData.gpu?.model || formData.gpu?.type || 'GPU'
})
);
}
+
+ formData.gpu = {
+ ...formData.gpu,
+ model: selectedGpu?.model.model || formData.gpu?.model,
+ type:
+ selectedPodConfig.annotations?.[gpuTypeAnnotationKey] ||
+ formData.gpu?.type ||
+ formData.gpu?.model ||
+ '',
+ amount,
+ specType: selectedSpecType,
+ specValue: selectedSpec?.value || formData.gpu?.specValue,
+ specMemory: selectedSpec?.memory || formData.gpu?.specMemory,
+ stock: selectedStock,
+ podConfig: selectedPodConfig
+ };
}
// update
@@ -355,7 +480,7 @@ const DevboxCreatePage = () => {
/>
{tabType === 'form' ? (
-
+
) : (
)}
diff --git a/frontend/providers/devbox/app/api/createDevbox/schema.ts b/frontend/providers/devbox/app/api/createDevbox/schema.ts
index 7b0727618ac8..90e268e60b87 100644
--- a/frontend/providers/devbox/app/api/createDevbox/schema.ts
+++ b/frontend/providers/devbox/app/api/createDevbox/schema.ts
@@ -43,6 +43,34 @@ const GpuSchema = z
amount: z.number().default(1).openapi({
description: 'GPU amount'
}),
+ model: z.string().optional().openapi({
+ description: 'GPU model'
+ }),
+ specType: z.string().optional().openapi({
+ description: 'GPU spec type, e.g. GPU or vGPU'
+ }),
+ specValue: z.string().optional().openapi({
+ description: 'GPU spec value, e.g. full or 1/2'
+ }),
+ specMemory: z.string().optional().openapi({
+ description: 'GPU memory display text'
+ }),
+ stock: z.number().optional().openapi({
+ description: 'GPU stock snapshot for selected spec'
+ }),
+ podConfig: z
+ .object({
+ annotations: z.record(z.string()).optional(),
+ resources: z
+ .object({
+ limits: z.record(z.string()).optional()
+ })
+ .optional()
+ })
+ .optional()
+ .openapi({
+ description: 'GPU pod config from inventory spec'
+ }),
resource: z.record(z.string()).optional().openapi({
description: 'GPU resource map'
})
diff --git a/frontend/providers/devbox/app/api/platform/resourcePrice/route.ts b/frontend/providers/devbox/app/api/platform/resourcePrice/route.ts
index 8b26b402afc3..c108f8227eb3 100644
--- a/frontend/providers/devbox/app/api/platform/resourcePrice/route.ts
+++ b/frontend/providers/devbox/app/api/platform/resourcePrice/route.ts
@@ -1,9 +1,8 @@
import { NextRequest } from 'next/server';
-import { CoreV1Api } from '@kubernetes/client-node';
import { jsonRes } from '@/services/backend/response';
import { userPriceType } from '@/types/user';
-import { K8sApiDefault } from '@/services/backend/kubernetes';
+import type { GpuInventoryModel, GpuInventorySpec, GpuPodConfig } from '@/types/gpu';
export const dynamic = 'force-dynamic';
@@ -29,58 +28,130 @@ type ResourceType =
| 'infra-disk'
| 'services.nodeports';
-type GpuAliasName = {
- zh?: string;
- en?: string;
+const PRICE_SCALE = 1000000;
+const DEFAULT_GPU_INVENTORY_URL = 'http://node.node-system.svc:8090/api/v1/gpu/inventory';
+const GPU_TYPE_ANNOTATION_KEY = 'nvidia.com/use-gputype';
+
+const valuationMap: Record = {
+ cpu: 1000,
+ memory: 1024,
+ storage: 1024,
+ gpu: 1000,
+ ['services.nodeports']: 1
};
-type GpuAliasConfig = {
- default?: string;
- icon?: string;
- name?: GpuAliasName;
- resource?: Record;
+const decodeHeaderValue = (value: string) => {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
};
-type GpuAliasMap = Record;
-
-type GpuNodeType = {
- 'gpu.count': number;
- 'gpu.available': number;
- 'gpu.used': number;
- 'gpu.memory': number;
- 'gpu.product': string;
- 'gpu.ref'?: string;
- 'gpu.annotationType'?: string;
- 'gpu.icon'?: string;
- 'gpu.name'?: GpuAliasName;
- 'gpu.resource'?: Record;
- 'gpu.nodes'?: string[];
+const parseBearerHeader = (value: string, requirePrefix = false) => {
+ const decoded = decodeHeaderValue(value).trim();
+ if (/^bearer\s+/i.test(decoded)) {
+ return decoded.replace(/^bearer\s+/i, '').trim();
+ }
+ return requirePrefix ? '' : decoded;
};
-type GpuConfigMapItem = {
- 'gpu.count': string;
- 'gpu.available': string;
- 'gpu.used': string;
- 'gpu.memory': string;
- 'gpu.product': string;
- 'gpu.devbox': string;
- 'gpu.ref'?: string;
+const normalizeEndpoint = (url: string) => {
+ const trimmed = url.trim().replace(/\/+$/, '');
+ if (/\/api\/v1\/gpu\/inventory$/i.test(trimmed)) {
+ return trimmed;
+ }
+ return `${trimmed}/api/v1/gpu/inventory`;
};
-const PRICE_SCALE = 1000000;
+const toStringRecord = (value: unknown): Record | undefined => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const entries = Object.entries(value as Record)
+ .filter(([key]) => Boolean(key))
+ .map(([key, raw]) => [key, raw == null ? '' : String(raw)] as const);
+ if (entries.length === 0) return undefined;
+ return Object.fromEntries(entries);
+};
-const valuationMap: Record = {
- cpu: 1000,
- memory: 1024,
- storage: 1024,
- gpu: 1000,
- ['services.nodeports']: 1
+const normalizePodConfig = (value: unknown): GpuPodConfig | undefined => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const podConfig = value as Record;
+ const annotations = toStringRecord(podConfig.annotations);
+ const limits = toStringRecord((podConfig.resources as any)?.limits);
+
+ if (!annotations && !limits) return undefined;
+ return {
+ ...(annotations ? { annotations } : {}),
+ ...(limits ? { resources: { limits } } : {})
+ };
+};
+
+const normalizeSpec = (value: unknown): GpuInventorySpec | null => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const item = value as Record;
+
+ const typeRaw =
+ (typeof item.type === 'string' ? item.type : '') ||
+ (typeof item.specType === 'string' ? item.specType : '');
+ const type = typeRaw.trim();
+ const memory =
+ (typeof item.memory === 'string' ? item.memory : '') ||
+ (typeof item.specMemory === 'string' ? item.specMemory : '');
+ const valueRaw =
+ (typeof item.value === 'string' ? item.value : '') ||
+ (typeof item.specValue === 'string' ? item.specValue : '');
+ const specValue = valueRaw || (type.toUpperCase() === 'GPU' ? 'full' : '');
+ const stockRaw = item.stock ?? item.available ?? item.count;
+ const stock = Number(stockRaw);
+ const podConfig = normalizePodConfig(item.podConfig);
+
+ if (!type || !memory || !specValue) return null;
+ return {
+ type,
+ memory,
+ value: specValue,
+ stock: Number.isFinite(stock) && stock > 0 ? stock : 0,
+ ...(podConfig ? { podConfig } : {})
+ };
+};
+
+const normalizeModel = (value: unknown): GpuInventoryModel | null => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const item = value as Record;
+ const model = typeof item.model === 'string' ? item.model : '';
+ if (!model) return null;
+
+ const icon = typeof item.icon === 'string' ? item.icon : undefined;
+ const displayNameRaw = item.displayName as Record | undefined;
+ const displayName =
+ displayNameRaw && typeof displayNameRaw === 'object'
+ ? {
+ zh: typeof displayNameRaw.zh === 'string' ? displayNameRaw.zh : undefined,
+ en: typeof displayNameRaw.en === 'string' ? displayNameRaw.en : undefined
+ }
+ : undefined;
+ const specs = Array.isArray(item.specs)
+ ? item.specs.map(normalizeSpec).filter((spec): spec is GpuInventorySpec => Boolean(spec))
+ : [];
+
+ if (specs.length === 0) return null;
+
+ return {
+ model,
+ ...(displayName?.zh || displayName?.en ? { displayName } : {}),
+ ...(icon ? { icon } : {}),
+ specs
+ };
};
export async function GET(req: NextRequest) {
try {
- const { ACCOUNT_URL, SEALOS_DOMAIN, GPU_ENABLE } = process.env;
+ const { ACCOUNT_URL, SEALOS_DOMAIN, GPU_ENABLE, GPU_INVENTORY_API_BASE_URL } = process.env;
const baseUrl = ACCOUNT_URL ? ACCOUNT_URL : `https://account-api.${SEALOS_DOMAIN}`;
+ const gpuFeatureEnabled = GPU_ENABLE === 'true';
+ const token =
+ parseBearerHeader(req.headers.get('Authorization-Bearer') || '') ||
+ parseBearerHeader(req.headers.get('Authorization') || '', true);
const getResourcePrice = async () => {
try {
@@ -89,16 +160,41 @@ export async function GET(req: NextRequest) {
});
const data: ResourcePriceType = await res.clone().json();
-
- return data.data.properties;
+ return data.data.properties || [];
} catch (error) {
console.log(error);
+ return [] as ResourcePriceType['data']['properties'];
}
};
- const [priceResponse, gpuNodes] = await Promise.all([
+ const getGpuInventory = async () => {
+ if (!gpuFeatureEnabled || !token) return [] as GpuInventoryModel[];
+ try {
+ const endpoint = normalizeEndpoint(GPU_INVENTORY_API_BASE_URL || DEFAULT_GPU_INVENTORY_URL);
+ const res = await fetch(endpoint, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ cache: 'no-store'
+ });
+ if (!res.ok) {
+ return [] as GpuInventoryModel[];
+ }
+ const data = (await res.json()) as Record;
+ if (!Array.isArray(data.data)) return [] as GpuInventoryModel[];
+ return data.data
+ .map(normalizeModel)
+ .filter((item): item is GpuInventoryModel => Boolean(item));
+ } catch (error) {
+ console.log('getGpuInventory error', error);
+ return [] as GpuInventoryModel[];
+ }
+ };
+
+ const [priceResponse, gpuInventory] = await Promise.all([
getResourcePrice() as Promise,
- GPU_ENABLE === 'true' ? getGpuNode() : Promise.resolve([])
+ getGpuInventory()
]);
const data: userPriceType = {
@@ -106,7 +202,7 @@ export async function GET(req: NextRequest) {
memory: countSourcePrice(priceResponse, 'memory'),
storage: countSourcePrice(priceResponse, 'storage'),
nodeports: countSourcePrice(priceResponse, 'services.nodeports'),
- gpu: GPU_ENABLE === 'true' ? countGpuSource(priceResponse, gpuNodes) : undefined
+ gpu: gpuFeatureEnabled ? countGpuSource(priceResponse, gpuInventory) : undefined
};
return jsonRes({
@@ -117,92 +213,21 @@ export async function GET(req: NextRequest) {
}
}
-/* get gpu nodes by configmap. */
-async function getGpuNode() {
- const gpuCrName = 'node-gpu-info';
- const gpuCrNS = 'node-system';
-
- try {
- const kc = K8sApiDefault();
- const api = kc.makeApiClient(CoreV1Api);
-
- /*
- * Use listNamespacedConfigMap with fieldSelector instead of readNamespacedConfigMap
- * to bypass Kubernetes API Server watch cache stale data issue.
- *
- * Root cause: For infrequently updated resources like ConfigMap, the API Server's
- * watch cache may not receive timely progress notifications from etcd, causing
- * readNamespacedConfigMap to return stale cached data even after backend updates.
- *
- * Solution: listNamespacedConfigMap with fieldSelector forces a more consistent read
- * path that is less susceptible to watch cache staleness, ensuring we get the latest
- * ConfigMap data without needing to restart the pod.
- *
- * References:
- * - https://github.com/kubernetes/enhancements/blob/master/keps/sig-api-machinery/2340-Consistent-reads-from-cache/README.md
- * - https://kubernetes.io/blog/2025/09/09/kubernetes-v1-34-snapshottable-api-server-cache/
- */
- const { body } = await api.listNamespacedConfigMap(
- gpuCrNS,
- undefined,
- undefined,
- undefined,
- `metadata.name=${gpuCrName}`
- );
-
- if (!body.items || body.items.length === 0) return [];
-
- const configMap = body.items[0];
- const gpuMap = configMap.data?.gpu;
-
- if (!gpuMap || !configMap.data?.alias) return [];
- const alias = (JSON.parse(configMap.data.alias) || {}) as GpuAliasMap;
- const aliasBackupRaw = configMap.data['alias-backup'];
- const aliasBackup = aliasBackupRaw
- ? (JSON.parse(aliasBackupRaw) as Record)
- : undefined;
-
- const parseGpuMap = JSON.parse(gpuMap) as Record;
- const gpuEntries = Object.entries(parseGpuMap).filter(
- ([, item]) => item['gpu.product'] && item['gpu.devbox'] === 'true'
- );
-
- const gpuList: GpuNodeType[] = gpuEntries.map(([nodeName, item]) => {
- const fallbackRef =
- item['gpu.product'] && aliasBackup
- ? aliasBackup[item['gpu.product']] ||
- aliasBackup[item['gpu.product'].replace(/\s+/g, '-')]
- : undefined;
- const ref = item['gpu.ref'] || fallbackRef;
- const aliasItem = (ref && alias[ref]) || alias[item['gpu.product']];
- const annotationType = aliasItem?.default || item['gpu.product'] || ref;
-
- return {
- ['gpu.count']: +item['gpu.count'],
- ['gpu.available']: +item['gpu.available'],
- ['gpu.used']: +item['gpu.used'],
- ['gpu.memory']: +item['gpu.memory'],
- ['gpu.product']: item['gpu.product'],
- ['gpu.ref']: ref,
- ['gpu.annotationType']: annotationType,
- ['gpu.icon']: aliasItem?.icon,
- ['gpu.name']: aliasItem?.name,
- ['gpu.resource']: aliasItem?.resource,
- ['gpu.nodes']: [nodeName]
- };
- });
-
- return gpuList;
- } catch (error) {
- console.log('error', error);
- return [];
- }
-}
-
const normalizeGpuKey = (value?: string) =>
value ? value.trim().replace(/\s+/g, '-').toLowerCase() : '';
-function countGpuSource(rawData: ResourcePriceType['data']['properties'], gpuNodes: GpuNodeType[]) {
+const parseGpuMemoryGi = (memory?: string) => {
+ if (!memory) return 0;
+ const matched = memory.match(/(\d+(\.\d+)?)/);
+ if (!matched) return 0;
+ const value = Number(matched[1]);
+ return Number.isFinite(value) ? value : 0;
+};
+
+function countGpuSource(
+ rawData: ResourcePriceType['data']['properties'],
+ gpuInventory: GpuInventoryModel[]
+) {
const gpuList: userPriceType['gpu'] = [];
const gpuPriceMap = rawData
.filter((item) => item.name.startsWith('gpu'))
@@ -211,24 +236,37 @@ function countGpuSource(rawData: ResourcePriceType['data']['properties'], gpuNod
normalizedType: normalizeGpuKey(item.name.replace('gpu-', ''))
}));
- // count gpu price by gpuNode and accountPriceConfig
- gpuNodes.forEach((gpuNode) => {
- const matchedPrice = gpuPriceMap.find((item) => {
- const keys = [gpuNode['gpu.ref'], gpuNode['gpu.product'], gpuNode['gpu.annotationType']];
- return keys.some((key) => normalizeGpuKey(key) === item.normalizedType);
- });
- if (!matchedPrice) return;
-
- gpuList.push({
- annotationType: gpuNode['gpu.annotationType'] || '',
- price: (matchedPrice.unit_price * valuationMap.gpu) / PRICE_SCALE,
- available: +gpuNode['gpu.available'],
- count: +gpuNode['gpu.count'],
- vm: +gpuNode['gpu.memory'] / 1024,
- icon: gpuNode['gpu.icon'],
- name: gpuNode['gpu.name'],
- resource: gpuNode['gpu.resource'],
- nodes: gpuNode['gpu.nodes']
+ gpuInventory.forEach((modelItem) => {
+ modelItem.specs.forEach((spec) => {
+ const annotationType =
+ spec.podConfig?.annotations?.[GPU_TYPE_ANNOTATION_KEY] || modelItem.model;
+ const matchKeys = [
+ modelItem.model,
+ modelItem.displayName?.zh,
+ modelItem.displayName?.en,
+ annotationType
+ ]
+ .map((key) => normalizeGpuKey(key))
+ .filter(Boolean);
+ const matchedPrice = gpuPriceMap.find((item) => matchKeys.includes(item.normalizedType));
+ const stock = Math.max(Number(spec.stock) || 0, 0);
+
+ gpuList.push({
+ model: modelItem.model,
+ displayName: modelItem.displayName,
+ specType: spec.type,
+ specValue: spec.value,
+ specMemory: spec.memory,
+ stock,
+ podConfig: spec.podConfig,
+ annotationType,
+ price: matchedPrice ? (matchedPrice.unit_price * valuationMap.gpu) / PRICE_SCALE : 0,
+ available: stock,
+ count: stock,
+ vm: parseGpuMemoryGi(spec.memory),
+ icon: modelItem.icon,
+ name: modelItem.displayName
+ });
});
});
diff --git a/frontend/providers/devbox/components/GPUItem.tsx b/frontend/providers/devbox/components/GPUItem.tsx
index a5a720920738..196e9987c00f 100644
--- a/frontend/providers/devbox/components/GPUItem.tsx
+++ b/frontend/providers/devbox/components/GPUItem.tsx
@@ -7,16 +7,33 @@ import { GpuType } from '@/types/user';
import { usePriceStore } from '@/stores/price';
import { Tooltip, TooltipContent, TooltipTrigger } from '@sealos/shadcn-ui/tooltip';
+const normalizeText = (value?: string) => value?.trim().replace(/\s+/g, '').toLowerCase() || '';
+
const GPUItem = ({ gpu, className }: { gpu?: GpuType; className?: string }) => {
const t = useTranslations();
const locale = useLocale();
const { sourcePrice } = usePriceStore();
const defaultGpuIcon = '/images/nvidia.svg';
- const gpuItem = useMemo(
- () => sourcePrice?.gpu?.find((item) => item.annotationType === gpu?.type),
- [gpu?.type, sourcePrice?.gpu]
- );
+ const gpuItem = useMemo(() => {
+ const gpuList = sourcePrice?.gpu;
+ if (!gpuList?.length || !gpu?.type) return undefined;
+
+ const normalizedType = normalizeText(gpu.type);
+ const normalizedSpecType = normalizeText(gpu.specType);
+ const normalizedSpecValue = normalizeText(gpu.specValue);
+
+ const matchedBySpec = gpuList.find(
+ (item) =>
+ normalizeText(item.annotationType) === normalizedType &&
+ (!normalizedSpecType || normalizeText(item.specType) === normalizedSpecType) &&
+ (!normalizedSpecValue || normalizeText(item.specValue) === normalizedSpecValue)
+ );
+
+ if (matchedBySpec) return matchedBySpec;
+
+ return gpuList.find((item) => normalizeText(item.annotationType) === normalizedType);
+ }, [gpu?.specType, gpu?.specValue, gpu?.type, sourcePrice?.gpu]);
const gpuLabel = useMemo(() => {
const name = gpuItem?.name;
@@ -24,6 +41,20 @@ const GPUItem = ({ gpu, className }: { gpu?: GpuType; className?: string }) => {
return localizedName || gpuItem?.annotationType || gpu?.type || '';
}, [gpu?.type, gpuItem?.annotationType, gpuItem?.name, locale]);
+ const gpuSpecType = gpu?.specType || gpuItem?.specType || '';
+ const gpuSpecMemory = gpu?.specMemory || gpuItem?.specMemory || '';
+ const isVgpu = gpuSpecType.toLowerCase() === 'vgpu';
+
+ const gpuDisplayLabel = useMemo(() => {
+ if (!gpuLabel) return `0${t('Card')}`;
+
+ if (isVgpu) {
+ return [gpuLabel, gpuSpecType || 'vGPU', gpuSpecMemory].filter(Boolean).join(' ');
+ }
+
+ return `${gpuLabel} / ${gpu?.amount || 0}${t('Card')}`;
+ }, [gpu?.amount, gpuLabel, gpuSpecMemory, gpuSpecType, isVgpu, t]);
+
const handleGpuIconError = (event: React.SyntheticEvent) => {
const target = event.currentTarget;
if (!target.src.endsWith(defaultGpuIcon)) {
@@ -41,16 +72,7 @@ const GPUItem = ({ gpu, className }: { gpu?: GpuType; className?: string }) => {
className="mr-2 shrink-0"
onError={handleGpuIconError}
/>
- {gpuItem?.name && (
- <>
- {gpuLabel}
- /
- >
- )}
-
- {!!gpuLabel ? gpu?.amount : 0}
- {t('Card')}
-
+ {gpuDisplayLabel}
);
@@ -68,8 +90,7 @@ const GPUItem = ({ gpu, className }: { gpu?: GpuType; className?: string }) => {
height={16}
onError={handleGpuIconError}
/>
- {gpuLabel} / {gpu?.amount}
- {t('Card')}
+ {gpuDisplayLabel}