diff --git a/src/locales/en-US/clusters.ts b/src/locales/en-US/clusters.ts index c92b3fc3..975e7386 100644 --- a/src/locales/en-US/clusters.ts +++ b/src/locales/en-US/clusters.ts @@ -184,11 +184,14 @@ export default { 'clusters.namespace.title': 'Namespace', 'clusters.namespace.tip': 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', - 'clusters.gpuInstances.title': 'GPU Instance Service', + 'clusters.clusterType.title': 'Cluster Type', + 'clusters.modelService.title': 'Model Service', + 'clusters.modelService.tip': + 'For LLM inference and API serving — e.g. exposing model APIs and token-based services.', + 'clusters.gpuInstances.title': 'GPU Service', 'clusters.gpuInstances.tip': - 'Once enabled, the cluster is dedicated to GPU instance service and cannot be used for model service.', - 'clusters.gpuInstances.staticAddress': - 'GPU Instance Service Static Access Address', + 'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.', + 'clusters.gpuInstances.staticAddress': 'GPU 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 e655636c..3488957b 100644 --- a/src/locales/ja-JP/clusters.ts +++ b/src/locales/ja-JP/clusters.ts @@ -184,11 +184,14 @@ export default { 'clusters.namespace.title': 'Namespace', 'clusters.namespace.tip': 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', - 'clusters.gpuInstances.title': 'GPU Instance Service', + 'clusters.clusterType.title': 'Cluster Type', + 'clusters.modelService.title': 'Model Service', + 'clusters.modelService.tip': + 'For LLM inference and API serving — e.g. exposing model APIs and token-based services.', + 'clusters.gpuInstances.title': 'GPU Service', 'clusters.gpuInstances.tip': - 'Once enabled, the cluster is dedicated to GPU instance service and cannot be used for model service.', - 'clusters.gpuInstances.staticAddress': - 'GPU Instance Service Static Access Address', + 'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.', + 'clusters.gpuInstances.staticAddress': 'GPU 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 92406fdf..f10abe47 100644 --- a/src/locales/ru-RU/clusters.ts +++ b/src/locales/ru-RU/clusters.ts @@ -185,11 +185,14 @@ export default { 'clusters.namespace.title': 'Namespace', 'clusters.namespace.tip': 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', - 'clusters.gpuInstances.title': 'GPU Instance Service', + 'clusters.clusterType.title': 'Cluster Type', + 'clusters.modelService.title': 'Model Service', + 'clusters.modelService.tip': + 'For LLM inference and API serving — e.g. exposing model APIs and token-based services.', + 'clusters.gpuInstances.title': 'GPU Service', 'clusters.gpuInstances.tip': - 'Once enabled, the cluster is dedicated to GPU instance service and cannot be used for model service.', - 'clusters.gpuInstances.staticAddress': - 'GPU Instance Service Static Access Address', + 'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.', + 'clusters.gpuInstances.staticAddress': 'GPU 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 efacc6d3..6223f95a 100644 --- a/src/locales/tr-TR/clusters.ts +++ b/src/locales/tr-TR/clusters.ts @@ -185,11 +185,14 @@ export default { 'clusters.namespace.title': 'Namespace', 'clusters.namespace.tip': 'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.', - 'clusters.gpuInstances.title': 'GPU Instance Service', + 'clusters.clusterType.title': 'Cluster Type', + 'clusters.modelService.title': 'Model Service', + 'clusters.modelService.tip': + 'For LLM inference and API serving — e.g. exposing model APIs and token-based services.', + 'clusters.gpuInstances.title': 'GPU Service', 'clusters.gpuInstances.tip': - 'Once enabled, the cluster is dedicated to GPU instance service and cannot be used for model service.', - 'clusters.gpuInstances.staticAddress': - 'GPU Instance Service Static Access Address', + 'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.', + 'clusters.gpuInstances.staticAddress': 'GPU 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 f4e87e8e..1f2f06d0 100644 --- a/src/locales/zh-CN/clusters.ts +++ b/src/locales/zh-CN/clusters.ts @@ -177,10 +177,14 @@ export default { 'clusters.namespace.title': '命名空间', 'clusters.namespace.tip': '集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。', - 'clusters.gpuInstances.title': 'GPU 实例服务', + 'clusters.clusterType.title': '集群类型', + 'clusters.modelService.title': '模型服务', + 'clusters.modelService.tip': + '适用于大模型推理与 API 服务化场景,例如对外提供模型 API 与 Token 服务能力。', + 'clusters.gpuInstances.title': 'GPU 服务', 'clusters.gpuInstances.tip': - '启用后该集群将专用于 GPU 实例服务,不可用于模型服务。', - 'clusters.gpuInstances.staticAddress': 'GPU实例服务静态访问地址', + '适用于按需分配 GPU 计算资源的场景,例如交互式开发、训练任务或自定义运行环境。', + '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 424299a1..84781d68 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -26,7 +26,7 @@ import { import AdvanceConfig from '../step-forms/advance-config'; import CloudProvider from './cloud-provider-form'; import K8sAdvancedOptions, { - GpuInstanceServiceSwitch, + ClusterTypeSelector, K8sOptionsChangeWatcher } from './k8s-pod-spec'; @@ -260,8 +260,8 @@ const ClusterForm: React.FC = forwardRef( name="description" rules={[{ required: false }]} - // For Kubernetes the GPU instance service switch follows directly, - // so fall back to the default item margin (matching the name field) + // For Kubernetes the cluster type selector 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={{ @@ -275,9 +275,7 @@ const ClusterForm: React.FC = forwardRef( > - {provider === ProviderValueMap.Kubernetes && ( - - )} + {provider === ProviderValueMap.Kubernetes && } 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(); @@ -79,32 +69,123 @@ export const OperatorImageForm: 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. +// for whether GPU instances are enabled. Both the cluster-type selector +// (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 = () => { +// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends +// in with surrounding form fields: same border, radius, padding, and +// typography. The only differences are the two-column grid layout and an +// active state (blue border + tinted background) to mark the selection. +const ClusterTypeWrap = styled.div` + margin-bottom: 24px; +`; + +const ClusterTypeLabel = styled.div` + color: var(--ant-color-text); + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + .required { + color: var(--ant-color-error); + margin-left: 4px; + } +`; + +const ClusterTypeGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +`; + +const ClusterTypeCard = styled.div<{ $active: boolean }>` + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border-radius: var(--ant-border-radius-lg); + border: 1px solid + ${(p) => + p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'}; + background: ${(p) => + p.$active ? 'var(--ant-color-primary-bg)' : 'transparent'}; + cursor: pointer; + transition: + border-color 0.2s, + background-color 0.2s; + &:hover, + &:focus-visible { + border-color: var(--ant-color-primary); + } + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--ant-control-outline); + } + .body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .title { + color: var(--ant-color-text); + font-size: 14px; + font-weight: 500; + } + .description { + color: var(--ant-color-text-secondary); + } +`; + +const RadioDot = styled.span<{ $active: boolean }>` + position: relative; + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 3px; + border-radius: 50%; + border: 1.5px solid + ${(p) => + p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'}; + background: ${(p) => + p.$active ? 'var(--ant-color-primary)' : 'transparent'}; + transition: all 0.2s; + &::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: #fff; + opacity: ${(p) => (p.$active ? 1 : 0)}; + transition: opacity 0.2s; + } +`; + +// Card-based selector for cluster type. The two options are mutually exclusive +// and the choice maps directly to the presence/absence of `gpuInstanceOptions` +// on the form — "model" clears it, "gpu" seeds it to {} (preserving any +// already-entered static address). No standalone form field is registered; +// state is read via useWatch with `preserve: true` so it tracks updates made +// through setFieldValue. +export const ClusterTypeSelector: React.FC = () => { const intl = useIntl(); const form = Form.useFormInstance(); - // `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 labelId = useId(); const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { form, preserve: true }); - const enabled = !!gpuInstanceOptions; + const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model'; - const handleToggle = (checked: boolean) => { - if (!form) return; - // 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) { + const handleSelect = (next: 'model' | 'gpu') => { + if (!form || next === value) return; + if (next === 'gpu') { form.setFieldValue( GPU_INSTANCE_OPTIONS_PATH, form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {} @@ -114,33 +195,68 @@ export const GpuInstanceServiceSwitch: React.FC = () => { } }; + const options: { + key: 'model' | 'gpu'; + title: string; + description: string; + }[] = [ + { + key: 'model', + title: intl.formatMessage({ id: 'clusters.modelService.title' }), + description: intl.formatMessage({ id: 'clusters.modelService.tip' }) + }, + { + key: 'gpu', + title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }), + description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }) + } + ]; + return ( - - - + + + {intl.formatMessage({ id: 'clusters.clusterType.title' })} + * + + + {options.map((opt) => { + const active = value === opt.key; + return ( + handleSelect(opt.key)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(opt.key); + } + }} + > + +
+
{opt.title}
+
{opt.description}
+
+
+ ); + })} +
+
); }; -// 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 (节点配置). +// Static access address for GPU instances. Only shown when "GPU 服务" is +// the selected cluster type. 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. + // See note in ClusterTypeSelector: watch the full store so this field's + // visibility tracks the selector even before it has mounted its own + // Form.Item. const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { preserve: true }); @@ -209,7 +325,7 @@ export const K8sOptionsChangeWatcher: React.FC<{ }> = ({ action, currentData, onChange }) => { // `preserve: true` so the watch tracks the full store, including // gpuInstanceOptions which is toggled via setFieldValue without a mounted - // Form.Item (mirrors GpuInstanceServiceSwitch). + // Form.Item (mirrors ClusterTypeSelector). const k8sOptions = Form.useWatch(['k8s_options'], { preserve: true }); const changed =