Files
gpustack-ui/src/pages/gpu-service/instances/forms/index.tsx
T
jialin fe24f9b53d fix(gpu-service): address PR review on sliced scaling and theme logo
- Scale sliced CPU in millicores and RAM in MiB for precise, k8s-valid
  fractional allocations; show rounded decimals in the disabled inputs
- Disable the by-ratio option when the type has no sliced capacity
- Use the dark logo map under the dark theme in template cards
2026-07-22 15:52:16 +08:00

870 lines
28 KiB
TypeScript

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<void>;
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<InstanceFormProps> = 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<InstanceFormValues>();
const scrollTabsRef = useRef<any>(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: <IconFont type="icon-basic" />,
field: 'name'
},
{
value: TABKeysMap.INSTANCE_TYPE,
label: intl.formatMessage({ id: 'gpuservice.instance.section.type' }),
icon: <IconFont type="icon-gpu1" />,
field: 'instanceType'
},
{
value: TABKeysMap.TEMPLATE,
label: intl.formatMessage({
id: 'gpuservice.instance.section.template'
}),
icon: <IconFont type="icon-model" />,
field: 'template'
},
{
value: TABKeysMap.STORAGE,
label: intl.formatMessage({
id: 'gpuservice.instance.section.storage'
}),
icon: <IconFont type="icon-storage-outlined" />,
field: 'storage'
}
],
[intl]
);
const [selectedInstanceType, setSelectedInstanceType] = useState<
InstanceTypeItem | undefined
>(undefined);
const [onceMaxRequest, setOnceMaxRequest] = useState<BasicResourceMax>({
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<any>(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<any>(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<InstanceFormValues>) => {
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 (
<ScrollSpyTabs
ref={scrollTabsRef}
defaultTarget={TABKeysMap.BASIC}
segmentOptions={segmentOptions}
activeKey={activeKey}
setActiveKey={handleActiveChange}
segmentedTop={segmentedTop}
getScrollElementScrollableHeight={getScrollElementScrollableHeight}
>
<FormContext.Provider
value={{
action: formAction,
currentData: currentData,
isGPUType: isGPUType
}}
>
<Form
name="gpuServiceInstanceForm"
form={form}
disabled={disabled}
onFinish={handleFinish}
onFinishFailed={handleOnFinishFailed}
preserve={false}
initialValues={{
spec: {
description: '',
displayName: '',
image: '',
imagePullPolicy: DefaultImagePullPolicy,
command: [],
ports: [
{
protocol: 'TCP',
port: SSH_PORT,
name: 'SSH'
}
],
env: [],
volumeMount: '',
resources: {
accelerator: 1
},
volume: {
ephemeral: {
capacity: '50Gi'
},
persistent: {
name: ''
}
}
},
enable_ssh: false,
storageMode: StorageModeValueMap.Temporary
}}
>
<Basic action={formAction} disabled={disabled} />
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
<CollapsePanel
activeKey={collapseKeys}
accordion={false}
onChange={handleOnCollapseChange}
items={[
{
key: TABKeysMap.INSTANCE_TYPE,
label: intl.formatMessage({
id: 'gpuservice.instance.section.type'
}),
forceRender: true,
children: (
<InstanceTypeFormItem
action={formAction}
disabled={disabled}
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
noAvailableTypes={noAvailableInstanceTypes}
sliceMode={sliceMode}
onSliceModeChange={handleSliceModeChange}
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
onGPUCountChange={handleAcceleratorChange}
/>
)
},
{
key: TABKeysMap.TEMPLATE,
label: intl.formatMessage({
id: 'gpuservice.instance.section.template'
}),
forceRender: true,
children: (
<TemplateBasicForm
page="instance"
disabled={disabled || formAction === PageAction.EDIT}
onceMaxRequest={onceMaxRequest}
/>
)
},
{
key: TABKeysMap.STORAGE,
label: intl.formatMessage({
id: 'gpuservice.instance.section.storage'
}),
forceRender: true,
children: (
<StorageVolume
disabled={disabled || formAction === PageAction.EDIT}
action={formAction}
/>
)
}
]}
/>
<Flex align="center" justify="space-between">
<Form.Item<FormData>
name="enable_ssh"
valuePropName="checked"
style={{ marginBottom: 8 }}
>
<CheckboxField
onChange={handleOnEnableSSHChange}
disabled={disabled}
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.enable'
})}
></CheckboxField>
</Form.Item>
<Button
size="small"
type="link"
icon={<PlusOutlined />}
onClick={handleAddSSHKey}
>
{intl.formatMessage({
id: 'gpuservice.instance.ssh.addKey'
})}
</Button>
</Flex>
<div className={instanceStyles.sshkeySelection}>
<Form.Item<FormData>
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')
}
]}
>
<MultipleSelect
required
disabled={disabled}
mode="multiple"
maxTagCount={'responsive'}
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.assignKey'
})}
styles={{
wrapper: {
width: '100%'
}
}}
options={sshkeyOptions}
></MultipleSelect>
</Form.Item>
</div>
</Form>
</FormContext.Provider>
<PublicKeyOverlay
open={sshOverlayOpen}
onCancel={() => setSshOverlayOpen(false)}
onSubmit={handleCreateSSHKey}
/>
</ScrollSpyTabs>
);
}
);
export default GPUServiceInstanceForm;