diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index 07c35d43..6624b319 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -222,6 +222,12 @@ export default { 'common.title.delete.confirm': 'Confirm deletion', 'common.title.stop.confirm': 'Confirm stop', 'common.title.start.confirm': 'Confirm start', + 'common.title.activate.confirm': 'Confirm activate', + 'common.title.deactivate.confirm': 'Confirm deactivate', + 'common.activate.single.confirm': + 'Are you sure you want to activate? \n {name}', + 'common.deactivate.single.confirm': + 'Are you sure you want to deactivate? \n {name}', 'common.title.recreate.confirm': 'Confirm recreate', 'common.button.addLabel': 'Add Label', 'common.button.addSelector': 'Add Selector', diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index bdbfa9f5..93227b2b 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -122,6 +122,9 @@ export default { 'gpuservice.instance.mode.whole': 'Full GPU', 'gpuservice.instance.mode.sliced': 'By Ratio', 'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)', + 'gpuservice.instance.slice.coresPercentage': 'Compute Percentage (%)', + 'gpuservice.instance.slice.cores.min': + 'The compute ratio must be no less than the VRAM ratio ({count}%)', 'gpuservice.instance.slice.fullCores': '100% Compute', 'gpuservice.instance.slice.percentage.required': 'Please select or enter a percentage', diff --git a/src/locales/ja-JP/common.ts b/src/locales/ja-JP/common.ts index 25af4459..53128334 100644 --- a/src/locales/ja-JP/common.ts +++ b/src/locales/ja-JP/common.ts @@ -221,6 +221,12 @@ export default { 'common.title.delete.confirm': '削除を確認', 'common.title.stop.confirm': '停止を確認', 'common.title.start.confirm': '開始を確認', + 'common.title.activate.confirm': '有効化を確認', + 'common.title.deactivate.confirm': '無効化を確認', + 'common.activate.single.confirm': + '{name} を有効化してもよろしいですか?', + 'common.deactivate.single.confirm': + '{name} を無効化してもよろしいですか?', 'common.title.recreate.confirm': '再作成を確認', 'common.button.addLabel': 'ラベルを追加', 'common.button.addSelector': 'セレクターを追加', diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index 35dd6d5e..22928c42 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -121,6 +121,9 @@ export default { 'gpuservice.instance.mode.whole': 'GPU 全体', 'gpuservice.instance.mode.sliced': '比率で', 'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%)', + 'gpuservice.instance.slice.coresPercentage': '演算能力の割合(%)', + 'gpuservice.instance.slice.cores.min': + '演算能力の割合は VRAM の割合({count}%)以上である必要があります', 'gpuservice.instance.slice.fullCores': '100% コンピュート', 'gpuservice.instance.slice.percentage.required': 'パーセンテージを選択または入力してください', diff --git a/src/locales/ru-RU/common.ts b/src/locales/ru-RU/common.ts index 8df477ba..4ae3a2da 100644 --- a/src/locales/ru-RU/common.ts +++ b/src/locales/ru-RU/common.ts @@ -219,6 +219,12 @@ export default { 'common.title.delete.confirm': 'Подтверждение удаления', 'common.title.stop.confirm': 'Подтверждение остановки', 'common.title.start.confirm': 'Подтверждение запуска', + 'common.title.activate.confirm': 'Подтверждение активации', + 'common.title.deactivate.confirm': 'Подтверждение деактивации', + 'common.activate.single.confirm': + 'Вы уверены, что хотите активировать {name}?', + 'common.deactivate.single.confirm': + 'Вы уверены, что хотите деактивировать {name}?', 'common.title.recreate.confirm': 'Подтверждение пересоздания', 'common.button.addLabel': 'Добавить метку', 'common.button.addSelector': 'Добавить селектор', diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index 3600069d..97f39577 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -120,6 +120,9 @@ export default { 'gpuservice.instance.mode.whole': 'Весь GPU', 'gpuservice.instance.mode.sliced': 'По доле', 'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)', + 'gpuservice.instance.slice.coresPercentage': 'Доля вычислений (%)', + 'gpuservice.instance.slice.cores.min': + 'Доля вычислений должна быть не меньше доли VRAM ({count}%)', 'gpuservice.instance.slice.fullCores': '100% вычислений', 'gpuservice.instance.slice.percentage.required': 'Выберите или введите процент', diff --git a/src/locales/tr-TR/common.ts b/src/locales/tr-TR/common.ts index 62de4f36..ec191845 100644 --- a/src/locales/tr-TR/common.ts +++ b/src/locales/tr-TR/common.ts @@ -224,6 +224,12 @@ export default { 'common.title.delete.confirm': 'Silme onayı', 'common.title.stop.confirm': 'Durdurma onayı', 'common.title.start.confirm': 'Başlatma onayı', + 'common.title.activate.confirm': 'Etkinleştirme onayı', + 'common.title.deactivate.confirm': 'Devre dışı bırakma onayı', + 'common.activate.single.confirm': + 'Etkinleştirmek istediğinizden emin misiniz? \n {name}', + 'common.deactivate.single.confirm': + 'Devre dışı bırakmak istediğinizden emin misiniz? \n {name}', 'common.title.recreate.confirm': 'Yeniden oluşturma onayı', 'common.button.addLabel': 'Etiket Ekle', 'common.button.addSelector': 'Seçici Ekle', diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index 56dc1911..31115802 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -116,6 +116,9 @@ export default { 'gpuservice.instance.mode.whole': 'Tam GPU', 'gpuservice.instance.mode.sliced': 'Orana Göre', 'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)', + 'gpuservice.instance.slice.coresPercentage': 'İşlem Gücü Yüzdesi (%)', + 'gpuservice.instance.slice.cores.min': + 'İşlem gücü oranı VRAM oranından ({count}%) küçük olamaz', 'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü', 'gpuservice.instance.slice.percentage.required': 'Lütfen bir yüzde seçin veya girin', diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index 44d26e0b..cca24501 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -212,6 +212,12 @@ export default { 'common.title.delete.confirm': '确认删除', 'common.title.stop.confirm': '确认停止', 'common.title.start.confirm': '确认启动', + 'common.title.activate.confirm': '确认启用', + 'common.title.deactivate.confirm': '确认停用', + 'common.activate.single.confirm': + '确定启用 {name}?', + 'common.deactivate.single.confirm': + '确定停用 {name}?', 'common.title.recreate.confirm': '确认重新创建', 'common.button.addLabel': '添加标签', 'common.button.addSelector': '添加选择器', diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index 56282404..631a7ca9 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -111,6 +111,8 @@ export default { 'gpuservice.instance.mode.whole': '整卡', 'gpuservice.instance.mode.sliced': '按比例', 'gpuservice.instance.slice.memoryPercentage': '显存占比(%)', + 'gpuservice.instance.slice.coresPercentage': '算力占比(%)', + 'gpuservice.instance.slice.cores.min': '算力占比需不小于显存占比 {count}%', 'gpuservice.instance.slice.fullCores': '100% 算力', 'gpuservice.instance.slice.percentage.required': '请选择或输入百分比', 'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间', diff --git a/src/pages/gpu-service/instance-types/index.tsx b/src/pages/gpu-service/instance-types/index.tsx index 8c0ecba9..edd933ca 100644 --- a/src/pages/gpu-service/instance-types/index.tsx +++ b/src/pages/gpu-service/instance-types/index.tsx @@ -39,7 +39,8 @@ const GPUServiceInstanceTypes: React.FC = () => { const { dataList, loading: instanceTypesLoading, - fetchInstanceTypes + fetchInstanceTypes, + startWatch } = useQueryInstanceTypes(); const { fetchData: createInstanceType } = useCreateInstanceType(); const { @@ -66,16 +67,18 @@ const GPUServiceInstanceTypes: React.FC = () => { if (firstK8s?.id != null) { setClusterId(firstK8s.id); await fetchInstanceTypes(firstK8s.id); + startWatch(firstK8s.id); } setLoaded(true); }; init(); }, []); - const handleClusterChange = useMemoizedFn((value: number) => { + const handleClusterChange = useMemoizedFn(async (value: number) => { setClusterId(value); setKeyword(''); - fetchInstanceTypes(value); + await fetchInstanceTypes(value); + startWatch(value); }); const handleRefresh = useMemoizedFn(() => { @@ -125,18 +128,29 @@ const GPUServiceInstanceTypes: React.FC = () => { }); const handleToggleActive = useMemoizedFn( - async (record: ListItem, activate: boolean) => { + (record: ListItem, activate: boolean) => { if (clusterId == null) return; - try { - const action = activate - ? activateGPUInstanceType - : deactivateGPUInstanceType; - await action({ name: record.name, cluster_id: clusterId }); - message.success(intl.formatMessage({ id: 'common.message.success' })); - fetchInstanceTypes(clusterId); - } catch (error) { - // handled by the request interceptor - } + const action = activate + ? activateGPUInstanceType + : deactivateGPUInstanceType; + deleteModalRef.current?.show({ + content: intl.formatMessage({ id: 'gpuservice.instanceType' }), + title: activate + ? 'common.title.activate.confirm' + : 'common.title.deactivate.confirm', + okText: activate + ? 'gpuservice.instanceType.activate' + : 'gpuservice.instanceType.deactivate', + operation: activate + ? 'common.activate.single.confirm' + : 'common.deactivate.single.confirm', + name: record.spec?.displayName || record.name, + async onOk() { + await action({ name: record.name, cluster_id: clusterId }); + message.success(intl.formatMessage({ id: 'common.message.success' })); + fetchInstanceTypes(clusterId); + } + }); } ); diff --git a/src/pages/gpu-service/instance-types/services/use-query-instance-types.ts b/src/pages/gpu-service/instance-types/services/use-query-instance-types.ts index 45e7418e..847a268c 100644 --- a/src/pages/gpu-service/instance-types/services/use-query-instance-types.ts +++ b/src/pages/gpu-service/instance-types/services/use-query-instance-types.ts @@ -1,15 +1,49 @@ -import { createAxiosToken } from '@/hooks/use-chunk-request'; +import { WatchEventType } from '@/config'; +import useSetChunkRequest, { + createAxiosToken +} from '@/hooks/use-chunk-request'; import { useRequest } from 'ahooks'; import { CancelTokenSource } from 'axios'; +import qs from 'query-string'; import { useEffect, useRef, useState } from 'react'; -import { queryGPUInstanceTypes } from '../apis'; +import { GPU_INSTANCE_TYPES_API, queryGPUInstanceTypes } from '../apis'; import { ListItem } from '../config/types'; +// Merge a batch of watch events into the current name-keyed list. Instance +// types have no numeric id, so we upsert / remove by `name` rather than reuse +// the shared id-based chunked-list helper. +const mergeWatchEvents = (current: ListItem[], events: any[]) => { + let list = [...current]; + events.forEach((event: any) => { + const collection: ListItem[] = event?.collection || []; + if (event?.type === WatchEventType.DELETE) { + const names = collection.map((item) => item.name); + list = list.filter((item) => !names.includes(item.name)); + } else if ( + event?.type === WatchEventType.CREATE || + event?.type === WatchEventType.UPDATE + ) { + collection.forEach((item) => { + const index = list.findIndex((it) => it.name === item.name); + if (index > -1) { + list[index] = item; + } else { + list = [item, ...list]; + } + }); + } + }); + return list; +}; + // Cluster-scoped instance types for the management list. Action-driven: // call fetchInstanceTypes(clusterId) from the cluster picker / refresh, not -// via an effect dependency. +// via an effect dependency. startWatch(clusterId) opens a chunked watch that +// keeps the list in sync with live create/update/delete events. export default function useQueryInstanceTypes() { const tokenRef = useRef(null); + const chunkRequestRef = useRef(null); + const { setChunkRequest } = useSetChunkRequest(); const [dataList, setDataList] = useState([]); const { @@ -43,10 +77,27 @@ export default function useQueryInstanceTypes() { tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST'); }; + const stopWatch = () => { + chunkRequestRef.current?.current?.cancel?.(); + }; + + const startWatch = (clusterId: number) => { + stopWatch(); + chunkRequestRef.current = setChunkRequest({ + url: `${GPU_INSTANCE_TYPES_API}?${qs.stringify({ + cluster_id: clusterId + })}`, + handler: (events: any[]) => { + setDataList((pre) => mergeWatchEvents(pre, events)); + } + }); + }; + useEffect(() => { return () => { cancel(); tokenRef.current?.cancel(); + stopWatch(); }; }, []); @@ -55,6 +106,8 @@ export default function useQueryInstanceTypes() { loading, fetchInstanceTypes, cancelRequest, + startWatch, + stopWatch, setDataList }; } diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index 8386b0eb..af99f5d0 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -350,21 +350,39 @@ const GPUServiceInstanceForm: React.FC = forwardRef( } 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. + // Single entry point for the sliced memory ratio: write the ratio and + // rescale CPU / RAM off it. The compute (cores) ratio must stay >= memory, + // so bump it up when memory overtakes it. Reused by the slider onChange. const applySliceMemoryPercentage = (value: number) => { + const currentCores = _.toNumber( + form.getFieldValue([ + 'spec', + 'resources', + 'acceleratorSlicedCoresPercentage' + ]) + ); + const coresPercentage = currentCores >= value ? currentCores : value; form.setFieldsValue({ spec: { resources: { acceleratorSlicedMemoryPercentage: value, - acceleratorSlicedCoresPercentage: 100 + acceleratorSlicedCoresPercentage: coresPercentage } } } as any); applySlicedResourceScaling(); }; + // Compute (cores) ratio — a GPU-slice-only parameter that rides along on + // submit. It does not scale CPU / RAM (those track the memory ratio), so + // just write the field. + const applySliceCoresPercentage = (value: number) => { + form.setFieldValue( + ['spec', 'resources', 'acceleratorSlicedCoresPercentage'], + value + ); + }; + const resolveAndApply = ( instanceType: InstanceTypeItem | undefined, count: number, @@ -436,14 +454,25 @@ const GPUServiceInstanceForm: React.FC = forwardRef( 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. + // Seed the sliced-mode defaults for an instance type: memory ratio at 10% + // (never above the type's max sliceable ratio, status.onceMaxRequest + // .acceleratorSliced), and the cores ratio defaulting to the same value + // (cores >= memory). Set both together so a fresh selection doesn't carry a + // stale cores value from a previous type. const applySlicedDefaults = (instanceType?: InstanceTypeItem) => { const slicedMax = _.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) || 0; - applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10); + const memoryPercentage = slicedMax ? Math.min(10, slicedMax) : 10; + form.setFieldsValue({ + spec: { + resources: { + acceleratorSlicedMemoryPercentage: memoryPercentage, + acceleratorSlicedCoresPercentage: memoryPercentage + } + } + } as any); + applySlicedResourceScaling(); }; // Toggle between whole-card and sliced mode. Sliced fixes the accelerator @@ -757,6 +786,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( sliceMode={sliceMode} onSliceModeChange={handleSliceModeChange} onSliceMemoryPercentageChange={applySliceMemoryPercentage} + onSliceCoresPercentageChange={applySliceCoresPercentage} 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 f715dc00..0eb88551 100644 --- a/src/pages/gpu-service/instances/forms/instance-type.tsx +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -1,9 +1,9 @@ import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import NumberSelection from '@/pages/_components/number-selection'; -import { InputNumber } from '@gpustack/core-ui'; +import { InputNumber, LabelInfo } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { Flex, Form, Segmented } from 'antd'; +import { Divider, Flex, Form, Segmented } from 'antd'; import _ from 'lodash'; import { useContext, useMemo } from 'react'; import styled from 'styled-components'; @@ -76,6 +76,8 @@ interface InstanceTypeFormItemProps { onSliceModeChange?: (mode: 'whole' | 'sliced') => void; // Commit a new sliced memory ratio (writes the field + rescales CPU / RAM). onSliceMemoryPercentageChange?: (value: number) => void; + // Commit a new sliced compute (cores) ratio (writes the field only). + onSliceCoresPercentageChange?: (value: number) => void; onGPUCountChange?: (value: number) => void; } @@ -89,6 +91,7 @@ const InstanceTypeFormItem: React.FC = ({ sliceMode = 'whole', onSliceModeChange, onSliceMemoryPercentageChange, + onSliceCoresPercentageChange, onGPUCountChange }) => { const intl = useIntl(); @@ -139,6 +142,21 @@ const InstanceTypeFormItem: React.FC = ({ onSliceMemoryPercentageChange?.(value); }; + // Compute (cores) percentage changed — forward the new value. + const handleCoresPercentageChange = (value: number) => { + onSliceCoresPercentageChange?.(value); + }; + + // The cores ratio must be >= the memory ratio, so ticks below the current + // memory percentage are disabled (min). Cores range is a fixed 10..100. + const slicedMemoryPercentage = + _.toNumber( + Form.useWatch( + ['spec', 'resources', 'acceleratorSlicedMemoryPercentage'], + form + ) + ) || 1; + // Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced // (a percentage). Ticks above it stay visible but disabled. const slicedMaxPercentage = @@ -272,6 +290,13 @@ const InstanceTypeFormItem: React.FC = ({ )} + {showModeSwitch && ( +
+ +
{modeSegmented}
+ +
+ )} {!noAvailableTypes && ( key={isGPUType ? 'accelerator' : 'cpu'} @@ -322,7 +347,6 @@ const InstanceTypeFormItem: React.FC = ({ max={maxComputeUnitCount} step={1} required - labelExtra={sliceMode === 'whole' ? modeSegmented : undefined} disabled={disabled || readonlyType} label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage( { @@ -335,66 +359,113 @@ 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(); - } - } - ]} - > - + + name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']} + getValueProps={(value) => ({ + value: value != null ? _.toNumber(value) : undefined })} - /> - - {/* 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 - > - - + 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. Fixed 10..100 ticks; ticks below the + chosen memory ratio are disabled (cores must be >= memory). */} + + name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']} + style={{ marginBottom: 0 }} + 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 < slicedMemoryPercentage || num > 100) { + return Promise.reject( + new Error( + intl.formatMessage( + { id: 'gpuservice.instance.slice.cores.min' }, + { count: slicedMemoryPercentage } + ) + ) + ); + } + return Promise.resolve(); + } + } + ]} + > + + + )} {/* A not-yet-re-typed edit renders a readonly card (no sliced UI), so