diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx index bd3732454481..f36ff46f726b 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx @@ -8,6 +8,7 @@ import { useSearchParams } from 'next/navigation'; import { useRouter } from '@/i18n'; import { obj2Query } from '@/utils/tools'; import type { DevboxEditTypeV2 } from '@/types/devbox'; +import type { GpuInventoryModel } from '@/types/gpu'; import Gpu from './Gpu'; import Cpu from './Cpu'; @@ -25,10 +26,10 @@ import { useEnvStore } from '@/stores/env'; interface FormProps { isEdit: boolean; - countGpuInventory: (type: string) => number; + gpuInventory: GpuInventoryModel[]; } -const Form = ({ isEdit, countGpuInventory }: FormProps) => { +const Form = ({ isEdit, gpuInventory }: FormProps) => { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); @@ -106,7 +107,7 @@ const Form = ({ isEdit, countGpuInventory }: FormProps) => { {/* Usage */}
{t('usage')} - + {showNfs && } diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Gpu.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Gpu.tsx index f429fc3b1061..007d35fdabc7 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Gpu.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Gpu.tsx @@ -1,15 +1,15 @@ import Image from 'next/image'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import type { SyntheticEvent } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { Minus, Plus } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; import { cn } from '@sealos/shadcn-ui'; import { Label } from '@sealos/shadcn-ui/label'; -import { usePriceStore } from '@/stores/price'; -import { DevboxEditTypeV2 } from '@/types/devbox'; -import { GpuAmountMarkList } from '@/constants/devbox'; -import type { SourcePrice } from '@/types/static'; +import type { DevboxEditTypeV2 } from '@/types/devbox'; +import type { GpuInventoryModel, GpuInventorySpec, GpuPodConfig } from '@/types/gpu'; +import { gpuTypeAnnotationKey } from '@/constants/devbox'; import { Select, @@ -19,110 +19,213 @@ import { SelectValue } from '@sealos/shadcn-ui/select'; -export default function Gpu({ - countGpuInventory -}: { - countGpuInventory: (type: string) => number; -}) { - type GpuPriceItem = NonNullable[number]; - type GpuOption = GpuPriceItem & { - optionKey: string; - nodeAlias: string; - }; - const defaultGpuIcon = '/images/nvidia.svg'; +const defaultGpuIcon = '/images/nvidia.svg'; + +const normalizeText = (value?: string) => value?.trim().replace(/\s+/g, '').toLowerCase() || ''; + +const getSpecKey = (spec: Pick) => `${spec.type}@@${spec.value}`; + +const getModelDisplayName = ( + model: Pick, + locale: string +) => { + const localizedName = locale.includes('zh') ? model.displayName?.zh : model.displayName?.en; + return localizedName || model.model; +}; + +const getSpecDisplayName = (spec: Pick, memoryLabel: string) => + `${spec.type} | ${memoryLabel}:${spec.memory}`; + +const getSpecFromModel = ( + model: GpuInventoryModel, + specType?: string, + specValue?: string +): GpuInventorySpec | undefined => { + if (!specType || !specValue) return undefined; + return model.specs.find((spec) => spec.type === specType && spec.value === specValue); +}; + +const isSameRecord = (lhs?: Record, rhs?: Record) => { + const left = lhs || {}; + const right = rhs || {}; + const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); + const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); + + if (leftEntries.length !== rightEntries.length) return false; + return leftEntries.every( + ([key, value], index) => rightEntries[index]?.[0] === key && rightEntries[index]?.[1] === value + ); +}; + +const isSamePodConfig = (left?: GpuPodConfig, right?: GpuPodConfig) => { + return ( + isSameRecord(left?.annotations, right?.annotations) && + isSameRecord(left?.resources?.limits, right?.resources?.limits) + ); +}; +const clampAmount = (value: number | undefined, min: number, max: number) => { + const safeValue = typeof value === 'number' && Number.isFinite(value) ? value : min; + return Math.min(Math.max(safeValue, min), max); +}; + +export default function Gpu({ gpuInventory }: { gpuInventory: GpuInventoryModel[] }) { const t = useTranslations(); const locale = useLocale(); - const { sourcePrice } = usePriceStore(); const { watch, setValue } = useFormContext(); - const [selectedGpuOptionKey, setSelectedGpuOptionKey] = useState('none'); - const selectedGpuType = watch('gpu.type'); - const selectedGpuAmount = watch('gpu.amount'); - const maxGpuAmount = GpuAmountMarkList[GpuAmountMarkList.length - 1]?.value ?? 4; + const selectedModel = watch('gpu.model'); + const selectedType = watch('gpu.type'); + const selectedSpecType = watch('gpu.specType'); + const selectedSpecValue = watch('gpu.specValue'); + const selectedSpecMemory = watch('gpu.specMemory'); + const selectedSpecStock = watch('gpu.stock'); + const selectedPodConfig = watch('gpu.podConfig'); + const selectedAmount = watch('gpu.amount'); + const selectedResource = watch('gpu.resource'); - const getGpuDisplayName = (gpu?: GpuPriceItem) => { - if (!gpu) return ''; - const name = gpu.name; - const localizedName = locale.includes('zh') ? name?.zh : name?.en; - return localizedName || gpu.annotationType || ''; - }; - const nodeAliasMap = useMemo(() => { - const nodes = Array.from( - new Set((sourcePrice?.gpu || []).flatMap((gpu) => gpu.nodes || [])) - ).sort((a, b) => a.localeCompare(b)); - return nodes.reduce>((acc, nodeName, index) => { - acc[nodeName] = `${t('gpu_node')} ${String(index + 1).padStart(2, '0')}`; - return acc; - }, {}); - }, [sourcePrice?.gpu, t]); - const gpuOptions = useMemo( - () => - (sourcePrice?.gpu || []).map((item, index) => { - const nodeName = item.nodes?.[0]; - const optionKey = `${item.annotationType}@@${nodeName || `index-${index}`}`; - - return { - ...item, - optionKey, - nodeAlias: nodeName ? nodeAliasMap[nodeName] || '-' : '-' - }; - }), - [sourcePrice?.gpu, nodeAliasMap] - ); - const selectedGpu = useMemo( - () => gpuOptions.find((item) => item.optionKey === selectedGpuOptionKey), - [gpuOptions, selectedGpuOptionKey] - ); - const gpuTotalCountMap = useMemo( - () => - (sourcePrice?.gpu || []).reduce>((acc, item) => { - acc[item.annotationType] = (acc[item.annotationType] || 0) + item.count; - return acc; - }, {}), - [sourcePrice?.gpu] + const selectedModelItem = useMemo( + () => gpuInventory.find((item) => item.model === selectedModel), + [gpuInventory, selectedModel] ); - const getGpuNodeDisplayName = (gpu?: GpuOption) => { - if (!gpu) return '-'; - return gpu.nodeAlias; - }; - const selectedGpuInventory = selectedGpuType ? countGpuInventory(selectedGpuType) : 0; - const selectedGpuTotalCount = selectedGpuType - ? gpuTotalCountMap[selectedGpuType] || selectedGpu?.count || 0 - : 0; - const gpuAmountOptions = useMemo(() => { - const options = [...GpuAmountMarkList]; - - if ( - typeof selectedGpuAmount === 'number' && - selectedGpuAmount > maxGpuAmount && - !options.some((item) => item.value === selectedGpuAmount) - ) { - options.push({ - label: String(selectedGpuAmount), - value: selectedGpuAmount - }); + + const selectedSpecItem = useMemo(() => { + if (!selectedModelItem) return undefined; + + const bySpecValue = getSpecFromModel(selectedModelItem, selectedSpecType, selectedSpecValue); + if (bySpecValue) return bySpecValue; + + if (selectedPodConfig) { + return selectedModelItem.specs.find((spec) => isSamePodConfig(spec.podConfig, selectedPodConfig)); } - return options.sort((a, b) => a.value - b.value); - }, [maxGpuAmount, selectedGpuAmount]); - const isLegacyGpuAmount = - typeof selectedGpuAmount === 'number' && selectedGpuAmount > maxGpuAmount; + return undefined; + }, [selectedModelItem, selectedSpecType, selectedSpecValue, selectedPodConfig]); + + const selectedModelDisplayName = selectedModelItem + ? getModelDisplayName(selectedModelItem, locale) + : undefined; + + const selectedSpecDisplayName = selectedSpecItem + ? getSpecDisplayName(selectedSpecItem, t('video_memory')) + : selectedSpecType && selectedSpecMemory + ? `${selectedSpecType} | ${t('video_memory')}:${selectedSpecMemory}` + : undefined; + + const upsertGpuValue = useCallback( + ({ + model, + spec, + keepAmount = false, + shouldDirty = true + }: { + model: GpuInventoryModel; + spec: GpuInventorySpec; + keepAmount?: boolean; + shouldDirty?: boolean; + }) => { + const amountMax = Math.max(spec.stock || 0, 1); + const amount = spec.type === 'GPU' ? clampAmount(keepAmount ? selectedAmount : 1, 1, amountMax) : 1; + const annotationType = + spec.podConfig?.annotations?.[gpuTypeAnnotationKey] || selectedType || selectedModel || model.model; + + setValue( + 'gpu', + { + manufacturers: 'nvidia', + model: model.model, + type: annotationType, + amount, + specType: spec.type, + specValue: spec.value, + specMemory: spec.memory, + stock: spec.stock, + podConfig: spec.podConfig, + resource: selectedResource + }, + { + shouldDirty, + shouldTouch: shouldDirty, + shouldValidate: true + } + ); + }, + [selectedAmount, selectedModel, selectedResource, selectedType, setValue] + ); useEffect(() => { - if (!selectedGpuType || selectedGpuType === 'none') { - setSelectedGpuOptionKey('none'); + if (!gpuInventory.length) { return; } - const stillValid = gpuOptions.some( - (item) => item.optionKey === selectedGpuOptionKey && item.annotationType === selectedGpuType - ); - if (stillValid) return; + const selectedGpuModel = + (selectedModel && gpuInventory.find((item) => item.model === selectedModel)) || + gpuInventory.find((item) => + item.specs.some( + (spec) => + normalizeText(spec.podConfig?.annotations?.[gpuTypeAnnotationKey]) === normalizeText(selectedType) + ) + ); + + if (!selectedGpuModel) return; + + const matchedSpec = + getSpecFromModel(selectedGpuModel, selectedSpecType, selectedSpecValue) || + (selectedPodConfig + ? selectedGpuModel.specs.find((spec) => isSamePodConfig(spec.podConfig, selectedPodConfig)) + : undefined); - const fallback = gpuOptions.find((item) => item.annotationType === selectedGpuType); - setSelectedGpuOptionKey(fallback?.optionKey || 'none'); - }, [gpuOptions, selectedGpuOptionKey, selectedGpuType]); + if (!matchedSpec) return; + + const shouldSync = + selectedModel !== selectedGpuModel.model || + selectedSpecType !== matchedSpec.type || + selectedSpecValue !== matchedSpec.value || + selectedSpecMemory !== matchedSpec.memory || + selectedSpecStock !== matchedSpec.stock || + !isSamePodConfig(selectedPodConfig, matchedSpec.podConfig); + + if (!shouldSync) return; + + upsertGpuValue({ + model: selectedGpuModel, + spec: matchedSpec, + keepAmount: true, + shouldDirty: false + }); + }, [ + gpuInventory, + selectedModel, + selectedType, + selectedSpecType, + selectedSpecValue, + selectedSpecMemory, + selectedSpecStock, + selectedPodConfig, + upsertGpuValue + ]); + + useEffect(() => { + if (!selectedSpecItem || selectedSpecItem.type !== 'GPU') { + if (selectedAmount !== 1 && selectedAmount !== undefined) { + setValue('gpu.amount', 1, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false + }); + } + return; + } + + const clamped = clampAmount(selectedAmount, 1, Math.max(selectedSpecItem.stock, 1)); + if (clamped !== selectedAmount) { + setValue('gpu.amount', clamped, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false + }); + } + }, [selectedAmount, selectedSpecItem, setValue]); const handleGpuIconError = (event: SyntheticEvent) => { const target = event.currentTarget; @@ -131,163 +234,155 @@ export default function Gpu({ } }; - if (!sourcePrice?.gpu) { + const showGpuAmountControl = selectedSpecItem?.type === 'GPU'; + + if (gpuInventory.length === 0) { return null; } return ( -
- -
- { + if (value === 'none') { + setValue('gpu', undefined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + }); + return; } - } - }} - > - - - {selectedGpu ? ( -
-
- {selectedGpu.annotationType} - - {getGpuDisplayName(selectedGpu)} - + + const model = gpuInventory.find((item) => item.model === value); + if (!model) return; + + const firstAvailableSpec = model.specs.find((spec) => spec.stock > 0) || model.specs[0]; + if (!firstAvailableSpec) return; + + upsertGpuValue({ + model, + spec: firstAvailableSpec, + keepAmount: false, + shouldDirty: true + }); + }} + > + + + {selectedModelItem ? ( +
+
+ {selectedModelItem.model} + {selectedModelDisplayName} +
- - {getGpuNodeDisplayName(selectedGpu)} - -
- - {t('video_memory')}: {Math.round(selectedGpu.vm)}GB - -
- - {t('Inventory')}:{' '} - - {selectedGpuInventory}/{selectedGpuTotalCount} - - -
- ) : ( - t('No GPU') - )} - - - - {t('No GPU')} - {gpuOptions.map((item) => { - const available = countGpuInventory(item.annotationType); - const total = gpuTotalCountMap[item.annotationType] || item.count; - return ( - + ) : ( + t('No GPU') + )} + + + + {t('No GPU')} + {gpuInventory.map((item) => ( +
-
+
{item.annotationType} - {getGpuDisplayName(item)} + {getModelDisplayName(item, locale)}
- - {getGpuNodeDisplayName(item)} - -
- - {t('video_memory')}: {Math.round(item.vm)}GB - -
- - {t('Inventory')}:{' '} - - {available}/{total} - -
- ); - })} - - - - {selectedGpuType && selectedGpuType !== 'none' && ( -
- -
- {gpuAmountOptions.map((item) => { - const available = selectedGpuType ? countGpuInventory(selectedGpuType) : 0; - const isLegacyOption = item.value > maxGpuAmount; - const isSelectable = item.value <= available && !isLegacyOption; - const isSelected = selectedGpuAmount === item.value; - - return ( + ))} + + +
+ + {selectedModelItem && ( +
+
+ + +
+ + {showGpuAmountControl && ( +
+ +
+ +
+ {selectedAmount || 1} +
- ); - })} -
- {isLegacyGpuAmount && ( - - {t('Gpu amount over max Tip', { max: maxGpuAmount })} - +
+
)}
)} diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/PriceBox.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/PriceBox.tsx index da1726a62ae7..bcbff1d9f8c5 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/PriceBox.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/PriceBox.tsx @@ -4,8 +4,10 @@ import { useTranslations } from 'next-intl'; import { CircuitBoard, Cpu, HardDrive, HdmiPort, MemoryStick } from 'lucide-react'; import { cn } from '@sealos/shadcn-ui'; +import { gpuTypeAnnotationKey } from '@/constants/devbox'; import { useEnvStore } from '@/stores/env'; import { usePriceStore } from '@/stores/price'; +import type { DevboxEditTypeV2 } from '@/types/devbox'; import { Card, CardContent, CardHeader } from '@sealos/shadcn-ui/card'; @@ -21,14 +23,56 @@ interface PriceBoxProps { cpu: number; memory: number; pvcStorage?: number; - gpu?: { - type: string; - amount: number; - }; + gpu?: DevboxEditTypeV2['gpu']; }[]; className?: string; } +const normalizeText = (value?: string) => value?.trim().replace(/\s+/g, '').toLowerCase() || ''; + +const parseGpuFractionFromValue = (value?: string) => { + if (!value) return 1; + if (value === 'full') return 1; + const matched = value.match(/^(\d+)\/(\d+)$/); + if (!matched) return 1; + + const numerator = Number(matched[1]); + const denominator = Number(matched[2]); + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) return 1; + return numerator / denominator; +}; + +const getGpuSliceMultiplier = (gpu?: DevboxEditTypeV2['gpu']) => { + if (!gpu) return 1; + + const limits = gpu.podConfig?.resources?.limits || {}; + const memoryPercentage = Number(limits['nvidia.com/gpumem-percentage'] || 0); + if (Number.isFinite(memoryPercentage) && memoryPercentage > 0) { + return memoryPercentage / 100; + } + + const coresPercentage = Number(limits['nvidia.com/gpucores'] || 0); + if (Number.isFinite(coresPercentage) && coresPercentage > 0) { + return coresPercentage / 100; + } + + return parseGpuFractionFromValue(gpu.specValue); +}; + +const getGpuCardCount = (gpu?: DevboxEditTypeV2['gpu']) => { + if (!gpu) return 0; + if (gpu.specType !== 'GPU') return 1; + return Math.max(gpu.amount || 1, 1); +}; + +const getGpuPriceMatchKeys = (gpu?: DevboxEditTypeV2['gpu']) => { + if (!gpu) return []; + const annotationType = gpu.podConfig?.annotations?.[gpuTypeAnnotationKey]; + return [gpu.type, gpu.model, annotationType] + .map((item) => normalizeText(item)) + .filter((item, index, arr) => item && arr.indexOf(item) === index); +}; + const PriceBox = ({ components = [], className }: PriceBoxProps) => { const t = useTranslations(); const { sourcePrice } = usePriceStore(); @@ -53,10 +97,24 @@ const PriceBox = ({ components = [], className }: PriceBoxProps) => { pp = sourcePrice.nodeports * 1 * 24; gp = (() => { - if (!gpu || !gpu.amount) return 0; - const item = sourcePrice?.gpu?.find((item) => item.annotationType === gpu.type); + if (!gpu) return 0; + const keys = getGpuPriceMatchKeys(gpu); + const item = sourcePrice?.gpu?.find((priceItem) => { + const priceKeys = [ + priceItem.annotationType, + priceItem.model, + priceItem.name?.zh, + priceItem.name?.en + ] + .map((entry) => normalizeText(entry)) + .filter(Boolean); + return keys.some((key) => priceKeys.includes(key)); + }); if (!item) return 0; - return +(item.price * gpu.amount * 24); + + const cardCount = getGpuCardCount(gpu); + const sliceMultiplier = getGpuSliceMultiplier(gpu); + return +(item.price * cardCount * sliceMultiplier * 24); })(); tp = cp + mp + sp + pp + gp; diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx index 9d61187e5cc8..d00b9484a5af 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx @@ -17,7 +17,9 @@ import { useConfirm } from '@/hooks/useConfirm'; import { generateYamlList } from '@/utils/json2Yaml'; import { createDevbox, updateDevbox } from '@/api/devbox'; import type { DevboxEditTypeV2, DevboxKindsType } from '@/types/devbox'; -import { defaultDevboxEditValueV2, editModeMap, GpuAmountMarkList } from '@/constants/devbox'; +import type { GpuInventoryModel, GpuInventorySpec, GpuPodConfig } from '@/types/gpu'; +import type { SourcePrice } from '@/types/static'; +import { defaultDevboxEditValueV2, editModeMap, gpuTypeAnnotationKey } from '@/constants/devbox'; import { useEnvStore } from '@/stores/env'; import { useIDEStore } from '@/stores/ide'; @@ -34,6 +36,91 @@ import { track } from '@sealos/gtm'; import { listTemplate } from '@/api/template'; import { z } from 'zod'; +const normalizeText = (value?: string) => value?.trim().replace(/\s+/g, '').toLowerCase() || ''; +type GpuPriceItem = NonNullable[number]; + +const isSameRecord = (lhs?: Record, rhs?: Record) => { + const left = lhs || {}; + const right = rhs || {}; + const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); + const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); + if (leftEntries.length !== rightEntries.length) return false; + return leftEntries.every( + ([key, value], index) => rightEntries[index]?.[0] === key && rightEntries[index]?.[1] === value + ); +}; + +const isSamePodConfig = (left?: GpuPodConfig, right?: GpuPodConfig) => + isSameRecord(left?.annotations, right?.annotations) && + isSameRecord(left?.resources?.limits, right?.resources?.limits); + +const buildGpuInventoryFromPrice = (gpuPriceList?: SourcePrice['gpu']): GpuInventoryModel[] => { + if (!gpuPriceList || gpuPriceList.length === 0) return []; + + const modelMap = new Map(); + + gpuPriceList.forEach((item: GpuPriceItem) => { + const model = item.model || item.annotationType; + if (!model) return; + + const specType = item.specType || 'GPU'; + const specValue = item.specValue || (specType === 'GPU' ? 'full' : ''); + const specMemory = item.specMemory || `${Math.max(item.vm || 0, 0)}GB`; + if (!specValue || !specMemory) return; + + const stock = Math.max( + Number(item.stock ?? item.available ?? item.count ?? 0), + 0 + ); + const podConfig = + item.podConfig || + (item.annotationType + ? { + annotations: { + [gpuTypeAnnotationKey]: item.annotationType + } + } + : undefined); + + const nextSpec: GpuInventorySpec = { + type: specType, + value: specValue, + memory: specMemory, + stock, + ...(podConfig ? { podConfig } : {}) + }; + + const existingModel = modelMap.get(model); + if (!existingModel) { + modelMap.set(model, { + model, + displayName: item.displayName || item.name, + icon: item.icon, + specs: [nextSpec] + }); + return; + } + + const existingSpecIndex = existingModel.specs.findIndex( + (spec) => spec.type === nextSpec.type && spec.value === nextSpec.value + ); + if (existingSpecIndex < 0) { + existingModel.specs.push(nextSpec); + return; + } + + const existingSpec = existingModel.specs[existingSpecIndex]; + existingModel.specs[existingSpecIndex] = { + ...existingSpec, + ...nextSpec, + stock: Math.max(existingSpec.stock || 0, nextSpec.stock || 0), + podConfig: existingSpec.podConfig || nextSpec.podConfig + }; + }); + + return Array.from(modelMap.values()).filter((item) => item.specs.length > 0); +}; + const DevboxCreatePage = () => { const router = useRouter(); const t = useTranslations(); @@ -47,7 +134,6 @@ const DevboxCreatePage = () => { const crOldYamls = useRef([]); const formOldYamls = useRef([]); - const oldDevboxEditData = useRef(); const [isLoading, setIsLoading] = useState(false); const [yamlList, setYamlList] = useState([]); @@ -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}
diff --git a/frontend/providers/devbox/message/en.json b/frontend/providers/devbox/message/en.json index e8948f7f8fe4..0afe976461be 100644 --- a/frontend/providers/devbox/message/en.json +++ b/frontend/providers/devbox/message/en.json @@ -15,6 +15,9 @@ "Gpu amount over max Tip": "GPU quantity currently supports up to {max}. Please adjust before saving", "Input your custom domain": "Enter your custom domain", "Inventory": "Stock", + "model": "Model", + "specification": "Spec", + "gpu_spec_invalid": "GPU spec is invalid. Please reselect and try again.", "gpu_node": "Node", "Network Settings": "Network Settings", "No GPU": "No GPU", diff --git a/frontend/providers/devbox/message/zh.json b/frontend/providers/devbox/message/zh.json index 1fe92f79f06e..c08df4c5619f 100644 --- a/frontend/providers/devbox/message/zh.json +++ b/frontend/providers/devbox/message/zh.json @@ -15,6 +15,9 @@ "Gpu amount over max Tip": "当前 GPU 数量最多支持 {max},请调整后再保存", "Input your custom domain": "输入您的自定义域名", "Inventory": "库存", + "model": "型号", + "specification": "规格", + "gpu_spec_invalid": "GPU 规格无效,请重新选择后再保存", "gpu_node": "节点", "Network Settings": "网络配置", "No GPU": "不使用GPU", diff --git a/frontend/providers/devbox/services/backend/gpu.ts b/frontend/providers/devbox/services/backend/gpu.ts index c8dda2ef5373..8559852251a3 100644 --- a/frontend/providers/devbox/services/backend/gpu.ts +++ b/frontend/providers/devbox/services/backend/gpu.ts @@ -5,6 +5,50 @@ import type { GpuAliasMap } from '@/types/gpu'; const GPU_CONFIGMAP_NAME = 'node-gpu-info'; const GPU_CONFIGMAP_NAMESPACE = 'node-system'; +const GPU_FEATURE_CONFIGMAP_NAME = 'gpu-config'; +const GPU_FEATURE_CONFIGMAP_NAMESPACE = 'sealos-system'; + +const hasConfigMapGpuData = (raw?: string) => { + if (!raw) return false; + const value = raw.trim(); + if (!value) return false; + + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.length > 0; + } + if (parsed && typeof parsed === 'object') { + return Object.keys(parsed).length > 0; + } + return Boolean(parsed); + } catch (_error) { + // Treat non-json non-empty string as enabled config. + return true; + } +}; + +export async function hasGpuInventoryConfig(): Promise { + try { + const kc = K8sApiDefault(); + const api = kc.makeApiClient(CoreV1Api); + + const { body } = await api.listNamespacedConfigMap( + GPU_FEATURE_CONFIGMAP_NAMESPACE, + undefined, + undefined, + undefined, + `metadata.name=${GPU_FEATURE_CONFIGMAP_NAME}` + ); + + if (!body.items || body.items.length === 0) return false; + const gpuRaw = body.items[0].data?.gpu; + return hasConfigMapGpuData(gpuRaw); + } catch (error) { + console.log('hasGpuInventoryConfig error', error); + return false; + } +} export async function getGpuAliasMap(): Promise { try { diff --git a/frontend/providers/devbox/services/request.ts b/frontend/providers/devbox/services/request.ts index 385fafab4d1e..78e11d0a2103 100644 --- a/frontend/providers/devbox/services/request.ts +++ b/frontend/providers/devbox/services/request.ts @@ -73,7 +73,9 @@ request.interceptors.request.use( const devboxToken = getSessionFromSessionStorage(); _headers['Authorization'] = encodeURIComponent(session?.kubeconfig || ''); - _headers['Authorization-Bearer'] = encodeURIComponent(devboxToken || session?.token || ''); + if (!_headers['Authorization-Bearer']) { + _headers['Authorization-Bearer'] = encodeURIComponent(devboxToken || session?.token || ''); + } if (!config.headers || config.headers['Content-Type'] === '') { _headers['Content-Type'] = 'application/json'; } diff --git a/frontend/providers/devbox/types/devbox.d.ts b/frontend/providers/devbox/types/devbox.d.ts index 54e0ab635750..12ce1421f571 100644 --- a/frontend/providers/devbox/types/devbox.d.ts +++ b/frontend/providers/devbox/types/devbox.d.ts @@ -20,6 +20,7 @@ import { } from '@/constants/devbox'; import { PortInfos } from './ingress'; import { MonitorDataResult } from './monitor'; +import type { GpuPodConfig, GpuSpecType } from './gpu'; export type DevboxStatusValueType = `${DevboxStatusEnum}`; export type DevboxReleaseStatusValueType = `${DevboxReleaseStatusEnum}`; @@ -32,6 +33,12 @@ export type GpuType = { manufacturers: string; type: string; amount: number; + model?: string; + specType?: GpuSpecType; + specValue?: string; + specMemory?: string; + stock?: number; + podConfig?: GpuPodConfig; resource?: Record; }; export type DevboxToleration = { diff --git a/frontend/providers/devbox/types/gpu.d.ts b/frontend/providers/devbox/types/gpu.d.ts index 22706b4945cd..35be253d3a10 100644 --- a/frontend/providers/devbox/types/gpu.d.ts +++ b/frontend/providers/devbox/types/gpu.d.ts @@ -11,3 +11,32 @@ export type GpuAliasConfig = { }; export type GpuAliasMap = Record; + +export type GpuPodConfig = { + annotations?: Record; + resources?: { + limits?: Record; + }; +}; + +export type GpuSpecType = 'GPU' | 'vGPU' | string; + +export type GpuInventorySpec = { + type: GpuSpecType; + memory: string; + value: string; + stock: number; + podConfig?: GpuPodConfig; +}; + +export type GpuInventoryModel = { + model: string; + displayName?: GpuAliasName; + icon?: string; + specs: GpuInventorySpec[]; +}; + +export type GpuInventoryData = { + updatedAt: string; + data: GpuInventoryModel[]; +}; diff --git a/frontend/providers/devbox/types/static.d.ts b/frontend/providers/devbox/types/static.d.ts index 7faee717d215..0213f3305d4c 100644 --- a/frontend/providers/devbox/types/static.d.ts +++ b/frontend/providers/devbox/types/static.d.ts @@ -4,11 +4,26 @@ export interface SourcePrice { storage: number; nodeports: number; gpu?: { + model?: string; + displayName?: { + zh?: string; + en?: string; + }; + specType?: string; + specValue?: string; + specMemory?: string; + stock?: number; + podConfig?: { + annotations?: Record; + resources?: { + limits?: Record; + }; + }; annotationType: string; price: number; - available: number; - count: number; - vm: number; + available?: number; + count?: number; + vm?: number; icon?: string; name?: { zh?: string; diff --git a/frontend/providers/devbox/types/user.d.ts b/frontend/providers/devbox/types/user.d.ts index 9f1257e274ee..cbb232bd629d 100644 --- a/frontend/providers/devbox/types/user.d.ts +++ b/frontend/providers/devbox/types/user.d.ts @@ -1,3 +1,5 @@ +import type { GpuPodConfig, GpuSpecType } from './gpu'; + export type OAuthToken = { readonly access_token: string; readonly token_type: string; @@ -27,11 +29,21 @@ export type userPriceType = { storage: number; nodeports: number; gpu?: { + model?: string; + displayName?: { + zh?: string; + en?: string; + }; + specType?: GpuSpecType; + specValue?: string; + specMemory?: string; + stock?: number; + podConfig?: GpuPodConfig; annotationType: string; price: number; - available: number; - count: number; - vm: number; + available?: number; + count?: number; + vm?: number; icon?: string; name?: { zh?: string; @@ -51,6 +63,12 @@ export type GpuType = { manufacturers: string; type: string; amount: number; + model?: string; + specType?: GpuSpecType; + specValue?: string; + specMemory?: string; + stock?: number; + podConfig?: GpuPodConfig; resource?: Record; }; diff --git a/frontend/providers/devbox/utils/adapt.ts b/frontend/providers/devbox/utils/adapt.ts index 189e7f5ea383..30ed86e8556f 100644 --- a/frontend/providers/devbox/utils/adapt.ts +++ b/frontend/providers/devbox/utils/adapt.ts @@ -20,7 +20,7 @@ import { IngressListItemType } from '@/types/ingress'; import { V1Deployment, V1Ingress, V1Pod, V1StatefulSet } from '@kubernetes/client-node'; import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s'; -import type { GpuAliasMap } from '@/types/gpu'; +import type { GpuAliasMap, GpuPodConfig } from '@/types/gpu'; import { calculateUptime, cpuFormatToM, @@ -47,6 +47,76 @@ const getGpuResourceInfo = ( return { amount, resource: matchedAlias?.resource }; }; +const getGpuLimitsFromResource = (resource: Record | undefined) => { + if (!resource) return {}; + return Object.entries(resource).reduce>((acc, [key, value]) => { + if (!/gpu/i.test(key)) return acc; + if (value === undefined || value === null) return acc; + acc[key] = String(value); + return acc; + }, {}); +}; + +const getGpuAmountFromLimits = (limits: Record, fallback: number) => { + const amount = Number(limits['nvidia.com/gpu'] || 0); + if (Number.isFinite(amount) && amount > 0) { + return amount; + } + return fallback; +}; + +const inferGpuSpecType = (limits: Record) => { + const gpuMemoryPercentage = Number(limits['nvidia.com/gpumem-percentage'] || 0); + const gpuCoresPercentage = Number(limits['nvidia.com/gpucores'] || 0); + if ( + (Number.isFinite(gpuMemoryPercentage) && gpuMemoryPercentage > 0 && gpuMemoryPercentage < 100) || + (Number.isFinite(gpuCoresPercentage) && gpuCoresPercentage > 0 && gpuCoresPercentage < 100) + ) { + return 'vGPU'; + } + return Object.keys(limits).length > 0 ? 'GPU' : undefined; +}; + +const inferGpuSpecValue = (limits: Record, specType?: string) => { + if (!specType) return undefined; + if (specType === 'GPU') return 'full'; + + const gpuMemoryPercentage = Number(limits['nvidia.com/gpumem-percentage'] || 0); + const gpuCoresPercentage = Number(limits['nvidia.com/gpucores'] || 0); + const percentage = + Number.isFinite(gpuMemoryPercentage) && gpuMemoryPercentage > 0 + ? gpuMemoryPercentage + : gpuCoresPercentage; + if (!Number.isFinite(percentage) || percentage <= 0) return undefined; + + const denominator = Math.round(100 / percentage); + if (!Number.isFinite(denominator) || denominator <= 0) return undefined; + return `1/${denominator}`; +}; + +const buildGpuPodConfig = ( + resource: Record | undefined, + annotations: Record | undefined +): GpuPodConfig | undefined => { + const limits = getGpuLimitsFromResource(resource); + const nextAnnotations = annotations && Object.keys(annotations).length > 0 ? annotations : undefined; + + if (Object.keys(limits).length === 0 && !nextAnnotations) { + return undefined; + } + + return { + ...(nextAnnotations ? { annotations: nextAnnotations } : {}), + ...(Object.keys(limits).length > 0 + ? { + resources: { + limits + } + } + : {}) + }; +}; + export const adaptDevboxListItemV2 = ( [devbox, template]: [ KBDevboxTypeV2, @@ -66,6 +136,19 @@ export const adaptDevboxListItemV2 = ( gpuType, gpuAliasMap ); + const gpuAnnotations = gpuType + ? { + [gpuTypeAnnotationKey]: gpuType + } + : undefined; + const gpuPodConfig = buildGpuPodConfig( + devbox.spec.resource as Record, + gpuAnnotations + ); + const gpuLimits = gpuPodConfig?.resources?.limits || {}; + const specType = inferGpuSpecType(gpuLimits); + const specValue = inferGpuSpecValue(gpuLimits, specType); + const amount = getGpuAmountFromLimits(gpuLimits, Number(gpuAmount || 0)); return { id: devbox.metadata?.uid || ``, @@ -82,9 +165,13 @@ export const adaptDevboxListItemV2 = ( memory: memoryFormatToMi(devbox.spec.resource.memory), gpu: gpuType ? { + model: gpuType, type: gpuType, - amount: Number(gpuAmount || 0), + amount: Number(amount || 0), manufacturers: 'nvidia', + specType, + specValue, + podConfig: gpuPodConfig, resource: gpuResource } : undefined, @@ -228,10 +315,25 @@ export const adaptDevboxDetailV2 = ( if (!gpuType) { return undefined; } + const gpuAnnotations = { + [gpuTypeAnnotationKey]: gpuType + }; + const gpuPodConfig = buildGpuPodConfig( + devbox.spec.resource as Record, + gpuAnnotations + ); + const gpuLimits = gpuPodConfig?.resources?.limits || {}; + const specType = inferGpuSpecType(gpuLimits); + const specValue = inferGpuSpecValue(gpuLimits, specType); + const amount = getGpuAmountFromLimits(gpuLimits, Number(gpuAmount || 0)); return { + model: gpuType, type: gpuType, - amount: Number(gpuAmount || 0), + amount: Number(amount || 0), manufacturers: 'nvidia', + specType, + specValue, + podConfig: gpuPodConfig, resource: gpuResource }; })(), diff --git a/frontend/providers/devbox/utils/json2Yaml.ts b/frontend/providers/devbox/utils/json2Yaml.ts index de79994a1a2d..0d5eb5829a0e 100644 --- a/frontend/providers/devbox/utils/json2Yaml.ts +++ b/frontend/providers/devbox/utils/json2Yaml.ts @@ -9,6 +9,84 @@ import { RuntimeNamespaceMap } from '@/types/static'; const GPU_CORES_DEFAULT = 100; +const isPureNumberString = (value: string) => /^-?\d+(\.\d+)?$/.test(value); + +const scaleGpuLimitValue = (resourceKey: string, resourceValue: string, amount: number) => { + if (amount <= 1) return resourceValue; + if (!isPureNumberString(resourceValue)) return resourceValue; + if (resourceKey.toLowerCase().includes('percentage')) return resourceValue; + return String(Number(resourceValue) * amount); +}; + +const getGpuPodConfig = (gpu?: DevboxEditTypeV2['gpu'] | DevboxEditType['gpu']) => { + if (!gpu?.podConfig) return undefined; + const annotations = gpu.podConfig.annotations || {}; + const limits = gpu.podConfig.resources?.limits || {}; + const amount = gpu.specType === 'GPU' ? Math.max(1, gpu.amount || 1) : 1; + + const normalizedAnnotations = Object.entries(annotations).reduce>( + (acc, [key, value]) => { + if (!key || value == null) return acc; + acc[key] = String(value); + return acc; + }, + {} + ); + + const normalizedLimits = Object.entries(limits).reduce>((acc, [key, value]) => { + if (!key || value == null) return acc; + const stringValue = String(value); + acc[key] = scaleGpuLimitValue(key, stringValue, amount); + return acc; + }, {}); + + if (Object.keys(normalizedAnnotations).length === 0 && Object.keys(normalizedLimits).length === 0) { + return undefined; + } + + return { + ...(Object.keys(normalizedAnnotations).length > 0 ? { annotations: normalizedAnnotations } : {}), + ...(Object.keys(normalizedLimits).length > 0 + ? { + resources: { + limits: normalizedLimits + } + } + : {}) + }; +}; + +const getLegacyGpuConfig = (gpu?: DevboxEditTypeV2['gpu'] | DevboxEditType['gpu']) => { + const gpuResourceKeyValue = gpu?.resource?.card; + const gpuCoresResourceKeyValue = gpu?.resource?.cores; + const hasGpu = !!gpu?.type && !!gpuResourceKeyValue; + const hasGpuCores = hasGpu && !!gpuCoresResourceKeyValue; + + return { + annotations: hasGpu + ? { + [gpuTypeAnnotationKey]: gpu?.type || '' + } + : undefined, + limits: { + ...(hasGpu ? { [gpuResourceKeyValue as string]: String(gpu?.amount || 0) } : {}), + ...(hasGpuCores ? { [gpuCoresResourceKeyValue as string]: String(GPU_CORES_DEFAULT) } : {}) + } + }; +}; + +const getGpuConfig = (gpu?: DevboxEditTypeV2['gpu'] | DevboxEditType['gpu']) => { + const podConfig = getGpuPodConfig(gpu); + if (podConfig) { + return { + annotations: podConfig.annotations, + limits: podConfig.resources?.limits || {} + }; + } + + return getLegacyGpuConfig(gpu); +}; + export const json2Devbox = ( data: DevboxEditType, runtimeNamespaceMap: RuntimeNamespaceMap, @@ -17,15 +95,9 @@ export const json2Devbox = ( ) => { // runtimeNamespace inject const runtimeNamespace = runtimeNamespaceMap[data.runtimeVersion]; - const gpuResourceKeyValue = data.gpu?.resource?.card; - const gpuCoresResourceKeyValue = data.gpu?.resource?.cores; - const hasGpu = !!data.gpu?.type && !!gpuResourceKeyValue; - const hasGpuCores = hasGpu && !!gpuCoresResourceKeyValue; - const gpuConfigAnnotation = hasGpu - ? { - [gpuTypeAnnotationKey]: data.gpu?.type || '' - } - : undefined; + const gpuConfig = getGpuConfig(data.gpu); + const gpuLimits = gpuConfig.limits || {}; + const gpuConfigAnnotation = gpuConfig.annotations; let json: any = { apiVersion: 'devbox.sealos.io/v1alpha1', kind: 'Devbox', @@ -43,8 +115,7 @@ export const json2Devbox = ( resource: { cpu: `${str2Num(Math.floor(data.cpu))}m`, memory: `${str2Num(data.memory)}Mi`, - ...(hasGpu ? { [gpuResourceKeyValue]: data.gpu?.amount || 0 } : {}), - ...(hasGpuCores ? { [gpuCoresResourceKeyValue]: GPU_CORES_DEFAULT } : {}) + ...gpuLimits }, runtimeRef: { name: data.runtimeVersion, @@ -92,15 +163,9 @@ export const json2DevboxV2 = ( devboxAffinityEnable: string = 'true', squashEnable: string = 'false' ) => { - const gpuResourceKeyValue = data.gpu?.resource?.card; - const gpuCoresResourceKeyValue = data.gpu?.resource?.cores; - const hasGpu = !!data.gpu?.type && !!gpuResourceKeyValue; - const hasGpuCores = hasGpu && !!gpuCoresResourceKeyValue; - const gpuConfigAnnotation = hasGpu - ? { - [gpuTypeAnnotationKey]: data.gpu?.type || '' - } - : undefined; + const gpuConfig = getGpuConfig(data.gpu); + const gpuLimits = gpuConfig.limits || {}; + const gpuConfigAnnotation = gpuConfig.annotations; let json: any = { apiVersion: 'devbox.sealos.io/v1alpha1', @@ -120,8 +185,7 @@ export const json2DevboxV2 = ( cpu: `${str2Num(Math.floor(data.cpu))}m`, memory: `${str2Num(data.memory)}Mi`, 'ephemeral-storage': `${str2Num(data.storage)}Gi`, - ...(hasGpu ? { [gpuResourceKeyValue]: data.gpu?.amount || 0 } : {}), - ...(hasGpuCores ? { [gpuCoresResourceKeyValue]: GPU_CORES_DEFAULT } : {}) + ...gpuLimits }, templateID: data.templateUid, image: data.image, @@ -138,10 +202,11 @@ export const json2DevboxV2 = ( ...(draftAny.annotations || {}), ...gpuConfigAnnotation }; - } else if (draftAny.annotations?.[gpuTypeAnnotationKey]) { - // Remove GPU annotation when GPU is not used - delete draftAny.annotations[gpuTypeAnnotationKey]; - // If annotations becomes empty, remove it completely + } else if (draftAny.annotations) { + // Remove legacy GPU annotation when GPU is not used. + if (draftAny.annotations[gpuTypeAnnotationKey]) { + delete draftAny.annotations[gpuTypeAnnotationKey]; + } if (Object.keys(draftAny.annotations).length === 0) { delete draftAny.annotations; }