diff --git a/config/routes.ts b/config/routes.ts index 8b49e1ea..fac1820c 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -186,6 +186,7 @@ const baseRoutes = [ name: 'gpuService', path: '/gpu-service', key: 'gpuService', + access: 'canSeeAdmin', routes: [ { path: '/gpu-service', diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index 771f4e65..8fe2206c 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -46,7 +46,11 @@ export default { 'gpuservice.storageType.kind': 'Type', 'gpuservice.storageType.mountOptions': 'Mount Options', 'gpuservice.storageType.nfs.server': 'NFS Server', + 'gpuservice.storageType.nfs.server.tips': + 'Ensure the NFS server address is reachable from all Kubernetes clusters.', 'gpuservice.storageType.nfs.share': 'Share Path', + 'gpuservice.storageType.nfs.share.tips': + 'A directory based on the organization and storage names will be automatically created within this share path. If a subdirectory is specified, the generated directory will be created under that subdirectory.', 'gpuservice.storageType.nfs.subDirectory': 'Sub Directory', 'gpuservice.storageType.nfs.subDirectory.tips': 'If empty, a subdirectory named after the persistent volume will be created. If set, a directory with the persistent volume name will be created beneath this subdirectory.', @@ -54,11 +58,17 @@ export default { 'gpuservice.storageType.nfs.mountPermissions.tips': 'Inherit the file permissions from the NFS server.', 'gpuservice.storageType.s3.endpoint': 'Endpoint', + 'gpuservice.storageType.s3.endpoint.tips': + 'Ensure the S3 endpoint is reachable from all Kubernetes clusters.', 'gpuservice.storageType.s3.endpoint.rule': 'Must start with http or https', 'gpuservice.storageType.s3.region': 'Region', 'gpuservice.storageType.s3.bucket': 'Bucket', 'gpuservice.storageType.s3.bucket.tips': 'If empty, a new bucket named after the persistent volume will be created. If set, a subdirectory with the persistent volume name will be created inside this bucket.', + 'gpuservice.storageType.s3.bucket.tips1': + 'A prefix based on the organization and storage names will be automatically created within this bucket.', + 'gpuservice.storageType.s3.bucket.tips2': + 'For example, if the organization is named awesome-group and the storage is named storage-1, the resulting prefix will be awesome-group/storage-1.', 'gpuservice.storageType.s3.accessKey': 'Access Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.insecure': 'Skip TLS/SSL certificate verification', diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index dd0f5b6f..e518a4b8 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -44,7 +44,11 @@ export default { 'gpuservice.storageType.kind': '種別', 'gpuservice.storageType.mountOptions': 'マウントオプション', 'gpuservice.storageType.nfs.server': 'NFS サーバー', + 'gpuservice.storageType.nfs.server.tips': + 'すべての Kubernetes クラスターから NFS サーバーアドレスにアクセスできることを確認してください。', 'gpuservice.storageType.nfs.share': '共有パス', + 'gpuservice.storageType.nfs.share.tips': + 'この共有パス配下に、組織名とストレージ名に基づくディレクトリが自動的に作成されます。サブディレクトリが指定されている場合、生成されたディレクトリはそのサブディレクトリ配下に作成されます。', 'gpuservice.storageType.nfs.subDirectory': 'サブディレクトリ', 'gpuservice.storageType.nfs.subDirectory.tips': '空の場合、永続ボリューム名のサブディレクトリが作成されます。設定されている場合、このサブディレクトリ配下に永続ボリューム名のディレクトリが作成されます。', @@ -52,12 +56,18 @@ export default { 'gpuservice.storageType.nfs.mountPermissions.tips': 'NFS サーバー上のファイル権限を継承します。', 'gpuservice.storageType.s3.endpoint': 'エンドポイント', + 'gpuservice.storageType.s3.endpoint.tips': + 'すべての Kubernetes クラスターから S3 エンドポイントにアクセスできることを確認してください。', 'gpuservice.storageType.s3.endpoint.rule': 'http または https で始まる必要があります', 'gpuservice.storageType.s3.region': 'リージョン', 'gpuservice.storageType.s3.bucket': 'バケット', 'gpuservice.storageType.s3.bucket.tips': '空の場合、永続ボリューム名で新しいバケットが作成されます。設定されている場合、このバケット配下に永続ボリューム名のサブディレクトリが作成されます。', + 'gpuservice.storageType.s3.bucket.tips1': + 'このバケット内に、組織名とストレージ名に基づくプレフィックスディレクトリが自動的に作成されます。', + 'gpuservice.storageType.s3.bucket.tips2': + '例えば、組織名が awesome-group、ストレージ名が storage-1 の場合、生成されるプレフィックスは awesome-group/storage-1 になります。', 'gpuservice.storageType.s3.accessKey': 'アクセスキー', 'gpuservice.storageType.s3.secretKey': 'シークレットキー', 'gpuservice.storageType.s3.insecure': 'TLS/SSL 証明書の検証をスキップ', diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index 866843e9..bfc70f4d 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -47,7 +47,11 @@ export default { 'gpuservice.storageType.kind': 'Тип', 'gpuservice.storageType.mountOptions': 'Параметры монтирования', 'gpuservice.storageType.nfs.server': 'Сервер NFS', + 'gpuservice.storageType.nfs.server.tips': + 'Убедитесь, что адрес NFS-сервера доступен из всех кластеров Kubernetes.', 'gpuservice.storageType.nfs.share': 'Путь общего ресурса', + 'gpuservice.storageType.nfs.share.tips': + 'В этом общем пути будет автоматически создан каталог на основе названия организации и названия хранилища. Если указан подкаталог, итоговый каталог будет создан внутри него.', 'gpuservice.storageType.nfs.subDirectory': 'Подкаталог', 'gpuservice.storageType.nfs.subDirectory.tips': 'Если поле пустое, будет создан подкаталог с именем постоянного тома. Если задано, под этим подкаталогом будет создан каталог с именем постоянного тома.', @@ -55,15 +59,22 @@ export default { 'gpuservice.storageType.nfs.mountPermissions.tips': 'Наследует права файлов с NFS-сервера.', 'gpuservice.storageType.s3.endpoint': 'Endpoint', + 'gpuservice.storageType.s3.endpoint.tips': + 'Убедитесь, что S3 endpoint доступен из всех кластеров Kubernetes.', 'gpuservice.storageType.s3.endpoint.rule': 'Должен начинаться с http или https', 'gpuservice.storageType.s3.region': 'Регион', 'gpuservice.storageType.s3.bucket': 'Бакет', 'gpuservice.storageType.s3.bucket.tips': 'Если поле пустое, будет создан новый бакет с именем постоянного тома. Если задано, в этом бакете будет создан подкаталог с именем постоянного тома.', + 'gpuservice.storageType.s3.bucket.tips1': + 'В этом бакете будет автоматически создан префикс на основе названия организации и названия хранилища.', + 'gpuservice.storageType.s3.bucket.tips2': + 'Например, если организация называется awesome-group, а хранилище — storage-1, итоговый префикс будет: awesome-group/storage-1.', 'gpuservice.storageType.s3.accessKey': 'Access Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key', - 'gpuservice.storageType.s3.insecure': 'Пропустить проверку сертификата TLS/SSL', + 'gpuservice.storageType.s3.insecure': + 'Пропустить проверку сертификата TLS/SSL', 'gpuservice.storageType.s3.insecure.tips': 'Если включено, сертификат сервера S3 не проверяется. Подходит для внутреннего тестирования или самоподписанных сертификатов; в производственной среде включайте с осторожностью.', 'gpuservice.publicKey': 'Открытый ключ SSH', diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index e3c978f0..6c3af077 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -46,7 +46,11 @@ export default { 'gpuservice.storageType.kind': 'Tür', 'gpuservice.storageType.mountOptions': 'Bağlama Seçenekleri', 'gpuservice.storageType.nfs.server': 'NFS Sunucusu', + 'gpuservice.storageType.nfs.server.tips': + 'NFS sunucu adresinin tüm Kubernetes kümelerinden erişilebilir olduğundan emin olun.', 'gpuservice.storageType.nfs.share': 'Paylaşım Yolu', + 'gpuservice.storageType.nfs.share.tips': + 'Bu paylaşım yolu altında organizasyon ve depolama adlarına dayalı bir dizin otomatik olarak oluşturulur. Bir alt dizin belirtilmişse, oluşturulan dizin o alt dizin altında yer alır.', 'gpuservice.storageType.nfs.subDirectory': 'Alt Dizin', 'gpuservice.storageType.nfs.subDirectory.tips': 'Boş bırakılırsa kalıcı hacim adıyla bir alt dizin oluşturulur. Doldurulursa bu alt dizinin altında kalıcı hacim adıyla bir dizin oluşturulur.', @@ -54,12 +58,17 @@ export default { 'gpuservice.storageType.nfs.mountPermissions.tips': 'NFS sunucusundaki dosya izinleri devralınır.', 'gpuservice.storageType.s3.endpoint': 'Endpoint', - 'gpuservice.storageType.s3.endpoint.rule': - 'http veya https ile başlamalıdır', + 'gpuservice.storageType.s3.endpoint.tips': + 'S3 endpoint adresinin tüm Kubernetes kümelerinden erişilebilir olduğundan emin olun.', + 'gpuservice.storageType.s3.endpoint.rule': 'http veya https ile başlamalıdır', 'gpuservice.storageType.s3.region': 'Bölge', 'gpuservice.storageType.s3.bucket': 'Kova', 'gpuservice.storageType.s3.bucket.tips': 'Boş bırakılırsa kalıcı hacim adıyla yeni bir kova oluşturulur. Doldurulursa bu kova içinde kalıcı hacim adıyla bir alt dizin oluşturulur.', + 'gpuservice.storageType.s3.bucket.tips1': + 'Bu kova içinde organizasyon ve depolama adlarına dayalı bir önek dizini otomatik olarak oluşturulur.', + 'gpuservice.storageType.s3.bucket.tips2': + 'Örneğin, organizasyon adı awesome-group ve depolama adı storage-1 ise, oluşacak önek: awesome-group/storage-1.', 'gpuservice.storageType.s3.accessKey': 'Access Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.insecure': 'TLS/SSL sertifika doğrulamasını atla', diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index 1146961f..bd7a6f2b 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -41,7 +41,11 @@ export default { 'gpuservice.storageType.kind': '类型', 'gpuservice.storageType.mountOptions': '挂载参数', 'gpuservice.storageType.nfs.server': 'NFS 服务器', + 'gpuservice.storageType.nfs.server.tips': + '确保所有 Kubernetes 集群都能访问该 NFS 服务地址。', 'gpuservice.storageType.nfs.share': '共享路径', + 'gpuservice.storageType.nfs.share.tips': + '系统会在该共享路径下自动创建一个基于组织名称和存储名称的目录。如果指定了子目录,则生成的目录会创建在该子目录下。', 'gpuservice.storageType.nfs.subDirectory': '子目录', 'gpuservice.storageType.nfs.subDirectory.tips': '如果为空,会以持久卷的卷名创建一个子目录;如果不为空,会在同名子目录下,以持久卷的卷名创建一个孙目录。', @@ -49,11 +53,17 @@ export default { 'gpuservice.storageType.nfs.mountPermissions.tips': '沿用 NFS 服务器文件的权限。', 'gpuservice.storageType.s3.endpoint': 'Endpoint', + 'gpuservice.storageType.s3.endpoint.tips': + '确保所有 Kubernetes 集群都能访问该 S3 Endpoint。', 'gpuservice.storageType.s3.endpoint.rule': '必须以 http 或 https 开头', 'gpuservice.storageType.s3.region': '区域', 'gpuservice.storageType.s3.bucket': '存储桶', 'gpuservice.storageType.s3.bucket.tips': '如果为空,会以持久卷的卷名创建一个新桶;如果不为空,会在同名桶下,以持久卷的卷名创建一个子目录。', + 'gpuservice.storageType.s3.bucket.tips1': + '系统会在该 Bucket 中自动创建一个基于组织名称和存储名称的前缀目录。', + 'gpuservice.storageType.s3.bucket.tips2': + '例如,若组织名称为 awesome-group,存储名称为 storage-1,则生成的前缀为:awesome-group/storage-1。', 'gpuservice.storageType.s3.accessKey': 'Access Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.insecure': '跳过 TLS/SSL 证书验证', diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index f1539a78..962b6d9e 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -31,13 +31,6 @@ type AddModalProps = { onCancel: () => void; }; -type InstanceTypeSelection = { - instanceType?: string; - manufacturer?: string; -}; - -const EMPTY_INSTANCE_TYPE_SELECTION: InstanceTypeSelection = {}; - const matchKeyword = (fields: Array, keyword: string) => { const trimmed = keyword.trim().toLowerCase(); if (!trimmed) return true; @@ -85,11 +78,17 @@ const AddModal: React.FC = ({ const intl = useIntl(); const form = useRef(null); const sessionRef = useRef(0); - const [instanceTypeSelection, setInstanceTypeSelection] = - useState(EMPTY_INSTANCE_TYPE_SELECTION); + const [instanceTypeSelection, setInstanceTypeSelection] = useState<{ + instanceType?: string; + manufacturer?: string; + }>({ + instanceType: undefined, + manufacturer: undefined + }); const [templateId, setTemplateId] = useState(); const [instanceKeyword, setInstanceKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState(''); + const [loading, setLoading] = useState(false); const { detailData, @@ -116,6 +115,20 @@ const AddModal: React.FC = ({ : undefined; }; + const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => { + return JSON.stringify( + { + name: instanceType.name, + spec: { + ...instanceType.spec + } + }, + null, + 2 + ); + }; + + // apply the selection of instance type and template const applySelection = ( instanceType: InstanceTypeItem, template: TemplateItem | undefined @@ -126,37 +139,29 @@ const AddModal: React.FC = ({ instanceType: instanceType.name, manufacturer }); + setTemplateId(template?.id); if (template) { - const currentSpec = form.current?.getFieldsValue()?.spec || {}; + const formValues = form.current?.getFieldsValue(); form.current?.setFieldsValue({ - manufacturer: template.manufacturer, - description: JSON.stringify( - { - name: instanceType.name, - spec: { - ...instanceType.spec - } - }, - null, - 2 - ), + description: saveInstanceDataInDescription(instanceType), spec: { - ...currentSpec, + ...formValues?.spec, ...template.spec, - resources: { - ...(currentSpec.resources || {}), - ...(template.spec?.resources || {}) + sshPublicKeys: formValues?.spec?.sshPublicKeys, + volume: { + ...formValues?.spec?.volume } } }); } else { form.current?.setFieldsValue({ - manufacturer: undefined, - description: JSON.stringify(instanceType.spec, null, 2) + description: saveInstanceDataInDescription(instanceType) }); } + + // update form form.current?.applyInstanceType?.(instanceType); }; @@ -175,6 +180,7 @@ const AddModal: React.FC = ({ ); }; + // initial const applyAutoSelection = ( instanceTypes: InstanceTypeItem[], templates: TemplateItem[] @@ -208,7 +214,10 @@ const AddModal: React.FC = ({ useEffect(() => { if (!open) { sessionRef.current += 1; - setInstanceTypeSelection(EMPTY_INSTANCE_TYPE_SELECTION); + setInstanceTypeSelection({ + instanceType: undefined, + manufacturer: undefined + }); setTemplateId(undefined); setInstanceKeyword(''); setTemplateKeyword(''); @@ -240,6 +249,7 @@ const AddModal: React.FC = ({ ) { return false; } + return matchKeyword( [item.name, item.spec?.image, item.spec?.volumeMount], templateKeyword @@ -256,9 +266,14 @@ const AddModal: React.FC = ({ }; const onFinish = async (values: FormData) => { - onOk({ - ...values - }); + setLoading(true); + try { + await onOk({ + ...values + }); + } finally { + setLoading(false); + } }; const handleInstanceTypeChange = (item: InstanceTypeItem) => { @@ -271,10 +286,19 @@ const AddModal: React.FC = ({ const handleTemplateChange = (id: number, item: TemplateItem) => { setTemplateId(id); + const formValues = form.current?.getFieldsValue(); form.current?.setFieldsValue({ spec: { - ...form.current?.getFieldsValue()?.spec, - ...item.spec + ...formValues?.spec, + ...item.spec, + sshPublicKeys: formValues?.spec?.sshPublicKeys, + resources: { + ...item?.spec?.resources, + accelerator: formValues?.spec?.resources?.accelerator + }, + volume: { + ...formValues?.spec?.volume + } } }); }; @@ -400,6 +424,7 @@ const AddModal: React.FC = ({ onOk={handleSubmit} onCancel={handleCancel} showOkBtn={!readonly} + loading={loading} style={{ padding: '16px 24px 8px', display: 'flex', diff --git a/src/pages/gpu-service/instances/components/instance-type-list.tsx b/src/pages/gpu-service/instances/components/instance-type-list.tsx index 212056e2..d4ba983d 100644 --- a/src/pages/gpu-service/instances/components/instance-type-list.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-list.tsx @@ -34,7 +34,7 @@ const InstanceTypeList: React.FC = ({ loading }) => { const handleSelect = (item: InstanceTypeItemModel) => { - if (!isAvailable(item)) return; + if (!isAvailable(item) || value === item.name) return; onChange?.(item); }; diff --git a/src/pages/gpu-service/instances/components/view-events-modal.tsx b/src/pages/gpu-service/instances/components/view-events-modal.tsx index ae2ffb35..5b374b44 100644 --- a/src/pages/gpu-service/instances/components/view-events-modal.tsx +++ b/src/pages/gpu-service/instances/components/view-events-modal.tsx @@ -19,6 +19,7 @@ type ViewEventsModalProps = { name: string; namespace: string; clusterID?: number; + hasPersistentVolume?: boolean; onCancel: () => void; }; @@ -31,7 +32,8 @@ const eventTypeStatus: Record = { const ViewEventsModal: React.FC = (props) => { const intl = useIntl(); - const { open, onCancel, name, namespace, clusterID } = props || {}; + const { open, onCancel, name, namespace, clusterID, hasPersistentVolume } = + props || {}; const [activeKey, setActiveKey] = useState('instance'); const { @@ -54,7 +56,9 @@ const ViewEventsModal: React.FC = (props) => { const refreshAll = () => { if (!name || !namespace || !clusterID) return; fetchInstanceEvents({ name, namespace, clusterID }); - fetchVolumeEvents({ name, namespace, clusterID }); + if (hasPersistentVolume) { + fetchVolumeEvents({ name, namespace, clusterID }); + } }; useEffect(() => { @@ -184,16 +188,20 @@ const ViewEventsModal: React.FC = (props) => { }), children: renderTable(instanceEvents, instanceLoading) }, - { - key: 'volume', - label: intl.formatMessage({ - id: 'gpuservice.instance.event.tab.volume' - }), - children: renderTable(volumeEvents, volumeLoading) - } + ...(hasPersistentVolume + ? [ + { + key: 'volume', + label: intl.formatMessage({ + id: 'gpuservice.instance.event.tab.volume' + }), + children: renderTable(volumeEvents, volumeLoading) + } + ] + : []) ]; - const isLoading = instanceLoading || volumeLoading; + const isLoading = instanceLoading || (hasPersistentVolume && volumeLoading); return ( = forwardRef( instanceType.status?.acceleratorTiers, count ); + setSelectedInstanceType(instanceType); setOnceMaxRequest({ cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest), @@ -237,11 +239,11 @@ const GPUServiceInstanceForm: React.FC = forwardRef( }); form.setFieldsValue({ - clusterId: candidate?.cluster ? Number(candidate.cluster) : null, + clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null, spec: { type: candidate?.name || '', ...(options.writeAccelerator - ? { resources: { accelerator: count } } + ? { resources: { accelerator: _.toString(count) } } : {}) } as any }); @@ -278,91 +280,23 @@ const GPUServiceInstanceForm: React.FC = forwardRef( } if ( - currentData && - (action === PageAction.EDIT || - action === PageAction.VIEW || - realAction === PageAction.CREATE) + action === PageAction.EDIT || + action === PageAction.VIEW || + realAction === PageAction.CREATE ) { - // The server returns `accelerator` as a string; coerce to a number - // so NumberSelection's strict equality picks up the active item. - const persistedAccelerator = currentData.spec?.resources?.accelerator; - const acceleratorAsNumber = - persistedAccelerator != null && persistedAccelerator !== '' - ? Number(persistedAccelerator) - : undefined; form.setFieldsValue({ - name: currentData.name, - displayName: currentData.displayName, - description: currentData.description, - clusterId: currentData.clusterId, - spec: { - ...currentData.spec, - imagePullPolicy: - currentData.spec?.imagePullPolicy || DefaultImagePullPolicy, - resources: { - ...currentData.spec?.resources, - accelerator: acceleratorAsNumber - }, - sshPublicKeys: - currentData.spec?.sshPublicKeys?.map((k) => k.name) ?? [] - } as any, - enable_ssh: !!currentData.spec?.sshPublicKeys?.length + ...currentData, + enable_ssh: !!currentData?.spec?.sshPublicKeys?.length }); - - const candidateName = currentData.spec?.type; - const clusterId = currentData.clusterId; - const aggregate = instanceTypeList.find((item) => - (item.status?.acceleratorTiers ?? []).some((tier) => - (tier.candidates ?? []).some( - (c) => c.name === candidateName && Number(c.cluster) === clusterId - ) - ) - ); - if (aggregate) { - const count = - Number(currentData.spec?.resources?.accelerator) || - (aggregate.spec?.acceleratable ? 1 : 0); - // No accelerator rewrite — the form already has the persisted value. - resolveAndApply(aggregate, count); - } - return; } }, [action, currentData, form, open, realAction, instanceTypeList]); const handleFinish = async (values: InstanceFormValues) => { - const selectedKeys = (values.spec as any)?.sshPublicKeys as - | string[] - | undefined; - const volume = values.spec?.volume ?? {}; - const normalizedVolume = volume.persistentTemplate - ? { - persistentTemplate: { - ...volume.persistentTemplate, - name: volume.persistentTemplate.name || values.name || '' - } - } - : volume.persistent - ? { persistent: volume.persistent } - : volume.ephemeral - ? { ephemeral: volume.ephemeral } - : {}; - - // `spec.type` and `clusterId` are kept in sync with the resolved - // candidate via `resolveAndApply`, so submission is a straight pass - // -through. The API expects `accelerator` as a string per the schema. - const acceleratorValue = values.spec?.resources?.accelerator; - const normalizedResources = { - ...values.spec?.resources, - accelerator: - acceleratorValue != null && acceleratorValue !== '' - ? String(acceleratorValue) - : undefined - }; - const submittedPorts = [...(values.spec?.ports ?? [])]; const submittedHasSSHPort = submittedPorts.some( (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT ); + if (values.enable_ssh && !submittedHasSSHPort) { submittedPorts.push({ protocol: 'TCP', @@ -370,17 +304,12 @@ const GPUServiceInstanceForm: React.FC = forwardRef( name: 'SSH' }); } - + console.log('submit values', values, submittedPorts); await onFinish({ - ...values, + ..._.omit(values, ['enable_ssh']), spec: { ...values.spec, - ports: submittedPorts, - resources: normalizedResources, - volume: normalizedVolume, - sshPublicKeys: values.enable_ssh - ? (selectedKeys ?? []).map((name) => ({ name })) - : [] + ports: submittedPorts } }); }; @@ -451,7 +380,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( }, volume: { ephemeral: { - capacity: '20Gi' + capacity: '50Gi' }, persistent: { name: '' @@ -462,8 +391,6 @@ const GPUServiceInstanceForm: React.FC = forwardRef( }} > - {/* Hidden form field: the candidate.cluster resolved from the - selected aggregate + accelerator count. */} @@ -537,6 +464,16 @@ const GPUServiceInstanceForm: React.FC = forwardRef( style={{ marginBottom: 12 }} + normalize={(value) => + Array.isArray(value) + ? value?.map((item) => ({ name: item })) + : [] + } + getValueProps={(value) => ({ + value: Array.isArray(value) + ? value.map((item) => item?.name ?? item) + : [] + })} rules={[ { required: true, diff --git a/src/pages/gpu-service/instances/forms/instance-type.tsx b/src/pages/gpu-service/instances/forms/instance-type.tsx index 2859ab6c..c548b382 100644 --- a/src/pages/gpu-service/instances/forms/instance-type.tsx +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -79,13 +79,6 @@ const InstanceTypeFormItem: React.FC = ({ return selectedInstanceType?.spec?.acceleratable; }, [selectedInstanceType, action]); - const selectedInstanceData = useMemo(() => { - if (action === PageAction.EDIT) { - return JSON.parse(currentData?.description || '{}') || {}; - } - return selectedInstanceType; - }, [selectedInstanceType, action, currentData]); - const renderInstanceType = () => { const description = JSON.parse(currentData?.description || '{}').spec || {}; return ( @@ -130,6 +123,10 @@ const InstanceTypeFormItem: React.FC = ({ name={['spec', 'resources', 'accelerator']} hidden={action === PageAction.EDIT} + normalize={(value) => (value ? _.toString(value) : undefined)} + getValueProps={(value) => ({ + value: value ? _.toNumber(value) : undefined + })} rules={[ { required: true, diff --git a/src/pages/gpu-service/instances/forms/storage-overlay.tsx b/src/pages/gpu-service/instances/forms/storage-overlay.tsx index 1c91a58a..a6bb897d 100644 --- a/src/pages/gpu-service/instances/forms/storage-overlay.tsx +++ b/src/pages/gpu-service/instances/forms/storage-overlay.tsx @@ -1,8 +1,10 @@ import { PageAction } from '@/config'; import FormOverlayView from '@/pages/_components/form-overlay-view'; +import { FormContext } from '@/pages/gpu-service/storage/config/form-context'; +import useQueryStorageClass from '@/pages/gpu-service/storage/services/use-query-storage-class'; import { ModalFooter } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { FormData as StorageFormData } from '../../storage/config/types'; import GPUServiceStorageForm from '../../storage/forms'; @@ -18,8 +20,17 @@ const StorageOverlay: React.FC = ({ onSubmit }) => { const intl = useIntl(); + const { storageClassList, fetchData: fetchStorageClass } = + useQueryStorageClass(); + const [loading, setLoading] = useState(false); const formRef = useRef(null); + useEffect(() => { + if (open) { + fetchStorageClass({ page: -1 }); + } + }, [open]); + const handleSubmit = () => { formRef.current?.submit(); }; @@ -30,7 +41,12 @@ const StorageOverlay: React.FC = ({ }; const handleFinish = async (values: StorageFormData) => { - await onSubmit(values); + setLoading(true); + try { + await onSubmit(values); + } finally { + setLoading(false); + } }; const getOverlayContainer = useCallback(() => { @@ -52,6 +68,7 @@ const StorageOverlay: React.FC = ({ = ({ /> } > - + + + ); }; diff --git a/src/pages/gpu-service/instances/forms/storage-volume.tsx b/src/pages/gpu-service/instances/forms/storage-volume.tsx index 97786298..62259c16 100644 --- a/src/pages/gpu-service/instances/forms/storage-volume.tsx +++ b/src/pages/gpu-service/instances/forms/storage-volume.tsx @@ -66,12 +66,18 @@ const StorageVolume = ({ const applyMode = (mode: string) => { if (mode === StorageModeValueMap.Temporary) { - form.setFieldValue(['spec', 'volume'], { - ephemeral: { capacity: `${DEFAULT_TEMP_CAPACITY_GB}Gi` } - }); + form.setFieldValue( + ['spec', 'volume', 'ephemeral', 'capacity'], + form.getFieldValue(['spec', 'volume', 'ephemeral', 'capacity']) || + DEFAULT_TEMP_CAPACITY_GB + ); return; } - form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } }); + form.setFieldValue( + ['spec', 'volume', 'persistent', 'name'], + form.getFieldValue(['spec', 'volume', 'persistent', 'name']) || + (storageOptions[0]?.value as string) + ); }; const handleModeChange = (mode: string) => { @@ -82,10 +88,8 @@ const StorageVolume = ({ const handleCreateStorage = async (values: StorageFormData) => { try { await createStorage({ data: values }); - await fetchStorage({ page: 1, perPage: 100 }); - form.setFieldValue(['spec', 'volume'], { - persistent: { name: values.name } - }); + await fetchStorage({ page: -1 }); + form.setFieldValue(['spec', 'volume', 'persistent', 'name'], values.name); setOverlayOpen(false); } catch (error) { // ignore diff --git a/src/pages/gpu-service/instances/forms/template-selector.tsx b/src/pages/gpu-service/instances/forms/template-selector.tsx index 72380c07..98e0ca12 100644 --- a/src/pages/gpu-service/instances/forms/template-selector.tsx +++ b/src/pages/gpu-service/instances/forms/template-selector.tsx @@ -53,6 +53,12 @@ const TemplateSelector: React.FC = ({ dataList = [] }) => { const intl = useIntl(); + + const handleSelect = (item: TemplateItem) => { + if (value === item.id) return; + onChange?.(item.id, item); + }; + return ( {dataList.map((item: TemplateItem) => ( @@ -63,7 +69,7 @@ const TemplateSelector: React.FC = ({ hoverable height={102} active={value === item.id} - onClick={() => onChange?.(item.id, item)} + onClick={() => handleSelect(item)} >
diff --git a/src/pages/gpu-service/instances/hooks/use-view-events.ts b/src/pages/gpu-service/instances/hooks/use-view-events.ts index c7565c01..ecee914d 100644 --- a/src/pages/gpu-service/instances/hooks/use-view-events.ts +++ b/src/pages/gpu-service/instances/hooks/use-view-events.ts @@ -9,19 +9,23 @@ const useViewEvents = () => { name: string; namespace: string; clusterID?: number; + hasPersistentVolume: boolean; }>({ open: false, name: '', namespace: '', - clusterID: undefined + clusterID: undefined, + hasPersistentVolume: false }); const openModal = (row?: ListItem) => { + const volume = row?.spec?.volume; setOpenModalStatus({ open: true, name: row?.name || '', namespace: row?.status?.namespace || '', - clusterID: row?.clusterId ?? undefined + clusterID: row?.clusterId ?? undefined, + hasPersistentVolume: !!volume?.persistent?.name }); }; @@ -30,7 +34,8 @@ const useViewEvents = () => { open: false, name: '', namespace: '', - clusterID: undefined + clusterID: undefined, + hasPersistentVolume: false }); }; diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index e443d8cc..79a0dd6e 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -80,7 +80,7 @@ const GPUService: React.FC = () => { if (openInstanceModalStatus.realAction === PageAction.CREATE) { await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id); await new Promise((resolve) => { - setTimeout(resolve, 500); + setTimeout(resolve, 300); }); await createInstance({ data }); } else if (openInstanceModalStatus.action === PageAction.EDIT) { @@ -233,6 +233,7 @@ const GPUService: React.FC = () => { name={openViewEventsModalStatus.name} namespace={openViewEventsModalStatus.namespace} clusterID={openViewEventsModalStatus.clusterID} + hasPersistentVolume={openViewEventsModalStatus.hasPersistentVolume} onCancel={closeViewEventsModal} /> diff --git a/src/pages/gpu-service/storage-types/forms/basic.tsx b/src/pages/gpu-service/storage-types/forms/basic.tsx index 2d7e4562..d07ba052 100644 --- a/src/pages/gpu-service/storage-types/forms/basic.tsx +++ b/src/pages/gpu-service/storage-types/forms/basic.tsx @@ -2,7 +2,6 @@ import { PageAction, validateLabelNameRegxFor63 } from '@/config'; import { Input as CInput, Select as SealSelect, - Textarea, useAppUtils } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; @@ -43,13 +42,6 @@ const Basic = ({ action }: { action: string }) => { label={intl.formatMessage({ id: 'common.table.displayName' })} /> - name="description"> -