diff --git a/src/locales/en-US/clusters.ts b/src/locales/en-US/clusters.ts index 686a7188..bc15f8a0 100644 --- a/src/locales/en-US/clusters.ts +++ b/src/locales/en-US/clusters.ts @@ -182,7 +182,8 @@ export default { 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuInstances.title': 'GPU Instance Service', 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', - 'clusters.gpuInstances.staticAddress': 'Static Access Address', + 'clusters.gpuInstances.staticAddress': + 'GPU Instance Service Static Access Address', 'clusters.gpuInstances.staticAddress.tip': 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' }; diff --git a/src/locales/ja-JP/clusters.ts b/src/locales/ja-JP/clusters.ts index 25c6b04e..7aa75394 100644 --- a/src/locales/ja-JP/clusters.ts +++ b/src/locales/ja-JP/clusters.ts @@ -182,7 +182,8 @@ export default { 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuInstances.title': 'GPU Instance Service', 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', - 'clusters.gpuInstances.staticAddress': 'Static Access Address', + 'clusters.gpuInstances.staticAddress': + 'GPU Instance Service Static Access Address', 'clusters.gpuInstances.staticAddress.tip': 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' }; diff --git a/src/locales/ru-RU/clusters.ts b/src/locales/ru-RU/clusters.ts index 9158c9d0..020a7482 100644 --- a/src/locales/ru-RU/clusters.ts +++ b/src/locales/ru-RU/clusters.ts @@ -183,7 +183,8 @@ export default { 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuInstances.title': 'GPU Instance Service', 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', - 'clusters.gpuInstances.staticAddress': 'Static Access Address', + 'clusters.gpuInstances.staticAddress': + 'GPU Instance Service Static Access Address', 'clusters.gpuInstances.staticAddress.tip': 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' }; diff --git a/src/locales/tr-TR/clusters.ts b/src/locales/tr-TR/clusters.ts index 8ee15324..ec650c3f 100644 --- a/src/locales/tr-TR/clusters.ts +++ b/src/locales/tr-TR/clusters.ts @@ -183,7 +183,8 @@ export default { 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuInstances.title': 'GPU Instance Service', 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', - 'clusters.gpuInstances.staticAddress': 'Static Access Address', + 'clusters.gpuInstances.staticAddress': + 'GPU Instance Service Static Access Address', 'clusters.gpuInstances.staticAddress.tip': 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' }; diff --git a/src/locales/zh-CN/clusters.ts b/src/locales/zh-CN/clusters.ts index 3e30f57e..5bc5d7c4 100644 --- a/src/locales/zh-CN/clusters.ts +++ b/src/locales/zh-CN/clusters.ts @@ -175,7 +175,7 @@ export default { '集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。', 'clusters.gpuInstances.title': 'GPU 实例服务', 'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。', - 'clusters.gpuInstances.staticAddress': '静态访问地址', + 'clusters.gpuInstances.staticAddress': 'GPU实例服务静态访问地址', 'clusters.gpuInstances.staticAddress.tip': 'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。' }; diff --git a/src/pages/cluster-management/components/cluster-form.tsx b/src/pages/cluster-management/components/cluster-form.tsx index a8f40a70..a1339278 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -25,7 +25,7 @@ import { } from '../config/types'; import AdvanceConfig from '../step-forms/advance-config'; import CloudProvider from './cloud-provider-form'; -import K8sPodSpec from './k8s-pod-spec'; +import K8sAdvancedOptions, { GpuInstanceServiceSwitch } from './k8s-pod-spec'; type AddModalProps = { action: PageActionType; @@ -40,9 +40,6 @@ const ClusterForm: React.FC = forwardRef( const [form] = Form.useForm(); const intl = useIntl(); const [activeKey, setActiveKey] = React.useState([]); - const [k8sActiveKey, setK8sActiveKey] = React.useState([ - 'k8sOptions' - ]); const [submitAttempted, setSubmitAttempted] = useState(false); const advanceConfigRef = React.useRef(null); const systemConfig = useAtomValue(systemConfigAtom); @@ -247,7 +244,14 @@ const ClusterForm: React.FC = forwardRef( name="description" rules={[{ required: false }]} - style={{ marginBottom: 8 }} + // For Kubernetes the GPU instance service switch follows directly, + // so fall back to the default item margin (matching the name field) + // to keep the description spacing symmetric; other providers keep + // the tighter gap before the advanced panel. + style={{ + marginBottom: + provider === ProviderValueMap.Kubernetes ? undefined : 8 + }} > = forwardRef( {provider === ProviderValueMap.Kubernetes && ( - - setK8sActiveKey(Array.isArray(keys) ? keys : [keys]) - } - items={[ - { - key: 'k8sOptions', - label: intl.formatMessage({ - id: 'clusters.k8sOptions.title' - }), - forceRender: true, - children: ( - - ) - } - ]} - > + )} = forwardRef( label: intl.formatMessage({ id: 'resources.form.advanced' }), forceRender: true, children: ( - + <> + {provider === ProviderValueMap.Kubernetes && ( + + )} + + ) } ]} diff --git a/src/pages/cluster-management/components/k8s-pod-spec.tsx b/src/pages/cluster-management/components/k8s-pod-spec.tsx index 397ca805..b8bcd899 100644 --- a/src/pages/cluster-management/components/k8s-pod-spec.tsx +++ b/src/pages/cluster-management/components/k8s-pod-spec.tsx @@ -7,21 +7,20 @@ import styled from 'styled-components'; import ImageCredential from './image-credential'; import K8SVolumeMount from './k8s-volume-mount'; -const Title = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - background-color: transparent; - font-weight: 500; - font-size: 14px; - padding-top: 0px; - padding-bottom: 8px; -`; - const SectionWrap = styled.div` margin-bottom: 16px; `; +// SwitchCard always renders a content
while the switch is on (it's meant +// to wrap expandable children). We render no children here, so that div is +// empty — but the card is a flex column with an 8px gap, so the empty div still +// adds height and makes the card jump as the switch toggles. Hide it. +const SwitchCardWrap = styled.div` + & > div > div:empty { + display: none; + } +`; + const NodeSelectorForm: React.FC = () => { const intl = useIntl(); @@ -57,7 +56,7 @@ const NamespaceForm: React.FC = () => { ); }; -const OperatorImageForm: React.FC = () => { +export const OperatorImageForm: React.FC = () => { const intl = useIntl(); return ( @@ -76,55 +75,87 @@ const OperatorImageForm: React.FC = () => { ); }; -const GpuInstanceOptionsForm: React.FC = () => { +// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth +// for whether GPU instances are enabled. Both the switch (rendered up top) and +// the static-address field (rendered in the advanced section) watch this same +// path so they stay in sync without sharing local state. +const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions']; + +// Standalone switch shown directly under the cluster description. Toggling it +// only flips the presence of `gpuInstanceOptions` on the form; the related +// inputs live in the advanced section. +export const GpuInstanceServiceSwitch: React.FC = () => { const intl = useIntl(); const form = Form.useFormInstance(); - // The presence of `gpuInstanceOptions` on `k8s_options` is the source of - // truth for whether GPU instances are enabled. Derive the toggle directly - // from the watched form value rather than mirroring it into local state, so - // it stays in sync when the form loads async values (EDIT) or resets. - const gpuInstanceOptions = Form.useWatch( - ['k8s_options', 'gpuInstanceOptions'], - form - ); + // `preserve: true` makes useWatch read the full form store rather than only + // registered fields — required here because `gpuInstanceOptions` is set via + // setFieldValue and has no mounted Form.Item of its own while the switch is + // off, so a default watch would never see it flip on. + const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { + form, + preserve: true + }); const enabled = !!gpuInstanceOptions; const handleToggle = (checked: boolean) => { if (!form) return; - // The presence of `gpuInstanceOptions` is what signals "GPU instances - // enabled" to the backend — when on, ensure the object exists (defaulting - // to {} so it survives even when the static address is left blank); when - // off, remove it entirely. - const path = ['k8s_options', 'gpuInstanceOptions']; + // When on, ensure the object exists (defaulting to {} so it survives even + // when the static address is left blank); when off, remove it entirely. if (checked) { - form.setFieldValue(path, form.getFieldValue(path) ?? {}); + form.setFieldValue( + GPU_INSTANCE_OPTIONS_PATH, + form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {} + ); } else { - form.setFieldValue(path, undefined); + form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined); } }; return ( - + + + + ); +}; + +// Static access address for GPU instances. Only shown while the GPU instance +// service switch is on, mirroring the previous in-card behaviour. Rendered in +// the advanced section, between the default container registry and the worker +// config (节点配置). +export const GpuInstancesStaticAddressForm: React.FC = () => { + const intl = useIntl(); + // See note in GpuInstanceServiceSwitch: watch the full store so this field's + // visibility tracks the switch even before it has mounted its own Form.Item. + const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { + preserve: true + }); + + if (!enabled) { + return null; + } + + return ( + value || null} - noStyle > { })} > - + ); }; -const K8sPodSpec: React.FC<{ +// Kubernetes-specific options that live inside the cluster's advanced section. +const K8sAdvancedOptions: React.FC<{ action: PageActionType; }> = ({ action }) => { return ( <> - - ); }; -export default K8sPodSpec; +export default K8sAdvancedOptions; diff --git a/src/pages/cluster-management/components/k8s-volume-mount.tsx b/src/pages/cluster-management/components/k8s-volume-mount.tsx index 6efe245a..b7532e4d 100644 --- a/src/pages/cluster-management/components/k8s-volume-mount.tsx +++ b/src/pages/cluster-management/components/k8s-volume-mount.tsx @@ -40,7 +40,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { const k8sVolumeMounts = Form.useWatch(['k8s_options', 'volumeMounts'], form); const [collapseKey, setCollapseKey] = useState>( - new Set([0]) + new Set() ); const volumeList = [ diff --git a/src/pages/cluster-management/step-forms/advance-config.tsx b/src/pages/cluster-management/step-forms/advance-config.tsx index 20a27b00..9681dc71 100644 --- a/src/pages/cluster-management/step-forms/advance-config.tsx +++ b/src/pages/cluster-management/step-forms/advance-config.tsx @@ -7,6 +7,10 @@ import { useIntl } from '@umijs/max'; import { Button, Form } from 'antd'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import styled from 'styled-components'; +import { + GpuInstancesStaticAddressForm, + OperatorImageForm +} from '../components/k8s-pod-spec'; import { ProviderType, ProviderValueMap } from '../config'; import { ClusterFormData as FormData, @@ -107,6 +111,12 @@ const ClusterAdvanceConfig: React.FC<{ placeholder="docker.io" > + {provider === ProviderValueMap.Kubernetes && ( + <> + + + + )} {intl.formatMessage({ id: 'clusters.create.workerConfig' })}