import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import { ceilMilliToCore, parseJsonSafe, parseQuantityToGi } from '@/pages/gpu-service/utils'; import { PlusOutlined } from '@ant-design/icons'; import { CheckboxField, Input as CInput, CollapsePanel, IconFont, MultipleSelect, ScrollSpyTabs, useAppUtils, useFinishFailed, useScrollActiveChange, useWrapperContext } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Button, Flex, Form } from 'antd'; import _ from 'lodash'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { FormData as PublicKeyFormData } from '../../public-keys/config/types'; import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys'; import { DefaultImagePullPolicy } from '../../templates/config'; import TemplateBasicForm, { BasicResourceMax } from '../../templates/forms/basic'; import { pickCandidateForAccelerator, StorageModeValueMap } from '../config'; import { FormContext } from '../config/form-context'; import { FormData, InstanceTypeItem, ListItem } from '../config/types'; import instanceStyles from '../styles/instances.module.less'; import Basic from './basic'; import InstanceTypeFormItem from './instance-type'; import PublicKeyOverlay from './public-key-overlay'; import StorageVolume from './storage-volume'; const SSH_PORT = 22; type InstanceFormValues = FormData & { // ssh holder enable_ssh?: boolean; }; interface InstanceFormProps { ref?: any; open: boolean; action: PageActionType; realAction?: PageActionType | string; currentData?: ListItem | null; namespace?: string; instanceTypeList?: InstanceTypeItem[]; // True when the (org-scoped) instance-type list is empty and not loading — // surfaces a "no available instance type" message in the scheduling tab. noAvailableInstanceTypes?: boolean; disabled?: boolean; // Fired when the create-scope picker retargets the form to another // org (or Global). Only emitted on genuine changes — never on the // initial mount, and never in builds where the picker isn't mounted // (the watched field stays undefined). Lets the parent re-scope the // tenant-scoped instance-type / template offerings. onScopeChange?: (orgId: number | null | undefined) => void; onFinish: (values: FormData) => Promise; onFinishFailed?: (errorInfo: any) => void; } const TABKeysMap = { BASIC: 'basic', INSTANCE_TYPE: 'instanceType', TEMPLATE: 'template', STORAGE: 'storage' }; const requiredFields = { [TABKeysMap.BASIC]: { sort: 1, fields: ['name'] }, [TABKeysMap.INSTANCE_TYPE]: { sort: 2, fields: ['spec.type', 'spec.resources.accelerator'] }, [TABKeysMap.TEMPLATE]: { sort: 3, fields: ['spec.image', 'spec.ports', 'spec.env'] }, [TABKeysMap.STORAGE]: { sort: 4, fields: [ 'spec.volume.persistent.name', 'spec.volume.ephemeral.capacity', 'spec.sshPublicKeys' ] } }; const GPUServiceInstanceForm: React.FC = forwardRef( (props, ref) => { const { action, realAction, currentData, disabled, open, instanceTypeList = [], noAvailableInstanceTypes, onScopeChange, onFinish, onFinishFailed } = props; const intl = useIntl(); const { getRuleMessage } = useAppUtils(); const [form] = Form.useForm(); const scrollTabsRef = useRef(null); const formAction = realAction === PageAction.CREATE ? PageAction.CREATE : action; const sshEnabled = Form.useWatch('enable_ssh', form); const description = Form.useWatch(['description'], form); // `organization_id` is owned by the create-scope picker slot; it only // exists/changes when a platform admin retargets the form. Watch it // so the parent can re-scope offerings (see onScopeChange). const scopeOrgId = Form.useWatch('organization_id', form); const ports = Form.useWatch(['spec', 'ports'], form) || []; const scopeInitRef = useRef(true); // Keep the latest callback in a ref so the scope-change effect can call it // without depending on its identity (parent may pass a new fn each render). const onScopeChangeRef = useRef(onScopeChange); onScopeChangeRef.current = onScopeChange; const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys(); const [sshOverlayOpen, setSshOverlayOpen] = useState(false); const { getScrollElementScrollableHeight } = useWrapperContext(); const { activeKey, collapseKeys, handleActiveChange, handleOnCollapseChange, updateActiveKey } = useScrollActiveChange({ initalActiveKeys: [TABKeysMap.BASIC], initialCollapseKeys: action === PageAction.EDIT ? [] : [TABKeysMap.INSTANCE_TYPE, TABKeysMap.TEMPLATE, TABKeysMap.STORAGE] }); const hasSSHPort = useMemo( () => ports.some( (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT ), [ports] ); const isGPUType = useMemo(() => { const spec = parseJsonSafe(description || '{}', {} as any)?.spec; return spec?.acceleratable; }, [description]); useEffect(() => { if (open) { const initSSHKeys = async () => { // await 200 ms await new Promise((resolve) => { setTimeout(resolve, 200); }); fetchSSHData({ page: -1 }); }; initSSHKeys(); } }, [open]); // Skip the first run (initial mount value); thereafter notify the // parent whenever the chosen create scope changes so it can reload // the tenant-scoped instance-type / template lists. useEffect(() => { if (scopeInitRef.current) { scopeInitRef.current = false; return; } onScopeChangeRef.current?.(scopeOrgId); }, [scopeOrgId]); useEffect(() => { if ( hasSSHPort && !form.getFieldValue('enable_ssh') && action === PageAction.CREATE ) { form.setFieldValue('enable_ssh', true); } }, [hasSSHPort, form, action]); const segmentOptions = useMemo( () => [ { value: TABKeysMap.BASIC, label: intl.formatMessage({ id: 'common.title.basicInfo' }), icon: , field: 'name' }, { value: TABKeysMap.INSTANCE_TYPE, label: intl.formatMessage({ id: 'gpuservice.instance.section.type' }), icon: , field: 'instanceType' }, { value: TABKeysMap.TEMPLATE, label: intl.formatMessage({ id: 'gpuservice.instance.section.template' }), icon: , field: 'template' }, { value: TABKeysMap.STORAGE, label: intl.formatMessage({ id: 'gpuservice.instance.section.storage' }), icon: , field: 'storage' } ], [intl] ); const [selectedInstanceType, setSelectedInstanceType] = useState< InstanceTypeItem | undefined >(undefined); const [onceMaxRequest, setOnceMaxRequest] = useState({ cpu: null, memory: null, localStorage: null }); const buildResourcesData = ( instanceType: InstanceTypeItem | undefined, options: { count: number; } ) => { const unitResourcesParsed = instanceType?.spec?.unitResourcesParsed; const { count = 0 } = options; console.log('building resources data with', { unitResourcesParsed, count }); const result = { accelerator: _.toString(count), cpu: unitResourcesParsed?.cpu?.cores ? count * unitResourcesParsed?.cpu?.cores : null, ram: unitResourcesParsed?.ram?.value ? count * unitResourcesParsed?.ram?.value : null }; console.log('built resources data', result); return result; }; const buildResourcesDataForSubmit = (values: FormData) => { const unitResourcesParsed = getUnitResources(); 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 = resources.cpu; const percentage = _.toNumber( resources.acceleratorSlicedMemoryPercentage ); const sliced = isGPUType && percentage > 0; const wholeFactor = isGPUType ? accelerator : cpuCount; // Sliced mode: scale a single card's unit resources by the chosen // percentage. Scale CPU in millicores and RAM in MiB so fractional // slices stay precise and k8s-valid (integers) — e.g. 10% of a 4-core / // 16Gi card → 400m / 1638Mi, not a rounded-up 1 core / 1Gi. if (sliced && unitResourcesParsed) { const cpuCores = unitResourcesParsed.cpu?.cores ?? 0; const ramValue = unitResourcesParsed.ram?.value ?? 0; return { cpu: `${Math.max(1, _.floor((cpuCores * 1000 * percentage) / 100))}m`, ram: `${Math.max(1, _.floor((ramValue * 1024 * percentage) / 100))}Mi` }; } // Whole / CPU mode: multiply the unit by the count. return { cpu: cpuNum ? `${wholeFactor * 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 ? `${wholeFactor * 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: { // Display the precise (rounded) fractional values — the inputs are // disabled, so decimals are fine and match the submitted // millicore / MiB allocation better than a floored integer. cpu: cpuCores != null && percentage > 0 ? _.round((cpuCores * percentage) / 100, 2) : null, ram: ramValue != null && percentage > 0 ? _.round((ramValue * percentage) / 100, 2) : 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, sliced?: boolean ) => { if (!instanceType) { setSelectedInstanceType(undefined); setOnceMaxRequest({ cpu: null, memory: null, localStorage: null }); const spec = form.getFieldValue('spec') || {}; form.setFieldsValue({ clusterId: null, spec: { ...spec, type: null, resources: { ...spec.resources, accelerator: null, cpu: null, ram: null } } }); return; } setSelectedInstanceType(instanceType); const candidate = pickCandidateForAccelerator( instanceType.status?.tiers, { count, acceleratable: instanceType.spec?.acceleratable, sliced } ); console.log('picked candidate', candidate, instanceType, count); setOnceMaxRequest({ cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores, // 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({ clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null, spec: { type: candidate?.name || '', resources: { ...buildResourcesData(instanceType, { count }) } } as any }); }; // 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, 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) => { scrollTabsRef.current?.handleTargetChange(key); }; const { handleOnFinishFailed: rawHandleOnFinishFailed } = useFinishFailed({ requiredFields, onTargetChange, updateActiveKey }); const handleOnFinishFailed = (errorInfo: any) => { console.log('formvalues===', form.getFieldsValue()); const errorFields = (errorInfo?.errorFields || []).map((field: any) => ({ ...field, name: [ Array.isArray(field.name) ? field.name.join('.') : String(field.name) ] })); rawHandleOnFinishFailed({ ...errorInfo, errorFields }); onFinishFailed?.(errorInfo); }; const detectMode = (volume?: FormData['spec']['volume']) => { if (volume?.persistent?.name || volume?.persistentTemplate?.name) { return StorageModeValueMap.Persistent; } return StorageModeValueMap.Temporary; }; useEffect(() => { if (!open) { form.resetFields(); return; } if ( action === PageAction.EDIT || action === PageAction.VIEW || realAction === PageAction.CREATE ) { console.log('currentData', currentData); const currentSpec = parseJsonSafe( currentData?.description || '{}', {} as any )?.spec; const count = currentSpec?.acceleratable ? _.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: { ...currentData?.spec, resources: { ...currentData?.spec?.resources, ...buildResourcesData( parseJsonSafe(currentData?.description || '{}', {}), { count } ) } }, 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]); const getUnitResources = () => { if (selectedInstanceType?.spec?.unitResourcesParsed) { return selectedInstanceType.spec.unitResourcesParsed; } try { return ( parseJsonSafe(currentData?.description || '{}', {})?.spec ?.unitResourcesParsed ?? undefined ); } catch { return undefined; } }; const handleFinish = async (values: InstanceFormValues) => { const submittedPorts = [...(values.spec?.ports ?? [])]; const submittedHasSSHPort = submittedPorts.some( (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT ); if (!submittedHasSSHPort) { submittedPorts.push({ protocol: 'TCP', port: SSH_PORT, name: 'SSH' }); } await onFinish({ ..._.omit(values, ['enable_ssh']), spec: { ...values.spec, ports: submittedPorts, resources: { ...values.spec?.resources, ...buildResourcesDataForSubmit(values) } } }); }; const segmentedTop = action === PageAction.EDIT ? { top: 0, offsetTop: 100 } : { top: 40, offsetTop: 130 }; useImperativeHandle(ref, () => ({ submit: () => { form.submit(); }, resetFields: () => { form.resetFields(); }, setFieldsValue: (values: Partial) => { form.setFieldsValue(values as any); }, getFieldsValue: () => form.getFieldsValue(), applyInstanceType: (instanceType?: InstanceTypeItem) => { if (!instanceType) { setSliceMode('whole'); resolveAndApply(undefined, 0); return; } // 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); } })); const handleAddSSHKey = () => { setSshOverlayOpen(true); }; const handleCreateSSHKey = async (values: PublicKeyFormData) => { try { await fetchSSHData({ page: -1 }); const current: Array<{ name: string } | string> = form.getFieldValue(['spec', 'sshPublicKeys']) || []; form.setFieldValue( ['spec', 'sshPublicKeys'], [...current, { name: values.name }] ); setSshOverlayOpen(false); } catch (error) { // it's handled in interceptor } }; const handleOnEnableSSHChange = (e: any) => { const checked = e.target.checked; if (!checked) { form.setFieldValue(['spec', 'sshPublicKeys'], []); } }; return (
) }, { key: TABKeysMap.TEMPLATE, label: intl.formatMessage({ id: 'gpuservice.instance.section.template' }), forceRender: true, children: ( ) }, { key: TABKeysMap.STORAGE, label: intl.formatMessage({ id: 'gpuservice.instance.section.storage' }), forceRender: true, children: ( ) } ]} /> name="enable_ssh" valuePropName="checked" style={{ marginBottom: 8 }} >
name={['spec', 'sshPublicKeys']} style={{ marginBottom: 12 }} hidden={!sshEnabled} normalize={(value) => Array.isArray(value) ? value?.map((item) => ({ name: item })) : [] } getValueProps={(value) => ({ value: Array.isArray(value) ? value.map((item) => item?.name ?? item) : [] })} rules={[ { required: sshEnabled, message: getRuleMessage('select', 'gpuservice.publicKey') } ]} >
setSshOverlayOpen(false)} onSubmit={handleCreateSSHKey} />
); } ); export default GPUServiceInstanceForm;