diff --git a/src/config/theme/light.ts b/src/config/theme/light.ts index 537f61bb..484fc5f1 100644 --- a/src/config/theme/light.ts +++ b/src/config/theme/light.ts @@ -57,7 +57,7 @@ export default { itemColor: 'rgba(0,0,0,1)', itemHoverBg: 'rgba(0,0,0,0.04)', itemActiveBg: 'rgba(0,0,0,0.04)', - menuItemSelectedBg: 'rgba(0, 0, 0, 0.06)' + menuItemSelectedBg: '#e8eaed' }, Progress: { lineBorderRadius: 3 diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index 20663d9f..50344150 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -290,8 +290,8 @@ const AddModal: React.FC = ({ ...item.spec, sshPublicKeys: formValues?.spec?.sshPublicKeys, resources: { - ...item?.spec?.resources, - accelerator: formValues?.spec?.resources?.accelerator + ...formValues?.spec?.resources, + localStorage: item?.spec?.resources?.localStorage }, volume: { ...formValues?.spec?.volume 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 f5c6061e..6dbad5d0 100644 --- a/src/pages/gpu-service/instances/components/instance-type-item.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-item.tsx @@ -1,3 +1,4 @@ +import { ceilMilliToCore } from '@/pages/gpu-service/utils'; import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Flex } from 'antd'; @@ -21,28 +22,30 @@ const Title = styled.div` const Meta = styled.div` display: grid; - gap: 8px; + grid-template-columns: repeat(7, auto); + justify-content: start; + column-gap: 4px; + row-gap: 8px; + align-items: center; color: var(--ant-color-text-secondary); font-size: 13px; .meta-row { - display: flex; + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; align-items: center; - gap: 8px; + min-height: 15px; color: var(--ant-color-text-tertiary); } + .dot { width: 3px; height: 3px; border-radius: 50%; background-color: var(--ant-color-text-quaternary); - flex: none; - } - - .meta-item { - display: inline-flex; - align-items: center; - gap: 4px; + margin: 0 4px; + justify-self: center; } .meta-icon { @@ -66,14 +69,10 @@ const MetaItem: React.FC<{ if (!show) return null; return ( <> - {showDot && } - - - - {label} - {value || '-'} - - + {showDot && } + + {label} + {value || '-'} ); }; @@ -81,6 +80,8 @@ const MetaItem: React.FC<{ const InstanceTypeItem: React.FC = ({ item }) => { const intl = useIntl(); const specData = item.spec || {}; + + // false: CPU; true: GPU const acceleratable = specData.acceleratable; const manufacturer = acceleratable ? specData.manufacturer || '' : 'cpu'; @@ -89,6 +90,18 @@ const InstanceTypeItem: React.FC = ({ item }) => { // resource once-max-request status const onceMaxRequestData = item.status?.onceMaxRequest || {}; + // RAM resource + const ramRaw = acceleratable + ? specData.unitResources?.ram + : onceMaxRequestData.ram; + + console.log('InstanceTypeItem', item.name, 'ramRaw', ramRaw); + + // CPU resource + const cpuRaw = acceleratable + ? specData.unitResources?.cpu + : onceMaxRequestData.cpu; + const renderName = () => { const displayName = specData.acceleratable ? specData.product || item.name @@ -118,9 +131,9 @@ const InstanceTypeItem: React.FC = ({ item }) => { - + {acceleratable && ( - + <> = ({ item }) => { toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ?? '-' } - > + /> = ({ item }) => { id: 'gpuservice.instance.sliced' })} value={specData?.sliced} - > + /> - + value={`${item.maxAccelerator || '-'}`} + /> + )} @@ -155,15 +169,15 @@ const InstanceTypeItem: React.FC = ({ item }) => { showDot={false} icon="icon-ram-02" label={intl.formatMessage({ id: 'gpuservice.instance.ram' })} - value={toDisplayUnit(convertKiToGi(onceMaxRequestData.ram)) ?? '-'} - > + value={toDisplayUnit(convertKiToGi(ramRaw)) ?? '-'} + /> + value={ceilMilliToCore(cpuRaw) ?? '-'} + /> diff --git a/src/pages/gpu-service/instances/config/index.ts b/src/pages/gpu-service/instances/config/index.ts index 25696984..b4cda624 100644 --- a/src/pages/gpu-service/instances/config/index.ts +++ b/src/pages/gpu-service/instances/config/index.ts @@ -172,8 +172,8 @@ export const convertKiToGi = (value?: string): string | undefined => { const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/.exec(value); if (!match) return value; const [, num, unit] = match; - if (unit === 'Ti') return `${_.round(Number(num), 2)}Ti`; - return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`; + if (unit === 'Ti') return `${_.floor(Number(num), 0)}Ti`; + return `${_.floor(Number(num) / GI_DIVISOR[unit], 0)}Gi`; }; const parseQuantity = (value?: string | null): number => { diff --git a/src/pages/gpu-service/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts index 92525a0c..988d61b1 100644 --- a/src/pages/gpu-service/instances/config/types.ts +++ b/src/pages/gpu-service/instances/config/types.ts @@ -1,5 +1,21 @@ export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never'; +type QuanityCPU = `${number}m` | string; + +type QuanityMemory = + | `${number}Ki` + | `${number}Mi` + | `${number}Gi` + | `${number}Ti` + | string; + +type QuanityLocalStorage = + | `${number}Ki` + | `${number}Mi` + | `${number}Gi` + | `${number}Ti` + | string; + // instance form data export interface FormData { name: string; @@ -116,9 +132,9 @@ export interface InstanceTypeCandidate { export interface InstanceTypeTierOnceMaxRequestResource { accelerator?: string; - cpu: string; - ram: string; - localStorage: string; + cpu: QuanityCPU; + ram: QuanityMemory; + localStorage: QuanityLocalStorage; } export interface InstanceTypeTier { @@ -127,10 +143,10 @@ export interface InstanceTypeTier { } export interface InstanceTypeOnceMaxRequestResource { - accelerator?: string | null; - cpu: string; - ram: string; - localStorage: string; + accelerator?: `${number}` | null; + cpu: QuanityCPU; + ram: QuanityMemory; + localStorage: QuanityLocalStorage; } export interface InstanceTypeSpec { @@ -142,6 +158,10 @@ export interface InstanceTypeSpec { family?: string | null; computeCapability?: string | null; sliced?: string | null; + unitResources?: { + cpu: QuanityCPU; + ram: QuanityMemory; + }; } export interface InstanceTypeStatus { diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index bb83dcb7..12a32ee7 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -1,5 +1,10 @@ import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; +import { + ceilMilliToCore, + parseQuantityToGi, + parseQuantityToNumber +} from '@/pages/gpu-service/utils'; import { PlusOutlined } from '@ant-design/icons'; import { CheckboxField, @@ -57,35 +62,6 @@ interface InstanceFormProps { onFinish: (values: FormData) => Promise; } -const parseQuantityToNumber = (value?: string | null): number | null => { - if (!value) return null; - const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value)); - if (!match) return null; - const num = Number(match[1]); - return Number.isFinite(num) && num > 0 ? num : null; -}; - -// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare -// number assumed to be Gi) to an integer GiB. memory and localStorage -// inputs are displayed in GB, so the max bound and the "Remaining {count} -// GB" label need the converted value. -const GI_FROM_UNIT: Record = { - Ki: 1 / (1024 * 1024), - Mi: 1 / 1024, - Gi: 1, - Ti: 1024 -}; - -const parseQuantityToGi = (value?: string | null): number | null => { - if (!value) return null; - const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(String(value)); - if (!match) return null; - const num = Number(match[1]); - if (!Number.isFinite(num) || num <= 0) return null; - const factor = match[2] ? GI_FROM_UNIT[match[2]] : 1; - return Math.floor(num * factor); -}; - const TABKeysMap = { BASIC: 'basic', INSTANCE_TYPE: 'instanceType', @@ -238,18 +214,39 @@ const GPUServiceInstanceForm: React.FC = forwardRef( ); setSelectedInstanceType(instanceType); + setOnceMaxRequest({ - cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest), + cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest), memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest), localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest) }); + const unitResources = instanceType.spec?.unitResources; + const unitCpu = parseQuantityToNumber(unitResources?.cpu); + const unitRam = parseQuantityToNumber(unitResources?.ram); + const acceleratable = instanceType.spec?.acceleratable; + const cpuCores = + unitCpu != null ? ceilMilliToCore(`${count * unitCpu}m`) : null; + const ramGi = + unitRam != null ? parseQuantityToGi(`${count * unitRam}Mi`) : null; + form.setFieldsValue({ clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null, spec: { type: candidate?.name || '', ...(options.writeAccelerator ? { resources: { accelerator: _.toString(count) } } + : {}), + ...(acceleratable + ? { + resources: { + ...(options.writeAccelerator + ? { accelerator: _.toString(count) } + : {}), + ...(cpuCores != null ? { cpu: cpuCores } : {}), + ...(ramGi != null ? { ram: `${ramGi}Gi` } : {}) + } + } : {}) } as any }); @@ -304,6 +301,20 @@ const GPUServiceInstanceForm: React.FC = forwardRef( } }, [action, currentData, form, open, realAction, instanceTypeList]); + const getUnitResources = () => { + if (selectedInstanceType?.spec?.unitResources) { + return selectedInstanceType.spec.unitResources; + } + try { + return ( + JSON.parse(currentData?.description || '{}')?.spec?.unitResources ?? + undefined + ); + } catch { + return undefined; + } + }; + const handleFinish = async (values: InstanceFormValues) => { const submittedPorts = [...(values.spec?.ports ?? [])]; const submittedHasSSHPort = submittedPorts.some( @@ -317,11 +328,27 @@ const GPUServiceInstanceForm: React.FC = forwardRef( name: 'SSH' }); } + + const accelerator = _.toNumber(values.spec?.resources?.accelerator); + const submittedResources = { ...(values.spec?.resources ?? {}) }; + if (accelerator > 0) { + const unitResources = getUnitResources(); + const unitCpu = parseQuantityToNumber(unitResources?.cpu); + const unitRam = parseQuantityToNumber(unitResources?.ram); + if (unitCpu != null) { + submittedResources.cpu = `${accelerator * unitCpu}m`; + } + if (unitRam != null) { + submittedResources.ram = `${accelerator * unitRam}Mi`; + } + } + await onFinish({ ..._.omit(values, ['enable_ssh']), spec: { ...values.spec, - ports: submittedPorts + ports: submittedPorts, + resources: submittedResources } }); }; @@ -446,6 +473,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( disabled={disabled} selectedInstanceType={selectedInstanceType} currentData={currentData as any} + onceMaxRequest={onceMaxRequest} onGPUCountChange={handleAcceleratorChange} /> ) @@ -505,49 +533,48 @@ const GPUServiceInstanceForm: React.FC = forwardRef( })} - { -
- - name={['spec', 'sshPublicKeys']} - style={{ - marginBottom: 12 - }} - hidden={!sshEnabled} - normalize={(value) => - Array.isArray(value) - ? value?.map((item) => ({ name: item })) - : [] + +
+ + 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') } - getValueProps={(value) => ({ - value: Array.isArray(value) - ? value.map((item) => item?.name ?? item) - : [] + ]} + > + - - -
- } + }} + options={sshkeyOptions} + > + +
void; } @@ -59,6 +63,7 @@ const InstanceTypeFormItem: React.FC = ({ disabled, currentData, selectedInstanceType, + onceMaxRequest, onGPUCountChange }) => { const intl = useIntl(); @@ -68,17 +73,45 @@ const InstanceTypeFormItem: React.FC = ({ ? _.toNumber(currentData?.spec?.resources?.accelerator) || 0 : getAcceleratorMax(selectedInstanceType?.status?.acceleratorTiers); - const handleOnGPUCountChange = (value: number) => { - onGPUCountChange?.(value); - }; - - const showGPUCount = useMemo(() => { + const isGPU = useMemo(() => { if (action === PageAction.EDIT) { return _.toNumber(currentData?.spec?.resources?.accelerator) > 0; } return selectedInstanceType?.spec?.acceleratable; }, [selectedInstanceType, action]); + const handleOnGPUCountChange = (value: number) => { + onGPUCountChange?.(value); + }; + + const renderMaxLabel = ( + label: React.ReactNode, + max?: number | null + ): React.ReactNode => { + if (max == null) return label; + return ( + + {label} + {!isGPU && ( + + ({intl.formatMessage({ id: 'common.max' }, { count: max })}) + + )} + + ); + }; + + const renderMemoryLabel = (): React.ReactNode => { + if (isGPU) { + return intl.formatMessage({ id: 'gpuservice.template.memory' }); + } + + return intl.formatMessage( + { id: 'gpuservice.instance.memory.remaining' }, + { count: onceMaxRequest?.memory } + ); + }; + const renderInstanceType = () => { const description = JSON.parse(currentData?.description || '{}').spec || {}; return ( @@ -123,7 +156,7 @@ const InstanceTypeFormItem: React.FC = ({ {action === PageAction.EDIT && renderInstanceType()} - {showGPUCount && ( + {isGPU && ( name={['spec', 'resources', 'accelerator']} hidden={action === PageAction.EDIT} @@ -198,6 +231,48 @@ const InstanceTypeFormItem: React.FC = ({ /> )} + +
+ + name={['spec', 'resources', 'ram']} + normalize={(value) => (value ? `${value}Gi` : undefined)} + getValueProps={(value) => { + if (!value) return { value: '' }; + const str = String(value); + if (/Gi$/.test(str)) return { value: str.replace(/Gi$/, '') }; + if (/(Ki|Mi|Ti)$/.test(str)) { + return { value: parseQuantityToGi(str) ?? '' }; + } + return { value: str }; + }} + > + + +
+
+ + name={['spec', 'resources', 'cpu']} + normalize={(value) => (value ? `${value}` : '')} + getValueProps={(value) => { + if (!value) return { value: '' }; + const str = String(value); + return { + value: /m$/.test(str) ? (ceilMilliToCore(str) ?? '') : str + }; + }} + > + + +
+
); }; diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index 0aa4a690..b8b72cae 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -11,10 +11,23 @@ import type { ColumnsType } from 'antd/lib/table'; import dayjs from 'dayjs'; import _ from 'lodash'; import { Fragment, useMemo } from 'react'; +import { ceilMilliToCore, parseQuantityToGi } from '../../utils'; import { InstanceStatusLabelMap, rowActionList, status } from '../config'; import { ListItem } from '../config/types'; import tableSyles from '../styles/table.module.less'; +const formatCpu = (cpu?: string | null): string => { + if (!cpu) return '-'; + return /m$/.test(cpu) ? (ceilMilliToCore(cpu) ?? cpu) : cpu; +}; + +const formatMemoryGB = (value?: string | null): string => { + if (!value) return '-'; + if (/Gi$/.test(value)) return value.replace('Gi', 'GB'); + const gi = parseQuantityToGi(value); + return gi != null ? `${gi}GB` : value; +}; + type ConnectEntry = | { type: 'ssh'; @@ -106,16 +119,16 @@ const useInstancesColumns = ({ align="center" style={{ fontSize: 13, color: 'var(--ant-color-text-tertiary)' }} > - {record.spec.resources?.cpu}C + {formatCpu(record.spec.resources?.cpu)}C {intl.formatMessage({ id: 'gpuservice.instance.ram' })}:{' '} - {record.spec.resources?.ram?.replace('Gi', 'GB')} + {formatMemoryGB(record.spec.resources?.ram)} {intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '} - {record.spec.resources?.localStorage?.replace('Gi', 'GB')} + {formatMemoryGB(record.spec.resources?.localStorage)} diff --git a/src/pages/gpu-service/templates/forms/basic.tsx b/src/pages/gpu-service/templates/forms/basic.tsx index 01cb0890..9ebb5da5 100644 --- a/src/pages/gpu-service/templates/forms/basic.tsx +++ b/src/pages/gpu-service/templates/forms/basic.tsx @@ -49,21 +49,6 @@ const Basic: React.FC = ({ [] ); - const renderMaxLabel = ( - label: React.ReactNode, - max?: number | null - ): React.ReactNode => { - if (max == null) return label; - return ( - - {label} - - ({intl.formatMessage({ id: 'common.max' }, { count: max })}) - - - ); - }; - const renderStorageLabel = (): React.ReactNode => { if (page === 'instance' && onceMaxRequest?.localStorage != null) { return intl.formatMessage( @@ -74,16 +59,6 @@ const Basic: React.FC = ({ return intl.formatMessage({ id: 'gpuservice.template.containerDisk' }); }; - const renderMemoryLabel = (): React.ReactNode => { - if (page === 'instance' && onceMaxRequest?.memory != null) { - return intl.formatMessage( - { id: 'gpuservice.instance.memory.remaining' }, - { count: onceMaxRequest.memory } - ); - } - return intl.formatMessage({ id: 'gpuservice.template.memory' }); - }; - return ( <>
@@ -227,37 +202,6 @@ const Basic: React.FC = ({ - -
- - name={['spec', 'resources', 'cpu']} - normalize={(value) => (value ? `${value}` : '')} - getValueProps={(value) => ({ value: value ? String(value) : '' })} - > - - -
-
- - name={['spec', 'resources', 'ram']} - normalize={(value) => (value ? `${value}Gi` : undefined)} - getValueProps={(value) => ({ - value: value ? String(value).replace(/Gi$/, '') : '' - })} - > - - -
-
- diff --git a/src/pages/gpu-service/utils.ts b/src/pages/gpu-service/utils.ts index ae9486b9..1ad6cc9b 100644 --- a/src/pages/gpu-service/utils.ts +++ b/src/pages/gpu-service/utils.ts @@ -11,3 +11,43 @@ export const omitPathParams = >( 'cluster_id' ]) as Omit; }; + +export const parseQuantityToNumber = (value?: string | null): number | null => { + if (!value) return null; + const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value)); + if (!match) return null; + const num = Number(match[1]); + return Number.isFinite(num) && num > 0 ? num : null; +}; + +const GI_FROM_UNIT: Record = { + Ki: 1 / (1024 * 1024), + Mi: 1 / 1024, + Gi: 1, + Ti: 1024 +}; + +// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare +// number assumed to be bytes) to an integer GiB. Unit suffix is +// case-insensitive. +export const parseQuantityToGi = (value?: string | null): number | null => { + if (!value) return null; + const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/i.exec(String(value)); + if (!match) return null; + const num = Number(match[1]); + if (!Number.isFinite(num) || num <= 0) return null; + const unit = match[2] + ? match[2][0].toUpperCase() + match[2].slice(1).toLowerCase() + : null; + const factor = unit ? GI_FROM_UNIT[unit] : 1 / (1024 * 1024 * 1024); + return Math.floor(num * factor); +}; + +export const ceilMilliToCore = (value?: string | null): string | null => { + if (!value) return null; + const match = /^(-?\d+(?:\.\d+)?)m?$/.exec(value); + if (!match) return null; + const num = Number(match[1]); + if (!Number.isFinite(num)) return null; + return String(Math.ceil(num / 1000)); +};