diff --git a/.claude/skills/form-patterns/SKILL.md b/.claude/skills/form-patterns/SKILL.md index 8b563c13..1d53e610 100644 --- a/.claude/skills/form-patterns/SKILL.md +++ b/.claude/skills/form-patterns/SKILL.md @@ -97,6 +97,38 @@ const handleBChange = (b) => { }; ``` +## 4. Controlled input with derived fields + +When a controlled field's value comes from **both** user input and a programmatic default (e.g. a percentage picked on a slider, and a default seeded on select / mode-switch), funnel both through **one commit function** — don't duplicate "write field + recompute derived" per call site. + +- The `Form.Item`-bound input's `onChange(value)` forwards the value to the commit fn (the field is antd-bound, but pass the value explicitly so the default path can reuse the same fn instead of reading the store). +- Seed defaults by calling the **same** commit fn with the computed value. +- Separate the **commit action** (write field + recompute dependents) from the **render-derive** (read the field → recompute dependents). Keeping the derive standalone lets it re-run on reload/edit where there's no user event. + +```ts +// commit action — slider onChange AND default both call this +const commitRatio = (value: number) => { + form.setFieldsValue({ spec: { resources: { ratio: value, cores: 100 } } }); + rescaleDerived(); // reads ratio from the form, sets the disabled cpu/ram +}; + +// render-derive — also called from the edit/reload effect +const rescaleDerived = () => { + const ratio = form.getFieldValue(['spec', 'resources', 'ratio']); + form.setFieldsValue({ + spec: { + resources: { + cpu: floorScale(unit.cpu, ratio), + ram: floorScale(unit.ram, ratio) + } + } + }); +}; + +// default seeding reuses the commit fn — one path, not a second copy +const applyDefaults = (item) => commitRatio(Math.min(10, item.maxRatio) || 10); +``` + ## Related - Module/file structure for forms lives in the **create-crud-page** skill (section 3). diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index 03f44159..9dc4508d 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -119,8 +119,17 @@ export default { 'No available GPU resources, please choose another instance type.', 'gpuservice.instance.gpuCount.zero': 'CPU-only setup for environment preparation.', + 'gpuservice.instance.mode.whole': 'Full GPU', + 'gpuservice.instance.mode.sliced': 'By Ratio', + 'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)', + 'gpuservice.instance.slice.fullCores': '100% Compute', + 'gpuservice.instance.slice.percentage.required': + 'Please select or enter a percentage', + 'gpuservice.instance.slice.percentage.max': + 'The ratio must be between 1% and {count}%', 'gpuservice.instance.stock': 'Stock', 'gpuservice.instance.sliced': 'Sliced', + 'gpuservice.instance.sliceable': 'Sliceable', 'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.os': 'OS', diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index 3b644512..0244308c 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -118,8 +118,17 @@ export default { 'gpuservice.instance.gpuCount.noAvailable': '利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。', 'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。', + 'gpuservice.instance.mode.whole': 'GPU 全体', + 'gpuservice.instance.mode.sliced': '比率で', + 'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%)', + 'gpuservice.instance.slice.fullCores': '100% コンピュート', + 'gpuservice.instance.slice.percentage.required': + 'パーセンテージを選択または入力してください', + 'gpuservice.instance.slice.percentage.max': + '比率は 1% から {count}% の間で指定してください', 'gpuservice.instance.stock': '在庫', 'gpuservice.instance.sliced': '分割', + 'gpuservice.instance.sliceable': '分割可能', 'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.os': 'OS', diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index cba61779..6b7707f7 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -117,8 +117,17 @@ export default { 'gpuservice.instance.gpuCount.noAvailable': 'Нет доступных ресурсов GPU, выберите другой тип экземпляра.', 'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.', + 'gpuservice.instance.mode.whole': 'Весь GPU', + 'gpuservice.instance.mode.sliced': 'По доле', + 'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)', + 'gpuservice.instance.slice.fullCores': '100% вычислений', + 'gpuservice.instance.slice.percentage.required': + 'Выберите или введите процент', + 'gpuservice.instance.slice.percentage.max': + 'Доля должна быть от 1% до {count}%', 'gpuservice.instance.stock': 'Остаток', 'gpuservice.instance.sliced': 'Разделено', + 'gpuservice.instance.sliceable': 'Делимый', 'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.os': 'ОС', diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index baaf1edb..520c609f 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -113,8 +113,17 @@ export default { 'gpuservice.instance.gpuCount.noAvailable': 'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.', 'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.', + 'gpuservice.instance.mode.whole': 'Tam GPU', + 'gpuservice.instance.mode.sliced': 'Orana Göre', + 'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)', + 'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü', + 'gpuservice.instance.slice.percentage.required': + 'Lütfen bir yüzde seçin veya girin', + 'gpuservice.instance.slice.percentage.max': + 'Oran %1 ile %{count} arasında olmalıdır', 'gpuservice.instance.stock': 'Stok', 'gpuservice.instance.sliced': 'Bölünmüş', + 'gpuservice.instance.sliceable': 'Bölünebilir', 'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.os': 'OS', diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index 902e5d22..c8b965d0 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -108,8 +108,15 @@ export default { 'gpuservice.instance.gpuCount.noAvailable': '没有可用的 GPU 资源,请选择其他实例类型。', 'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。', + 'gpuservice.instance.mode.whole': '整卡', + 'gpuservice.instance.mode.sliced': '按比例', + 'gpuservice.instance.slice.memoryPercentage': '显存占比(%)', + 'gpuservice.instance.slice.fullCores': '100% 算力', + 'gpuservice.instance.slice.percentage.required': '请选择或输入百分比', + 'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间', 'gpuservice.instance.stock': '库存', 'gpuservice.instance.sliced': '切分', + 'gpuservice.instance.sliceable': '可切分', 'gpuservice.instance.memory': '显存', 'gpuservice.instance.ram': '内存', 'gpuservice.instance.os': '系统', diff --git a/src/pages/_components/number-selection/index.tsx b/src/pages/_components/number-selection/index.tsx index 573fe088..2f2ad843 100644 --- a/src/pages/_components/number-selection/index.tsx +++ b/src/pages/_components/number-selection/index.tsx @@ -23,6 +23,11 @@ interface NumberSelectionProps { labelExtra?: React.ReactNode; maxCount?: number; tips?: string; + // Explicit preset tick values (e.g. [10,20,...,100] for percentage slicing). + // Overrides the default 1..maxCount sequence. + presetValues?: number[]; + // Force the free-input box to show regardless of max/maxCount. + alwaysShowInput?: boolean; onChange?: (value: number) => void; } @@ -39,17 +44,18 @@ const NumberSelection: React.FC = ({ className, maxCount = 8, tips, + presetValues, + alwaysShowInput, style, onChange }) => { const intl = useIntl(); - const showCustomInput = max > maxCount; - const presetItems = Array.from( - { length: Math.max(0, maxCount) }, - (_, i) => i + 1 - ); - if (min <= 0) { + const showCustomInput = alwaysShowInput || max > maxCount; + const presetItems = + presetValues ?? + Array.from({ length: Math.max(0, maxCount) }, (_, i) => i + 1); + if (!presetValues && min <= 0) { presetItems.unshift(0); } const items = presetItems; diff --git a/src/pages/_components/number-selection/styles.less b/src/pages/_components/number-selection/styles.less index 1b60d54d..303baac1 100644 --- a/src/pages/_components/number-selection/styles.less +++ b/src/pages/_components/number-selection/styles.less @@ -39,9 +39,20 @@ display: flex; align-items: center; padding: 0 12px; + padding-right: 2px; color: var(--ant-color-text-tertiary); white-space: nowrap; padding-block: 6px; + width: 100%; + + // Spread the label and its labelExtra (e.g. the whole/sliced Segmented) + // to opposite ends of the row. + :global(.label-text) { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } } .contentWrapper { width: 100%; diff --git a/src/pages/gpu-service/instances/components/instance-type-item.tsx b/src/pages/gpu-service/instances/components/instance-type-item.tsx index f940716d..a0ea012e 100644 --- a/src/pages/gpu-service/instances/components/instance-type-item.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-item.tsx @@ -59,6 +59,9 @@ interface InstanceTypeItemProps { interface MetadataSectionProps { spec: InstanceTypeItemModel['spec']; + // status.onceMaxRequest.acceleratorSliced (max sliceable percentage). Shown + // next to Max for sliceable types. + slicedMaxPercentage?: number; } const MetaItem: React.FC<{ @@ -125,7 +128,8 @@ function getInstanceDerived(item: InstanceTypeItemModel) { } export const InstanceMetadataSection: React.FC = ({ - spec + spec, + slicedMaxPercentage }) => { const intl = useIntl(); @@ -133,6 +137,14 @@ export const InstanceMetadataSection: React.FC = ({ spec } as InstanceTypeItemModel); + // Sliceable types show "Max {n} · Sliceable {m}%" instead of just the count. + const showSliceable = !!spec.sliceable && (slicedMaxPercentage ?? 0) > 0; + const maxValue = showSliceable + ? `${spec.maxComputeUnitCount || 0} · ${intl.formatMessage({ + id: 'gpuservice.instance.sliceable' + })} ${slicedMaxPercentage}%` + : `${spec.maxComputeUnitCount || 0}`; + return ( {isGPU && ( @@ -159,7 +171,7 @@ export const InstanceMetadataSection: React.FC = ({ }, { count: '' } )} - value={`${spec.maxComputeUnitCount || 0}`} + value={maxValue} /> {/* row 2: OS | Arch | CPU */} = ({ item }) => { /> - + ); }; diff --git a/src/pages/gpu-service/instances/config/index.ts b/src/pages/gpu-service/instances/config/index.ts index 90f6dba5..d459c1cf 100644 --- a/src/pages/gpu-service/instances/config/index.ts +++ b/src/pages/gpu-service/instances/config/index.ts @@ -262,15 +262,15 @@ export const getAcceleratorMax = ( // Picks the candidate (cluster + type name) that should fulfill a requested // accelerator count: the first candidate of the smallest tier whose -// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage -// remaining are all > 0. +// onceMaxRequest.accelerator is >= the requested count. Accelerated types are +// not gated on CPU remaining (only CPU-only types are); in sliced mode the +// candidate's acceleratorSliced remaining must also be > 0. export const pickCandidateForAccelerator = < C extends { cluster: string; name: string; cpu?: { remaining?: string | null } | null; - ram?: { remaining?: string | null } | null; - localStorage?: { remaining?: string | null } | null; + acceleratorSliced?: { remaining?: string | null } | null; } >( tiers: @@ -280,14 +280,21 @@ export const pickCandidateForAccelerator = < }[] | undefined | null, - { count, acceleratable }: { count: number; acceleratable?: boolean } + { + count, + acceleratable, + sliced + }: { count: number; acceleratable?: boolean; sliced?: boolean } ): C | null => { if (!tiers?.length) return null; - const hasResources = (c: C) => - parseQuantity(c.cpu?.remaining) > 0 && - parseQuantity(c.ram?.remaining) > 0 && - parseQuantity(c.localStorage?.remaining) > 0; + const hasResources = (c: C) => { + // Accelerated types are not gated on CPU remaining; CPU-only types are. + if (!acceleratable && parseQuantity(c.cpu?.remaining) <= 0) return false; + if (sliced && parseQuantity(c.acceleratorSliced?.remaining) <= 0) + return false; + return true; + }; const sorted = [...tiers].sort( (a, b) => diff --git a/src/pages/gpu-service/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts index 0979cb47..e8b4d722 100644 --- a/src/pages/gpu-service/instances/config/types.ts +++ b/src/pages/gpu-service/instances/config/types.ts @@ -45,6 +45,11 @@ export interface FormData { ram: string | null | number; localStorage: string | null | number; accelerator: number | string | null; + // Sliced (percentage) mode only. Memory (VRAM) percentage bound to the + // 10-100 selector + free input; cores (compute) percentage bound to the + // "100% compute" checkbox (100 when checked, mirrors memory otherwise). + acceleratorSlicedMemoryPercentage?: number; + acceleratorSlicedCoresPercentage?: number; }; volume: { ephemeral?: { @@ -128,8 +133,10 @@ export interface InstanceTypeCandidate { name: string; accelerator: InstanceTypeResource; cpu: InstanceTypeResource; - ram: InstanceTypeResource; - localStorage: InstanceTypeResource; + // Shared-mode available resource (not shown in the GPU Instance form). + acceleratorShared: InstanceTypeResource; + // Sliced-mode available resource. + acceleratorSliced: InstanceTypeResource; } export interface InstanceTypeTierOnceMaxRequestResource { @@ -149,6 +156,8 @@ export interface InstanceTypeOnceMaxRequestResource { cpu: QuanityCPU; ram: QuanityMemory; localStorage: QuanityLocalStorage; + acceleratorShared: `${number}` | null; + acceleratorSliced: `${number}` | null; } export interface CPUCache { @@ -181,6 +190,8 @@ export interface InstanceTypeSpec { family?: string | null; computeCapability?: string | null; sliced?: string | null; + sliceable?: boolean; + localStorage?: QuanityLocalStorage; maxComputeUnitCount?: number; unitResources?: { cpu: QuanityCPU; diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index 9ce14b2f..25c63572 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -273,33 +273,94 @@ const GPUServiceInstanceForm: React.FC = forwardRef( const buildResourcesDataForSubmit = (values: FormData) => { const unitResourcesParsed = getUnitResources(); - const accelerator = _.toNumber(values.spec?.resources?.accelerator) || 0; - const cpuCount = _.toNumber(values.spec?.resources?.cpu) || 0; + const resources = values.spec?.resources ?? ({} as any); + const accelerator = _.toNumber(resources.accelerator) || 0; + const cpuCount = _.toNumber(resources.cpu) || 0; const cpuNum = unitResourcesParsed?.cpu?.num; const ramNum = unitResourcesParsed?.ram?.num; - const fallbackCpu = values.spec?.resources?.cpu; + const fallbackCpu = resources.cpu; - const factor = isGPUType ? accelerator : cpuCount; + // Sliced mode: scale a single card's unit resources by the chosen + // percentage (floored) — both CPU and RAM. Whole/CPU mode: multiply the + // unit by the count. + const percentage = _.toNumber( + resources.acceleratorSlicedMemoryPercentage + ); + const sliced = isGPUType && percentage > 0; + const wholeFactor = isGPUType ? accelerator : cpuCount; + + const scale = (num: number, unit: string) => + sliced + ? `${Math.max(1, _.floor((num * percentage) / 100))}${unit}` + : `${wholeFactor * num}${unit}`; return { cpu: cpuNum - ? `${factor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}` + ? scale(cpuNum, unitResourcesParsed?.cpu?.unit || '') : // Don't stringify an unset value — `${undefined}` becomes the // literal "undefined", which fails k8s quantity validation. fallbackCpu ? `${fallbackCpu}` : undefined, ram: ramNum - ? `${factor * ramNum}${unitResourcesParsed?.ram?.unit || ''}` - : values.spec?.resources?.ram + ? scale(ramNum, unitResourcesParsed?.ram?.unit || '') + : resources.ram }; }; + // Sliced display: set the (disabled) CPU / RAM inputs to a single card's + // unit resources scaled by the chosen percentage, floored. Reads the + // percentage straight from the form so it can be re-run after any slider + // change without threading values through. + const applySlicedResourceScaling = () => { + const unitResourcesParsed = getUnitResources(); + const cpuCores = unitResourcesParsed?.cpu?.cores; + const ramValue = unitResourcesParsed?.ram?.value; + const percentage = _.toNumber( + form.getFieldValue([ + 'spec', + 'resources', + 'acceleratorSlicedMemoryPercentage' + ]) + ); + + form.setFieldsValue({ + spec: { + resources: { + cpu: + cpuCores != null && percentage > 0 + ? Math.max(1, _.floor((cpuCores * percentage) / 100)) + : null, + ram: + ramValue != null && percentage > 0 + ? Math.max(1, _.floor((ramValue * percentage) / 100)) + : null + } + } + } as any); + }; + + // Single entry point for the sliced memory ratio: write the ratio (compute + // stays pinned at 100%) and rescale CPU / RAM off it. Reused by the slider + // onChange and by the sliced-mode defaults so both share one path. + const applySliceMemoryPercentage = (value: number) => { + form.setFieldsValue({ + spec: { + resources: { + acceleratorSlicedMemoryPercentage: value, + acceleratorSlicedCoresPercentage: 100 + } + } + } as any); + applySlicedResourceScaling(); + }; + const resolveAndApply = ( instanceType: InstanceTypeItem | undefined, - count: number + count: number, + sliced?: boolean ) => { if (!instanceType) { setSelectedInstanceType(undefined); @@ -328,7 +389,8 @@ const GPUServiceInstanceForm: React.FC = forwardRef( instanceType.status?.tiers, { count, - acceleratable: instanceType.spec?.acceleratable + acceleratable: instanceType.spec?.acceleratable, + sliced } ); @@ -336,9 +398,12 @@ const GPUServiceInstanceForm: React.FC = forwardRef( setOnceMaxRequest({ cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores, - memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value, - localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest) - ?.value + // candidate no longer carries ram/localStorage: memory max comes from + // the type-level onceMaxRequest.ram (already parsed to a Gi number by + // the query hook), disk max from spec.localStorage (UI-only cap). + memory: _.toNumber(instanceType.status?.onceMaxRequest?.ram) || null, + localStorage: + parseQuantityToGi(instanceType.spec?.localStorage)?.value ?? null }); form.setFieldsValue({ @@ -354,8 +419,44 @@ const GPUServiceInstanceForm: React.FC = forwardRef( }); }; + // Whole-card (exclusive) vs sliced (percentage) mode. Only meaningful for + // sliceable accelerator types; derived (no persisted field) — on edit/ + // recreate it is inferred from acceleratorSlicedMemoryPercentage > 0. + const [sliceMode, setSliceMode] = useState<'whole' | 'sliced'>('whole'); + const handleAcceleratorChange = (count: number) => { - resolveAndApply(selectedInstanceType, count); + resolveAndApply(selectedInstanceType, count, false); + }; + + // Seed the sliced-mode default ratio for an instance type: 10% but never + // above the type's max sliceable ratio (status.onceMaxRequest + // .acceleratorSliced). Shares applySliceMemoryPercentage with the slider. + const applySlicedDefaults = (instanceType?: InstanceTypeItem) => { + const slicedMax = + _.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) || + 0; + applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10); + }; + + // Toggle between whole-card and sliced mode. Sliced fixes the accelerator + // count to 1 (a single card is partitioned by percentage) and clears the + // slice-percentage fields when leaving sliced mode. + const handleSliceModeChange = (mode: 'whole' | 'sliced') => { + setSliceMode(mode); + if (mode === 'sliced') { + resolveAndApply(selectedInstanceType, 1, true); + applySlicedDefaults(selectedInstanceType); + } else { + form.setFieldsValue({ + spec: { + resources: { + acceleratorSlicedMemoryPercentage: undefined, + acceleratorSlicedCoresPercentage: undefined + } + } + } as any); + resolveAndApply(selectedInstanceType, 1, false); + } }; const onTargetChange = (key: string) => { @@ -407,6 +508,14 @@ const GPUServiceInstanceForm: React.FC = forwardRef( ? _.toNumber(currentData?.spec?.resources?.accelerator) : _.toNumber(currentData?.spec?.resources?.cpu) || 0; + // Infer the mode from the persisted slice percentage (recreate keeps + // the section editable; edit/view render a readonly card). + const persistedSliced = + _.toNumber( + currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage + ) > 0; + setSliceMode(persistedSliced ? 'sliced' : 'whole'); + form.setFieldsValue({ ...currentData, spec: { @@ -424,6 +533,12 @@ const GPUServiceInstanceForm: React.FC = forwardRef( enable_ssh: !!currentData?.spec?.sshPublicKeys?.length, storageMode: detectMode(currentData?.spec?.volume) }); + + // buildResourcesData above filled CPU / RAM for the whole card; rescale + // them off the persisted percentages when recreating a sliced instance. + if (persistedSliced) { + applySlicedResourceScaling(); + } } }, [action, currentData, form, open, realAction, instanceTypeList]); @@ -492,11 +607,30 @@ const GPUServiceInstanceForm: React.FC = forwardRef( getFieldsValue: () => form.getFieldsValue(), applyInstanceType: (instanceType?: InstanceTypeItem) => { if (!instanceType) { + setSliceMode('whole'); resolveAndApply(undefined, 0); return; } - // set default to 1, for all instance types: GPU or non-GPU + // A sliceable type with no whole-card capacity (Max < 1) defaults to + // sliced mode — whole mode would have nothing selectable. + const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0; + const slicedMax = + _.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) || + 0; + const defaultSliced = + !!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0; + + if (defaultSliced) { + setSliceMode('sliced'); + resolveAndApply(instanceType, 1, true); + applySlicedDefaults(instanceType); + return; + } + + // Otherwise default to whole-card mode (a new type may not be + // sliceable); set count to 1 for all instance types: GPU or non-GPU. + setSliceMode('whole'); resolveAndApply(instanceType, 1); } })); @@ -607,6 +741,9 @@ const GPUServiceInstanceForm: React.FC = forwardRef( currentData={currentData as any} onceMaxRequest={onceMaxRequest} noAvailableTypes={noAvailableInstanceTypes} + sliceMode={sliceMode} + onSliceModeChange={handleSliceModeChange} + onSliceMemoryPercentageChange={applySliceMemoryPercentage} onGPUCountChange={handleAcceleratorChange} /> ) diff --git a/src/pages/gpu-service/instances/forms/instance-type.tsx b/src/pages/gpu-service/instances/forms/instance-type.tsx index 163dbb58..12f7f570 100644 --- a/src/pages/gpu-service/instances/forms/instance-type.tsx +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -3,7 +3,7 @@ import { PageActionType } from '@/config/types'; import NumberSelection from '@/pages/_components/number-selection'; import { InputNumber } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { Flex, Form } from 'antd'; +import { Flex, Form, Segmented } from 'antd'; import _ from 'lodash'; import { useContext, useMemo } from 'react'; import styled from 'styled-components'; @@ -57,6 +57,9 @@ const InstanceTypePicker: React.FC = ({ ); }; +// Fixed 10-tick percentage scale (10..100) for the sliced (percentage) mode. +const SLICE_PERCENT_TICKS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + interface InstanceTypeFormItemProps { action: PageActionType; disabled?: boolean; @@ -67,6 +70,12 @@ interface InstanceTypeFormItemProps { // org owns no clusters. Surface a "no available" message instead of the // "please select" placeholder + empty CPU / memory inputs. noAvailableTypes?: boolean; + // Whole-card (exclusive) vs sliced (percentage) mode. Owned by the parent + // form (it drives candidate picking + the fixed accelerator=1 for sliced). + sliceMode?: 'whole' | 'sliced'; + onSliceModeChange?: (mode: 'whole' | 'sliced') => void; + // Commit a new sliced memory ratio (writes the field + rescales CPU / RAM). + onSliceMemoryPercentageChange?: (value: number) => void; onGPUCountChange?: (value: number) => void; } @@ -77,9 +86,13 @@ const InstanceTypeFormItem: React.FC = ({ selectedInstanceType, onceMaxRequest, noAvailableTypes, + sliceMode = 'whole', + onSliceModeChange, + onSliceMemoryPercentageChange, onGPUCountChange }) => { const intl = useIntl(); + const form = Form.useFormInstance(); const { isGPUType } = useContext(FormContext); const maxComputeUnitCount = useMemo(() => { @@ -104,6 +117,60 @@ const InstanceTypeFormItem: React.FC = ({ onGPUCountChange?.(value); }; + // Sliced mode is only offered for sliceable accelerator types, and only when + // the section is editable (create / recreate; edit renders a readonly card). + const showModeSwitch = + action !== PageAction.EDIT && + isGPUType && + !!selectedInstanceType?.spec?.sliceable; + + const handleModeChange = (value: string) => { + onSliceModeChange?.(value as 'whole' | 'sliced'); + }; + + // Memory (VRAM) percentage changed via the slider/input — forward the new + // value so the parent writes the field and rescales CPU / RAM. + const handleMemoryPercentageChange = (value: number) => { + onSliceMemoryPercentageChange?.(value); + }; + + const modeSegmented = showModeSwitch ? ( + + ) : null; + + const isSliced = showModeSwitch && sliceMode === 'sliced'; + + // Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced + // (a percentage). Ticks above it stay visible but disabled. + const slicedMaxPercentage = + _.toNumber( + selectedInstanceType?.status?.onceMaxRequest?.acceleratorSliced + ) || 0; + + // When the max ratio is below 10%, switch the ticks to a finer 1..10 scale + // so small slices are still selectable; otherwise use the 10..100 scale. + const sliceTicks = + slicedMaxPercentage < 10 + ? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + : SLICE_PERCENT_TICKS; + const renderMaxLabel = ( label: React.ReactNode, max?: number | null @@ -206,7 +273,7 @@ const InstanceTypeFormItem: React.FC = ({ : ['spec', 'resources', 'cpu'] } preserve - hidden={action === PageAction.EDIT} + hidden={action === PageAction.EDIT || isSliced} normalize={(value) => (value != null ? _.toString(value) : undefined)} getValueProps={(value) => ({ value: value != null ? _.toNumber(value) : undefined @@ -247,6 +314,7 @@ const InstanceTypeFormItem: React.FC = ({ max={maxComputeUnitCount} step={1} required + labelExtra={sliceMode === 'whole' ? modeSegmented : undefined} disabled={disabled || action === PageAction.EDIT} label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage( { @@ -257,6 +325,89 @@ const InstanceTypeFormItem: React.FC = ({ /> )} + {!noAvailableTypes && isSliced && ( + + + name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']} + getValueProps={(value) => ({ + value: value != null ? _.toNumber(value) : undefined + })} + rules={[ + { + required: true, + validator: (_, value) => { + const num = Number(value); + if (value == null || value === '' || Number.isNaN(num)) { + return Promise.reject( + new Error( + intl.formatMessage({ + id: 'gpuservice.instance.slice.percentage.required' + }) + ) + ); + } + if (num > slicedMaxPercentage || num <= 0) { + return Promise.reject( + new Error( + intl.formatMessage( + { + id: 'gpuservice.instance.slice.percentage.max' + }, + { count: slicedMaxPercentage } + ) + ) + ); + } + return Promise.resolve(); + } + } + ]} + > + + + {/* Compute (cores) percentage is fixed at 100. Kept in the form via a + hidden item so it rides along on submit. */} + + name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']} + hidden + > + + + + )} + {/* Edit renders a readonly card (no sliced UI), so register the slice + percentages as hidden fields — otherwise their persisted values are + dropped from the submit payload. */} + {action === PageAction.EDIT && ( + <> + + name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']} + hidden + > + + + + name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']} + hidden + > + + + + )} {!noAvailableTypes && (
@@ -281,11 +432,7 @@ const InstanceTypeFormItem: React.FC = ({ key="cpu_input" preserve > - +
)} diff --git a/src/pages/gpu-service/instances/services/use-query-instance-types.ts b/src/pages/gpu-service/instances/services/use-query-instance-types.ts index 04367365..093a85cd 100644 --- a/src/pages/gpu-service/instances/services/use-query-instance-types.ts +++ b/src/pages/gpu-service/instances/services/use-query-instance-types.ts @@ -36,6 +36,20 @@ export default function useQueryInstanceTypes() { } const max = getAcceleratorMax(item.status?.tiers); + + // Sliceable types stay selectable as long as either whole-card or sliced + // capacity remains; unavailable only when both status.onceMaxRequest + // .accelerator and .acceleratorSliced are 0. + if (item.spec?.sliceable) { + const wholeMax = Number(item.status?.onceMaxRequest?.accelerator) || 0; + const slicedMax = + Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0; + return { + maxComputeUnitCount: max || 0, + available: wholeMax > 0 || slicedMax > 0 + }; + } + return { maxComputeUnitCount: max || 0, available: (max || 0) > 0 diff --git a/src/pages/gpu-service/instances/utils/render-instance-type.tsx b/src/pages/gpu-service/instances/utils/render-instance-type.tsx index 7824d6a3..533d9b16 100644 --- a/src/pages/gpu-service/instances/utils/render-instance-type.tsx +++ b/src/pages/gpu-service/instances/utils/render-instance-type.tsx @@ -11,7 +11,7 @@ * with ``buildInstanceTypeRecordFromMiB`` and feed it here. */ import _ from 'lodash'; -import { parseJsonSafe } from '../../utils'; +import { parseJsonSafe, parseQuantityToGi } from '../../utils'; import InstanceTypeCell from '../components/instance-type-cell'; import { formatMemoryDisplay } from '../config'; import { InstanceTypeSpec, ListItem } from '../config/types'; @@ -49,6 +49,10 @@ const buildResourcesData = ( return {}; }; +// Memory (VRAM) percentage for a sliced instance; 0 when not sliced. +const getSliceMemoryPercentage = (record: ListItem) => + _.toNumber(record.spec?.resources?.acceleratorSlicedMemoryPercentage) || 0; + const formatResources = ( instanceTypeSpec: { spec: InstanceTypeSpec }, record: ListItem @@ -71,6 +75,34 @@ const formatResources = ( }; } + const sliceMemoryPercentage = getSliceMemoryPercentage(record); + + // Sliced: CPU / RAM carry the already-scaled values on spec.resources, and + // VRAM is the per-card memory scaled by the memory percentage (floored, + // min 1) — not the whole card's size. + if (sliceMemoryPercentage > 0) { + const vramGi = parseQuantityToGi( + (instanceTypeSpec.spec as any)?.memory + )?.value; + const vram = + vramGi != null + ? `${Math.max(1, _.floor((vramGi * sliceMemoryPercentage) / 100))} GB` + : undefined; + + return { + cpu: record.spec?.resources?.cpu + ? `${record.spec?.resources?.cpu} vCPU` + : '-', + ram: record.spec?.resources?.ram + ? toGB(record.spec?.resources?.ram) + : '-', + vram, + localStorage: record.spec?.resources?.localStorage + ? toGB(record.spec?.resources?.localStorage) + : undefined + }; + } + // VRAM = per-card GPU memory (a single card's size; not aggregated across // cards — the model's marquee spec). const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory); @@ -108,10 +140,14 @@ export const renderInstanceType = ( parseJsonSafe(record?.description || '{}', {}).spec || {}; const resources = formatResources({ spec: description }, record); const accelerator = record.spec?.resources?.accelerator; + const sliceMemoryPercentage = getSliceMemoryPercentage(record); + const isSliced = description.acceleratable && sliceMemoryPercentage > 0; const title = options.title ?? (description.acceleratable - ? `${description.product} x ${accelerator}` + ? isSliced + ? `${description.product} (${sliceMemoryPercentage}%)` + : `${description.product} x ${accelerator}` : 'CPU Only'); const volume = (record.spec as any)?.volume; @@ -131,10 +167,16 @@ export const renderInstanceType = ( icon: 'icon-gpu', name: 'GPU', rows: [ - [ - intl.formatMessage({ id: 'gpuservice.table.count' }), - accelerator ? `${accelerator}` : undefined - ], + // Sliced instances show the ratio instead of a card count (always 1). + isSliced + ? [ + intl.formatMessage({ id: 'gpuservice.instance.sliced' }), + `${sliceMemoryPercentage}%` + ] + : [ + intl.formatMessage({ id: 'gpuservice.table.count' }), + accelerator ? `${accelerator}` : undefined + ], [ intl.formatMessage({ id: 'gpuservice.instance.section.type' }), description.product