From acb90531b27bcc6599d9d25242f38eae96f92989 Mon Sep 17 00:00:00 2001 From: Yuxing Deng Date: Tue, 26 May 2026 15:44:00 +0800 Subject: [PATCH] feat: extend cluster k8s_options with multi-vendor manifest support - New k8s_pod_spec form sections (image credentials, node selector, gpu vendor overrides) under k8s_options, replacing the legacy flat k8s_volume_mounts list. UI keys aligned to the backend wire shape (snake_case k8s_options + camelCase inside). - System default container registry is pre-filled into the first image credential when creating a new cluster; empty username/password are coerced to null on submit to match the Optional[str] backend schema. - Register cluster flow supports multi-runtime selection gated by the cluster's gpuVendorOverrides: non-override vendors stay single-select with an inline hint; multi-add only opens once an override vendor is picked, and non-override cards become disabled in that state. - Manifest URL emits multiple ?runtime= params; check-env step combines per-vendor commands; downstream steps are disabled when no vendor is selected. - Pre-validate gpuVendorOverrides at save time (non-empty selector, no duplicates across vendors, no key clash with base nodeSelector) so the user sees the error before hitting the manifest endpoint. - Misc: dark-mode background of the k8s_pod_spec / volume mount titles no longer clashes with the drawer; cluster Steps no longer leaks the internal showModules/showForms props to the DOM. --- src/locales/en-US/clusters.ts | 25 +- src/locales/ja-JP/clusters.ts | 25 +- src/locales/ru-RU/clusters.ts | 25 +- src/locales/tr-TR/clusters.ts | 25 +- src/locales/zh-CN/clusters.ts | 25 +- .../components/add-worker/add-worker-step.tsx | 34 +- .../add-worker/check-environment.tsx | 7 +- .../components/add-worker/config.ts | 3 + .../components/add-worker/k8s-run-command.tsx | 2 + .../components/add-worker/select-vendor.tsx | 199 +++++++-- .../components/check-env-command.tsx | 23 +- .../components/cluster-form.tsx | 173 ++++++-- .../components/cluster-steps.tsx | 16 +- .../components/k8s-pod-spec.tsx | 413 ++++++++++++++++++ .../components/k8s-volume-mount.tsx | 48 +- .../components/provider-catalog.tsx | 9 +- .../components/register-cluster-inner.tsx | 9 +- .../components/support-gpus.tsx | 18 +- src/pages/cluster-management/config/index.ts | 17 +- src/pages/cluster-management/config/types.ts | 23 +- .../step-forms/advance-config.tsx | 16 +- 21 files changed, 1023 insertions(+), 112 deletions(-) create mode 100644 src/pages/cluster-management/components/k8s-pod-spec.tsx diff --git a/src/locales/en-US/clusters.ts b/src/locales/en-US/clusters.ts index 38cb2d01..92a10411 100644 --- a/src/locales/en-US/clusters.ts +++ b/src/locales/en-US/clusters.ts @@ -69,6 +69,9 @@ export default { 'clusters.addworker.selectCluster.tips': 'For non-Docker clusters, please register clusters or manage worker pools from the Clusters page.', 'clusters.addworker.selectGPU': 'Select GPU Vendor', + 'clusters.addworker.selectGPU.multiTag': 'Multi-select', + 'clusters.addworker.selectGPU.singleOnly': + 'The selected vendor is not in this cluster’s GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.', 'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.runCommand': 'Run Command', @@ -160,5 +163,25 @@ export default { 'clusters.volume.pvc.readOnly': 'Read Only', 'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.optional': 'Optional', - 'clusters.volume.add': 'Add Volume Mount' + 'clusters.volume.add': 'Add Volume Mount', + 'clusters.imageCredentials.title': 'Image Credentials', + 'clusters.imageCredentials.add': 'Add Credential', + 'clusters.imageCredentials.registry': 'Registry', + 'clusters.imageCredentials.username': 'Username', + 'clusters.imageCredentials.password': 'Password', + 'clusters.nodeSelector.title': 'Node Selector', + 'clusters.nodeSelector.tip': + 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', + 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides', + 'clusters.gpuVendorOverrides.validate.emptySelector': + 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.', + 'clusters.gpuVendorOverrides.validate.duplicate': + 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.', + 'clusters.gpuVendorOverrides.validate.keyConflict': + 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.', + 'clusters.gpuVendorOverrides.tip': + 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', + 'clusters.gpuVendorOverrides.add': 'Add Override', + 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', + 'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector' }; diff --git a/src/locales/ja-JP/clusters.ts b/src/locales/ja-JP/clusters.ts index b5dba540..903945d6 100644 --- a/src/locales/ja-JP/clusters.ts +++ b/src/locales/ja-JP/clusters.ts @@ -69,6 +69,9 @@ export default { 'clusters.addworker.selectCluster.tips': 'For non-Docker clusters, please register clusters or manage worker pools from the Clusters page.', 'clusters.addworker.selectGPU': 'Select GPU Vendor', + 'clusters.addworker.selectGPU.multiTag': 'Multi-select', + 'clusters.addworker.selectGPU.singleOnly': + 'The selected vendor is not in this cluster’s GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.', 'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.runCommand': 'Run Command', @@ -160,7 +163,27 @@ export default { 'clusters.volume.pvc.readOnly': 'Read Only', 'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.optional': 'Optional', - 'clusters.volume.add': 'Add Volume Mount' + 'clusters.volume.add': 'Add Volume Mount', + 'clusters.imageCredentials.title': 'Image Credentials', + 'clusters.imageCredentials.add': 'Add Credential', + 'clusters.imageCredentials.registry': 'Registry', + 'clusters.imageCredentials.username': 'Username', + 'clusters.imageCredentials.password': 'Password', + 'clusters.nodeSelector.title': 'Node Selector', + 'clusters.nodeSelector.tip': + 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', + 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides', + 'clusters.gpuVendorOverrides.validate.emptySelector': + 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.', + 'clusters.gpuVendorOverrides.validate.duplicate': + 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.', + 'clusters.gpuVendorOverrides.validate.keyConflict': + 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.', + 'clusters.gpuVendorOverrides.tip': + 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', + 'clusters.gpuVendorOverrides.add': 'Add Override', + 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', + 'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/ru-RU/clusters.ts b/src/locales/ru-RU/clusters.ts index 01981fb6..fea947d7 100644 --- a/src/locales/ru-RU/clusters.ts +++ b/src/locales/ru-RU/clusters.ts @@ -69,6 +69,9 @@ export default { 'clusters.addworker.selectCluster.tips': 'Для не-Docker кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.', 'clusters.addworker.selectGPU': 'Выбрать производителя GPU', + 'clusters.addworker.selectGPU.multiTag': 'Multi-select', + 'clusters.addworker.selectGPU.singleOnly': + 'The selected vendor is not in this cluster’s GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.', 'clusters.addworker.checkEnv': 'Проверить окружение', 'clusters.addworker.specifyArgs': 'Указать аргументы', 'clusters.addworker.runCommand': 'Выполнить команду', @@ -161,7 +164,27 @@ export default { 'clusters.volume.pvc.readOnly': 'Read Only', 'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.optional': 'Optional', - 'clusters.volume.add': 'Add Volume Mount' + 'clusters.volume.add': 'Add Volume Mount', + 'clusters.imageCredentials.title': 'Image Credentials', + 'clusters.imageCredentials.add': 'Add Credential', + 'clusters.imageCredentials.registry': 'Registry', + 'clusters.imageCredentials.username': 'Username', + 'clusters.imageCredentials.password': 'Password', + 'clusters.nodeSelector.title': 'Node Selector', + 'clusters.nodeSelector.tip': + 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', + 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides', + 'clusters.gpuVendorOverrides.validate.emptySelector': + 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.', + 'clusters.gpuVendorOverrides.validate.duplicate': + 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.', + 'clusters.gpuVendorOverrides.validate.keyConflict': + 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.', + 'clusters.gpuVendorOverrides.tip': + 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', + 'clusters.gpuVendorOverrides.add': 'Add Override', + 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', + 'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/tr-TR/clusters.ts b/src/locales/tr-TR/clusters.ts index 805e430e..5ee071ee 100644 --- a/src/locales/tr-TR/clusters.ts +++ b/src/locales/tr-TR/clusters.ts @@ -69,6 +69,9 @@ export default { 'clusters.addworker.selectCluster.tips': 'Docker dışı kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.', 'clusters.addworker.selectGPU': 'GPU Üreticisi Seç', + 'clusters.addworker.selectGPU.multiTag': 'Multi-select', + 'clusters.addworker.selectGPU.singleOnly': + 'The selected vendor is not in this cluster’s GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.', 'clusters.addworker.checkEnv': 'Ortamı Kontrol Et', 'clusters.addworker.specifyArgs': 'Argümanları Belirle', 'clusters.addworker.runCommand': 'Komutu Çalıştır', @@ -161,5 +164,25 @@ export default { 'clusters.volume.pvc.readOnly': 'Read Only', 'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.optional': 'Optional', - 'clusters.volume.add': 'Add Volume Mount' + 'clusters.volume.add': 'Add Volume Mount', + 'clusters.imageCredentials.title': 'Image Credentials', + 'clusters.imageCredentials.add': 'Add Credential', + 'clusters.imageCredentials.registry': 'Registry', + 'clusters.imageCredentials.username': 'Username', + 'clusters.imageCredentials.password': 'Password', + 'clusters.nodeSelector.title': 'Node Selector', + 'clusters.nodeSelector.tip': + 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', + 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides', + 'clusters.gpuVendorOverrides.validate.emptySelector': + 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.', + 'clusters.gpuVendorOverrides.validate.duplicate': + 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.', + 'clusters.gpuVendorOverrides.validate.keyConflict': + 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.', + 'clusters.gpuVendorOverrides.tip': + 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', + 'clusters.gpuVendorOverrides.add': 'Add Override', + 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', + 'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector' }; diff --git a/src/locales/zh-CN/clusters.ts b/src/locales/zh-CN/clusters.ts index ca327c2b..72ade84e 100644 --- a/src/locales/zh-CN/clusters.ts +++ b/src/locales/zh-CN/clusters.ts @@ -67,6 +67,9 @@ export default { 'clusters.addworker.selectCluster.tips': '非 Docker 集群请前往集群页面注册集群或管理节点池。', 'clusters.addworker.selectGPU': '选择 GPU 厂商', + 'clusters.addworker.selectGPU.multiTag': '可多选', + 'clusters.addworker.selectGPU.singleOnly': + '当前所选厂商未在集群的 GPU 厂商覆盖(override node selector)中配置,仅支持单选。如需多选,请先选择 override 中已配置的厂商。', 'clusters.addworker.checkEnv': '检查环境', 'clusters.addworker.specifyArgs': '指定参数', 'clusters.addworker.runCommand': '运行指令', @@ -153,5 +156,25 @@ export default { 'clusters.volume.pvc.readOnly': '只读', 'clusters.volume.configMap.name': '配置名称', 'clusters.volume.configMap.optional': '可选', - 'clusters.volume.add': '添加卷挂载' + 'clusters.volume.add': '添加卷挂载', + 'clusters.imageCredentials.title': '镜像仓库凭证', + 'clusters.imageCredentials.add': '添加凭证', + 'clusters.imageCredentials.registry': '镜像仓库地址', + 'clusters.imageCredentials.username': '用户名', + 'clusters.imageCredentials.password': '密码', + 'clusters.nodeSelector.title': '节点选择器', + 'clusters.nodeSelector.tip': + '应用到每个 worker DaemonSet 的 Pod nodeSelector,只有标签匹配的节点才会被调度运行 worker。', + 'clusters.gpuVendorOverrides.title': 'GPU 厂商覆盖配置', + 'clusters.gpuVendorOverrides.validate.emptySelector': + '{vendor} 的覆盖配置至少需要一项 nodeSelector —— 空覆盖会让多 vendor manifest 无法定位该 runtime 的节点。', + 'clusters.gpuVendorOverrides.validate.duplicate': + '{v1} 与 {v2} 的覆盖配置使用了完全相同的 nodeSelector。每个 vendor 必须对应不同的节点集,否则它们的 worker DaemonSet 会相互争抢同一批节点。', + 'clusters.gpuVendorOverrides.validate.keyConflict': + '{vendor} 的覆盖配置重用了基础节点选择器中已有的 key({keys})—— 这会让 CPU worker 同时"要求"和"禁止"这些 key,永远无法被调度。', + 'clusters.gpuVendorOverrides.tip': + '集群中存在多个 GPU 厂商时必填。每项的 nodeSelector 将该厂商的 worker DaemonSet 固定到匹配的节点,同时 CPU worker 通过 DoesNotExist 节点亲和性避开这些节点。', + 'clusters.gpuVendorOverrides.add': '添加覆盖配置', + 'clusters.gpuVendorOverrides.vendor': 'GPU 厂商', + 'clusters.gpuVendorOverrides.nodeSelector': '节点选择器' }; diff --git a/src/pages/cluster-management/components/add-worker/add-worker-step.tsx b/src/pages/cluster-management/components/add-worker/add-worker-step.tsx index aa596b83..fdaa4c19 100644 --- a/src/pages/cluster-management/components/add-worker/add-worker-step.tsx +++ b/src/pages/cluster-management/components/add-worker/add-worker-step.tsx @@ -96,6 +96,28 @@ const AddWorkerSteps: React.FC = (props) => { ); }, [clusterList, stepList, StepNamesMap]); + // Downstream steps (check env, run command, ...) only make sense after a + // GPU vendor has been chosen. If the user toggled off every vendor in + // multi-select, gate them shut so the wrong panel can't be opened. + const selectedGPUs = + (summary.get('selectedGPUs') as string[] | undefined) || []; + const currentGPU = (summary.get('currentGPU') as string | undefined) || ''; + const noVendorSelected = !currentGPU && selectedGPUs.length === 0; + const downstreamDisabled = disabled || noVendorSelected; + + React.useEffect(() => { + // If the user just deselected everything, collapse any downstream + // panel back to the GPU step so they aren't left looking at a stale + // disabled-but-open command. Functional updater so we don't have to + // depend on `collapseKey` and re-run the effect on every toggle. + if (!noVendorSelected) return; + setCollapseKey((prev) => + prev.has(StepNamesMap.SelectGPU) + ? prev + : new Set([StepNamesMap.SelectGPU]) + ); + }, [noVendorSelected]); + return ( = (props) => { !stepList.includes(StepNamesMap.SelectCluster)) && ( <> - + {provider === ProviderValueMap.Kubernetes && ( - + )} {provider === ProviderValueMap.Docker && ( <> - - + + )} diff --git a/src/pages/cluster-management/components/add-worker/check-environment.tsx b/src/pages/cluster-management/components/add-worker/check-environment.tsx index 6eea1ece..f8f36844 100644 --- a/src/pages/cluster-management/components/add-worker/check-environment.tsx +++ b/src/pages/cluster-management/components/add-worker/check-environment.tsx @@ -11,6 +11,7 @@ const CheckEnvironment: React.FC = ({ disabled }) => { const { stepList, summary, provider } = useAddWorkerContext(); const intl = useIntl(); const currentGPU = summary.get('currentGPU'); + const currentGPUs: string[] = summary.get('selectedGPUs') || []; const workerCommand = summary.get('workerCommand') || { label: '', link: '', @@ -51,7 +52,11 @@ const CheckEnvironment: React.FC = ({ disabled }) => { {intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })} - + ); }; diff --git a/src/pages/cluster-management/components/add-worker/config.ts b/src/pages/cluster-management/components/add-worker/config.ts index fc29beb3..04ec07d3 100644 --- a/src/pages/cluster-management/components/add-worker/config.ts +++ b/src/pages/cluster-management/components/add-worker/config.ts @@ -31,6 +31,9 @@ export const K8sStepsFromCluter = [ export interface SummaryDataKeys { currentGPU: string; + // Multi-vendor selection for K8s register flow — array of GPU driver keys. + // Falls back to `[currentGPU]` for the single-select default path. + selectedGPUs: string[]; cluster_id: number; clusterName: string; workerCommand: { diff --git a/src/pages/cluster-management/components/add-worker/k8s-run-command.tsx b/src/pages/cluster-management/components/add-worker/k8s-run-command.tsx index b5ba538f..2326126c 100644 --- a/src/pages/cluster-management/components/add-worker/k8s-run-command.tsx +++ b/src/pages/cluster-management/components/add-worker/k8s-run-command.tsx @@ -12,6 +12,7 @@ const K8sRunCommand: React.FC = ({ disabled }) => { const stepIndex = stepList.indexOf(StepNamesMap.RunCommand) + 1; const currentGPU = summary.get('currentGPU') || ''; + const currentGPUs: string[] = summary.get('selectedGPUs') || []; return ( = ({ disabled }) => { ); diff --git a/src/pages/cluster-management/components/add-worker/select-vendor.tsx b/src/pages/cluster-management/components/add-worker/select-vendor.tsx index 26891cef..069ab1f1 100644 --- a/src/pages/cluster-management/components/add-worker/select-vendor.tsx +++ b/src/pages/cluster-management/components/add-worker/select-vendor.tsx @@ -1,54 +1,161 @@ import { AddWorkerDockerNotes, - GPUDriverMap + GPUDriverMap, + GPUsConfigs } from '@/pages/resources/config/gpu-driver'; +import { BulbOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import React, { useEffect } from 'react'; +import { Alert, Tag } from 'antd'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { queryClusterItem } from '../../apis'; +import { ProviderValueMap } from '../../config'; +import { ClusterListItem } from '../../config/types'; import SupportedGPUs from '../support-gpus'; import { useAddWorkerContext } from './add-worker-context'; import { AddWorkerStepProps, StepNamesMap } from './config'; import { Title } from './constainers'; import StepCollapse from './step-collapse'; +const buildWorkerCommand = ( + driverKey: string, + itemHint?: { label?: string; link?: string } +) => ({ + label: itemHint?.label || GPUsConfigs[driverKey]?.label || driverKey, + link: itemHint?.link || '', + notes: AddWorkerDockerNotes[driverKey] || [] +}); + const SelectVendor: React.FC = ({ disabled }) => { - const { stepList, registerField, updateField } = useAddWorkerContext(); + const { stepList, registerField, updateField, provider, registrationInfo } = + useAddWorkerContext(); const intl = useIntl(); const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1; - const [currentGPU, setCurrentGPU] = React.useState( + + // Pull the cluster so we know whether gpuVendorOverrides was configured. + // The K8s register flow gates multi-select on that being present, and + // restricts available runtimes to its keys. + const [overrideRuntimes, setOverrideRuntimes] = useState([]); + useEffect(() => { + const id = registrationInfo?.cluster_id; + if (!id) { + // Reset when the cluster context goes away so we don't leak the + // previous cluster's overrides into the next session. + setOverrideRuntimes([]); + return; + } + let cancelled = false; + queryClusterItem({ id }) + .then((c) => { + if (cancelled) return; + const overrides = (c as ClusterListItem)?.k8s_options + ?.gpuVendorOverrides; + setOverrideRuntimes(overrides ? Object.keys(overrides) : []); + }) + .catch((err) => { + if (cancelled) return; + console.error('Failed to query cluster for vendor overrides:', err); + setOverrideRuntimes([]); + }); + return () => { + cancelled = true; + }; + }, [registrationInfo?.cluster_id]); + + // Set of GPU *driver keys* (e.g. "cuda", "cann") that match the cluster's + // override runtimes. Used to detect when multi-add becomes available. + const overrideKeys = useMemo(() => { + const set = new Set(); + if (!overrideRuntimes.length) return set; + Object.values(GPUsConfigs).forEach((cfg) => { + if (cfg.gpuVendor && overrideRuntimes.includes(cfg.gpuVendor)) { + set.add(cfg.value); + } + }); + return set; + }, [overrideRuntimes]); + + // Multi-add is only meaningful when the cluster has 2+ vendor overrides + // AND the current selection already includes one. Until both hold, the + // picker behaves like a single-select (so the user can freely land on + // any vendor — including ones outside the override list). + const multiCapable = + provider === ProviderValueMap.Kubernetes && overrideKeys.size >= 2; + + const [selectedKeys, setSelectedKeys] = useState([ GPUDriverMap.NVIDIA + ]); + + const isMultiActive = useMemo( + () => multiCapable && selectedKeys.some((k) => overrideKeys.has(k)), + [multiCapable, selectedKeys, overrideKeys] ); - const handleSelectProvider = (value: string, item: any) => { - if (value === currentGPU) return; - setCurrentGPU(value); + // In multi-active mode, non-override vendors are disabled — picking one + // would break the backend invariant that a multi-vendor manifest must + // only target configured runtimes. Otherwise everything stays enabled. + const availableKeys = isMultiActive ? overrideKeys : undefined; - updateField('currentGPU', value); - updateField('workerCommand', item); + // Cache vendor metadata (label/link from SupportedGPUs items) so we can + // rebuild workerCommand on toggle without re-clicking the card. + const itemMetaRef = useRef>( + {} + ); + + // Push current selection into the shared summary so consumers + // (K8sRunCommand, CheckEnvironment, VendorNotes) can read it. + useEffect(() => { + const primary = selectedKeys[0] || ''; + updateField('currentGPU', primary); + updateField('selectedGPUs', selectedKeys); + updateField( + 'workerCommand', + primary ? buildWorkerCommand(primary, itemMetaRef.current[primary]) : null + ); + }, [selectedKeys]); + + useEffect(() => { + const unregister1 = registerField('currentGPU'); + const unregister2 = registerField('workerCommand'); + const unregister3 = registerField('selectedGPUs'); + return () => { + unregister1(); + unregister2(); + unregister3(); + }; + }, []); + + const handleSelect = (key: string, item: any) => { + // Disabled cards are already blocked by TemplateCard; this is a + // defensive check for the multi-active case (only override runtimes + // can be added once multi-add is open). + if (availableKeys && !availableKeys.has(key)) return; + if (item) { + itemMetaRef.current[key] = { + label: item.label, + link: item.link + }; + } + setSelectedKeys((prev) => { + const has = prev.includes(key); + if (has) { + // Clicking a selected card always toggles it off. + return prev.filter((v) => v !== key); + } + // Multi-add only when the current state already includes an override + // pick. Otherwise (still in single-select land), replace. + if (isMultiActive) return [...prev, key]; + return [key]; + }); }; - useEffect(() => { - const unregisterField = registerField('currentGPU'); - return () => { - unregisterField(); - }; - }, []); - - useEffect(() => { - const unregisterField = registerField('workerCommand'); - return () => { - unregisterField(); - }; - }, []); - - useEffect(() => { - updateField('currentGPU', GPUDriverMap.NVIDIA); - updateField('workerCommand', { - label: 'NVIDIA', - link: 'https://docs.gpustack.ai/latest/installation/requirements/#nvidia-gpu', - notes: AddWorkerDockerNotes[GPUDriverMap.NVIDIA] - }); - }, []); + // Warn when multi-select is possible on this cluster but the user's + // current pick lands outside the override list — they're effectively + // locked into single-select until they switch to an override vendor. + const showSingleOnlyHint = + multiCapable && + selectedKeys.length > 0 && + selectedKeys.every((k) => !overrideKeys.has(k)); return ( = ({ disabled }) => { {stepIndex}.{' '} {intl.formatMessage({ id: 'clusters.addworker.selectGPU' })} + {multiCapable && ( + <Tag + color="blue" + style={{ + marginLeft: 8, + fontWeight: 400, + borderRadius: 4 + }} + > + {intl.formatMessage({ + id: 'clusters.addworker.selectGPU.multiTag' + })} + </Tag> + )} } > + {showSingleOnlyHint && ( + } + style={{ marginBottom: 8 }} + message={intl.formatMessage({ + id: 'clusters.addworker.selectGPU.singleOnly' + })} + /> + )} diff --git a/src/pages/cluster-management/components/check-env-command.tsx b/src/pages/cluster-management/components/check-env-command.tsx index 9dc43929..379ef787 100644 --- a/src/pages/cluster-management/components/check-env-command.tsx +++ b/src/pages/cluster-management/components/check-env-command.tsx @@ -6,18 +6,31 @@ import { ProviderType } from '../config'; type ViewModalProps = { provider: ProviderType; currentGPU: string; + // When multiple vendors are selected (K8s multi-vendor register flow), + // we emit one check command per vendor so the user can verify each + // runtimeclass is registered. + currentGPUs?: string[]; }; const AddWorkerCommand: React.FC = ({ provider = '', - currentGPU + currentGPU, + currentGPUs }) => { - console.log('check env command provider:', currentGPU); const code = React.useMemo(() => { const configs = addWorkerGuide['all']; - const command = configs.checkEnvCommand(currentGPU); - return command[provider || '']; - }, [provider, currentGPU]); + const keys = + currentGPUs && currentGPUs.length > 0 + ? currentGPUs + : currentGPU + ? [currentGPU] + : []; + if (!keys.length) return ''; + const lines = keys + .map((k) => configs.checkEnvCommand(k)?.[provider || '']) + .filter((cmd): cmd is string => !!cmd); + return lines.join('\n'); + }, [provider, currentGPU, currentGPUs]); return ( = forwardRef( const intl = useIntl(); const [activeKey, setActiveKey] = React.useState([]); const advanceConfigRef = React.useRef(null); + const systemConfig = useAtomValue(systemConfigAtom); const handleOnCollapseChange = async (keys: string | string[]) => { setActiveKey(Array.isArray(keys) ? keys : [keys]); @@ -50,20 +54,107 @@ const ClusterForm: React.FC = forwardRef( } }, [activeKey, action]); - const handleOnFinish = (values: FormData) => { - const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue()); + // Mirror the backend's `_validate_multi_vendor_overrides` check so the + // user sees the problem at save time instead of as a 400 when they + // run the register command. Returns the i18n'd message of the first + // problem found, or null when clean. + const validateGpuVendorOverrides = (values: any): string | null => { + const opts = values?.k8s_options; + const overrides = opts?.gpuVendorOverrides; + if (!overrides) return null; + const entries = Object.entries(overrides) as [string, any][]; + if (entries.length === 0) return null; - onFinish({ - ...values, - worker_config: { - ...workerConfig + // 1. Every override entry must declare a non-empty nodeSelector, + // otherwise it can't actually pin its DaemonSet to anything. + for (const [vendor, override] of entries) { + const sel = override?.nodeSelector; + if (!sel || Object.keys(sel).length === 0) { + return intl.formatMessage( + { id: 'clusters.gpuVendorOverrides.validate.emptySelector' }, + { vendor } + ); } - }); + } + + // 2. Two vendors with the same selector would fight for the same + // nodes — backend rejects this at manifest render. + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const [v1, o1] = entries[i]; + const [v2, o2] = entries[j]; + if (_.isEqual(o1?.nodeSelector, o2?.nodeSelector)) { + return intl.formatMessage( + { id: 'clusters.gpuVendorOverrides.validate.duplicate' }, + { v1, v2 } + ); + } + } + } + + // 3. Base nodeSelector keys can't be reused in any override — + // the CPU worker would require AND forbid the same key. + const baseKeys = Object.keys(opts?.nodeSelector || {}); + if (baseKeys.length > 0) { + for (const [vendor, override] of entries) { + const overrideKeys = Object.keys(override?.nodeSelector || {}); + const clash = overrideKeys.filter((k) => baseKeys.includes(k)); + if (clash.length > 0) { + return intl.formatMessage( + { id: 'clusters.gpuVendorOverrides.validate.keyConflict' }, + { vendor, keys: clash.join(', ') } + ); + } + } + } + + return null; + }; + + // Empty username/password aren't meaningful as credentials — the backend + // models them as Optional[str] and treats null as "no auth". Coerce + // before sending so a public registry placeholder stays unambiguous. + const normalizeOutgoing = (values: any): any => { + const creds = values?.k8s_options?.imageCredentials; + if (!Array.isArray(creds)) return values; + const fixed = creds.map((c: any) => ({ + ...c, + username: c?.username ? c.username : null, + password: c?.password ? c.password : null + })); + return { + ...values, + k8s_options: { ...values.k8s_options, imageCredentials: fixed } + }; + }; + + const handleOnFinish = (_values: FormData) => { + const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue()); + // antd's onFinish only delivers values for registered Form.Items. + // Spreading those on top of `getFieldsValue(true)` clobbers nested + // objects (e.g. `k8s_options` would lose `gpuVendorOverrides`, which is + // only set via setFieldValue), so we go straight to the full store. + const fullValues = form.getFieldsValue(true); + + const overridesErr = validateGpuVendorOverrides(fullValues); + if (overridesErr) { + message.error(overridesErr); + return; + } + + onFinish( + normalizeOutgoing({ + ...fullValues, + worker_config: { + ...workerConfig + } + }) + ); }; useEffect(() => { if (currentData) { - const volumeMounts = currentData?.k8s_volume_mounts || []; + const volumeMounts = currentData?.k8s_options?.volumeMounts || []; const realVolumeList = (volumeMounts || []).map( (item: any, index: number) => ({ ...item, @@ -72,27 +163,40 @@ const ClusterForm: React.FC = forwardRef( ); form.setFieldsValue({ ...currentData, - k8s_volume_mounts: realVolumeList + k8s_options: { + ...(currentData?.k8s_options || {}), + volumeMounts: realVolumeList + } }); } else { + const defaultRegistry = systemConfig?.system_default_container_registry; form.setFieldsValue({ - k8s_volume_mounts: [ - { - name: 'gpustack-data-dir', - mountPath: '/var/lib/gpustack', - readOnly: false, - sourceType: 'hostPath', - volumeSource: { - hostPath: { - path: '/var/lib/gpustack', - type: 'DirectoryOrCreate' + k8s_options: { + volumeMounts: [ + { + name: 'gpustack-data-dir', + mountPath: '/var/lib/gpustack', + readOnly: false, + sourceType: 'hostPath', + volumeSource: { + hostPath: { + path: '/var/lib/gpustack', + type: 'DirectoryOrCreate' + } } } - } - ] + ], + ...(defaultRegistry + ? { + imageCredentials: [ + { registry: defaultRegistry, username: '', password: '' } + ] + } + : {}) + } }); } - }, [currentData]); + }, [currentData, systemConfig?.system_default_container_registry]); useEffect(() => { if (currentData) { @@ -130,17 +234,33 @@ const ClusterForm: React.FC = forwardRef( }; }, validateFields: async () => { - const values = await form.validateFields(); + // Run validation first to display any field errors. Then read the + // FULL store via `getFieldsValue(true)` so values that were set via + // setFieldValue on non-registered paths (e.g. gpuVendorOverrides) + // are still included in what we hand to the API. + await form.validateFields(); + const values = form.getFieldsValue(true); + + // Mirror the backend invariants for gpuVendorOverrides so the user + // sees the same constraint before submit instead of at register time. + const overridesErr = validateGpuVendorOverrides(values); + if (overridesErr) { + message.error(overridesErr); + // Reject so the step-flow's Promise.allSettled marks this form + // as failed and the outer onNext skips the submit callback. + throw new Error(overridesErr); + } + const workerConfig = yaml2Json( advanceConfigRef.current?.getYamlValue() ); - return { + return normalizeOutgoing({ ...values, worker_config: { ...workerConfig } - }; + }); } })); @@ -207,6 +327,7 @@ const ClusterForm: React.FC = forwardRef( ) diff --git a/src/pages/cluster-management/components/cluster-steps.tsx b/src/pages/cluster-management/components/cluster-steps.tsx index 9b010416..5da0dd7c 100644 --- a/src/pages/cluster-management/components/cluster-steps.tsx +++ b/src/pages/cluster-management/components/cluster-steps.tsx @@ -1,7 +1,14 @@ import { Steps } from 'antd'; +import _ from 'lodash'; import React from 'react'; import styled from 'styled-components'; +// `description` is intentionally omitted: the upstream step list ships +// hardcoded English copy that isn't translated. Keeping it would cause the +// step to render both the localized title and the English description side +// by side. Same reason we don't surface `subTitle`. +const ANTD_STEP_KEYS = ['title', 'icon', 'status', 'disabled'] as const; + const Wrapper = styled.div` display: flex; flex-direction: column; @@ -39,7 +46,14 @@ const ClusterSteps: React.FC<{ }> = (props) => { const { steps, currentStep = 0, onChange } = props; - const visibleSteps = steps.filter((step) => !step.hideInSteps); + // Pick only props antd's Step accepts — the upstream step objects carry + // custom keys (showModules/showForms/showButtons/...) that would otherwise + // be forwarded to the DOM and trigger "React does not recognize the X + // prop on a DOM element" warnings. _.pick keeps missing keys missing + // (rather than explicitly `undefined`) so antd's defaults still kick in. + const visibleSteps = steps + .filter((step) => !step.hideInSteps) + .map((step) => _.pick(step, ANTD_STEP_KEYS)); const styles: Record = { root: { diff --git a/src/pages/cluster-management/components/k8s-pod-spec.tsx b/src/pages/cluster-management/components/k8s-pod-spec.tsx new file mode 100644 index 00000000..cd30cfe2 --- /dev/null +++ b/src/pages/cluster-management/components/k8s-pod-spec.tsx @@ -0,0 +1,413 @@ +import { + MinusOutlined, + PlusOutlined, + QuestionCircleOutlined +} from '@ant-design/icons'; +import { + Input as CInput, + CollapseContainer, + LabelSelector, + Select as SealSelect, + useAppUtils +} from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; +import { Button, Form, Tooltip } from 'antd'; +import _ from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { GPUsConfigs } from '../../resources/config/gpu-driver'; + +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 Label = styled.span` + display: flex; + align-items: center; + gap: 4px; + color: var(--ant-color-text-secondary); +`; + +const SectionWrap = styled.div` + margin-bottom: 16px; +`; + +const ImageCredentialsForm: React.FC = () => { + const intl = useIntl(); + const { getRuleMessage } = useAppUtils(); + + return ( + + + {(fields, { add, remove }) => ( + <> + + <div className="flex-center gap-8"> + <span> + {intl.formatMessage({ + id: 'clusters.imageCredentials.title' + })} + </span> + <Button + type="link" + onClick={() => + add({ registry: '', username: '', password: '' }) + } + > + <PlusOutlined />{' '} + {intl.formatMessage({ + id: 'clusters.imageCredentials.add' + })} + </Button> + </div> + +
+ {fields.map(({ key, name }) => ( +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+ ))} +
+ + )} +
+
+ ); +}; + +const NodeSelectorForm: React.FC = () => { + const intl = useIntl(); + + return ( + + + <span className="flex-center gap-4"> + <span> + {intl.formatMessage({ id: 'clusters.nodeSelector.title' })} + </span> + <Tooltip + title={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })} + > + <QuestionCircleOutlined + style={{ color: 'var(--ant-color-text-secondary)' }} + /> + </Tooltip> + </span> + + + + + + ); +}; + +const vendorOptions = Object.values(GPUsConfigs) + .filter((c) => !!c.gpuVendor) + .map((c) => ({ label: c.label, value: c.gpuVendor as string })); + +const GpuVendorOverridesForm: React.FC<{ + initialValue?: Record }>; +}> = ({ initialValue }) => { + const intl = useIntl(); + const form = Form.useFormInstance(); + + // Single source of truth for the cards while the user is editing. + // We mirror it into the form via setFieldValue so submit picks it up. + // Seed local state directly from the parent's currentData — Form.useWatch + // on a non-registered nested path proved unreliable for picking up + // initialValues, so we cut out that indirection. + const [overrides, setOverrides] = useState< + Record }> + >(() => initialValue || {}); + const [collapseKey, setCollapseKey] = useState>(new Set()); + const initializedRef = useRef(!!initialValue); + + // On mount: if we seeded with an initialValue, mirror it into the form so + // submit collects it. (When seeded, initializedRef is already true.) + useEffect(() => { + if (initialValue && Object.keys(initialValue).length > 0) { + form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue); + } + }, []); + + // If the parent currentData arrives after mount (e.g. async fetch), adopt + // it once. After the user has interacted (`initializedRef`), local state + // owns the visible list. + useEffect(() => { + if (initializedRef.current) return; + if (initialValue && Object.keys(initialValue).length > 0) { + setOverrides(initialValue); + form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue); + initializedRef.current = true; + } + }, [initialValue, form]); + + // Once the user has touched this section, keep the form mirror in sync + // so submit collects what's currently on screen even if the form was + // reset externally (e.g., parent re-renders that touch initialValues). + useEffect(() => { + if (!initializedRef.current) return; + form.setFieldValue( + ['k8s_options', 'gpuVendorOverrides'], + Object.keys(overrides).length > 0 ? overrides : undefined + ); + }, [overrides, form]); + + const vendorKeys = useMemo(() => Object.keys(overrides), [overrides]); + + const availableVendors = useMemo( + () => vendorOptions.filter((opt) => !vendorKeys.includes(opt.value)), + [vendorKeys] + ); + + const writeOverrides = (next: Record) => { + setOverrides(next); + form.setFieldValue( + ['k8s_options', 'gpuVendorOverrides'], + Object.keys(next).length > 0 ? next : undefined + ); + initializedRef.current = true; + }; + + const handleAdd = () => { + if (availableVendors.length === 0) return; + const next = availableVendors[0].value; + writeOverrides({ ...overrides, [next]: { nodeSelector: {} } }); + setCollapseKey(new Set([next])); + }; + + const handleRemove = (vendor: string) => { + writeOverrides(_.omit(overrides, vendor)); + }; + + const handleVendorChange = (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + const value = overrides[oldKey] ?? { nodeSelector: {} }; + const next = _.omit(overrides, oldKey); + next[newKey] = value; + writeOverrides(next); + setCollapseKey(new Set([newKey])); + }; + + const handleNodeSelectorChange = ( + vendor: string, + labels: Record + ) => { + writeOverrides({ + ...overrides, + [vendor]: { ...overrides[vendor], nodeSelector: labels } + }); + }; + + const onToggle = (open: boolean, key: string) => { + setCollapseKey(open ? new Set([key]) : new Set()); + }; + + return ( + + + <div className="flex-center gap-8"> + <span className="flex-center gap-4"> + <span> + {intl.formatMessage({ id: 'clusters.gpuVendorOverrides.title' })} + </span> + <Tooltip + title={intl.formatMessage({ + id: 'clusters.gpuVendorOverrides.tip' + })} + > + <QuestionCircleOutlined + style={{ color: 'var(--ant-color-text-secondary)' }} + /> + </Tooltip> + </span> + <Button + type="link" + onClick={handleAdd} + disabled={availableVendors.length === 0} + > + <PlusOutlined />{' '} + {intl.formatMessage({ id: 'clusters.gpuVendorOverrides.add' })} + </Button> + </div> + +
+ {vendorKeys.map((vendor) => { + const optionsForThisRow = [ + ...vendorOptions.filter( + (opt) => opt.value === vendor || !vendorKeys.includes(opt.value) + ) + ]; + const vendorLabel = + vendorOptions.find((o) => o.value === vendor)?.label || vendor; + return ( +
+ onToggle(open, vendor)} + styles={{ + body: collapseKey.has(vendor) ? { padding: 16 } : {}, + content: { paddingTop: 0 }, + header: { backgroundColor: 'unset' } + }} + title={ + + } + right={ + + } + > +
+ + handleVendorChange(vendor, value) + } + > +
+ + handleNodeSelectorChange(vendor, labels) + } + > +
+
+ ); + })} +
+
+ ); +}; + +type GpuVendorOverridesValue = Record< + string, + { nodeSelector?: Record } +>; + +const K8sPodSpec: React.FC<{ + initialOverrides?: GpuVendorOverridesValue; +}> = ({ initialOverrides }) => { + return ( + <> + + + + + ); +}; + +export default K8sPodSpec; diff --git a/src/pages/cluster-management/components/k8s-volume-mount.tsx b/src/pages/cluster-management/components/k8s-volume-mount.tsx index dcb7af36..a6154df0 100644 --- a/src/pages/cluster-management/components/k8s-volume-mount.tsx +++ b/src/pages/cluster-management/components/k8s-volume-mount.tsx @@ -26,7 +26,7 @@ const Title = styled.div` display: flex; align-items: center; justify-content: space-between; - background-color: var(--ant-color-bg-container); + background-color: transparent; font-weight: 500; font-size: 14px; padding-top: 0px; @@ -37,7 +37,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { const form = Form.useFormInstance(); const intl = useIntl(); const { getRuleMessage } = useAppUtils(); - const k8sVolumeMounts = Form.useWatch('k8s_volume_mounts', form); + const k8sVolumeMounts = Form.useWatch(['k8s_options', 'volumeMounts'], form); const [collapseKey, setCollapseKey] = useState>( new Set([0]) @@ -60,7 +60,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { useEffect(() => { if (action === PageAction.CREATE) { - form.setFieldValue('k8s_volume_mounts', []); + form.setFieldValue(['k8s_options', 'volumeMounts'], []); } }, [action]); @@ -70,33 +70,36 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { const handleAdd = async () => { try { - await form.validateFields(['k8s_volume_mounts'], { + await form.validateFields([['k8s_options', 'volumeMounts']], { recursive: true }); - const list = form.getFieldValue('k8s_volume_mounts') || []; + const list = form.getFieldValue(['k8s_options', 'volumeMounts']) || []; - form.setFieldValue('k8s_volume_mounts', [ - ...list, - { - name: `volume-${list.length + 1}`, - mountPath: '', - readOnly: false, - sourceType: 'hostPath', - volumeSource: { - hostPath: { - path: '', - type: 'DirectoryOrCreate' + form.setFieldValue( + ['k8s_options', 'volumeMounts'], + [ + ...list, + { + name: `volume-${list.length + 1}`, + mountPath: '', + readOnly: false, + sourceType: 'hostPath', + volumeSource: { + hostPath: { + path: '', + type: 'DirectoryOrCreate' + } } } - } - ]); + ] + ); setTimeout(() => { setCollapseKey(new Set([list.length])); }, 100); } catch (e: any) { - const errorIndex = e?.errorFields?.[0]?.name?.[1]; + const errorIndex = e?.errorFields?.[0]?.name?.[2]; if (typeof errorIndex === 'number') { setCollapseKey(new Set([errorIndex])); } @@ -121,7 +124,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { } form.setFieldValue( - ['k8s_volume_mounts', index, 'volumeSource'], + ['k8s_options', 'volumeMounts', index, 'volumeSource'], volumeSource ); }; @@ -144,9 +147,10 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => { marginBottom: '8px' }} > - + {(fields, { remove }) => { - const list = form.getFieldValue('k8s_volume_mounts') || []; + const list = + form.getFieldValue(['k8s_options', 'volumeMounts']) || []; return fields.map(({ name }) => { const item = list[name] || {}; diff --git a/src/pages/cluster-management/components/provider-catalog.tsx b/src/pages/cluster-management/components/provider-catalog.tsx index 474b648e..49b4a0fe 100644 --- a/src/pages/cluster-management/components/provider-catalog.tsx +++ b/src/pages/cluster-management/components/provider-catalog.tsx @@ -68,7 +68,8 @@ interface ProviderCatalogProps { onSelect?: (provider: string, item: any) => void; groupIcons?: Record; cols?: number; - current?: ProviderType | string; + // Single value (legacy) or array of selected keys for multi-select. + current?: ProviderType | string | string[]; clickable?: boolean; height: string | number; showTooltip?: boolean; @@ -149,7 +150,11 @@ const ProviderCatalog: React.FC = ({ onSelect?.(action.key as string, action)} - active={current === action.key} + active={ + Array.isArray(current) + ? current.includes(action.key) + : current === action.key + } disabled={action.disabled} clickable={clickable} header={renderTitle(action)} diff --git a/src/pages/cluster-management/components/register-cluster-inner.tsx b/src/pages/cluster-management/components/register-cluster-inner.tsx index 5dc74002..12105c5e 100644 --- a/src/pages/cluster-management/components/register-cluster-inner.tsx +++ b/src/pages/cluster-management/components/register-cluster-inner.tsx @@ -4,6 +4,7 @@ import { generateK8sRegisterCommand } from '../config'; type AddModalProps = { currentGPU?: string; + currentGPUs?: string[]; registrationInfo: { token: string; image: string; @@ -13,16 +14,18 @@ type AddModalProps = { }; const AddCluster: React.FC = ({ registrationInfo, - currentGPU + currentGPU, + currentGPUs }) => { const code = useMemo(() => { return generateK8sRegisterCommand({ server: registrationInfo?.server_url || window.location.origin, clusterId: registrationInfo?.cluster_id, registrationToken: registrationInfo?.token, - currentGPU + currentGPU, + currentGPUs }); - }, [registrationInfo, currentGPU]); + }, [registrationInfo, currentGPU, currentGPUs]); return (
diff --git a/src/pages/cluster-management/components/support-gpus.tsx b/src/pages/cluster-management/components/support-gpus.tsx index 4709e953..30acd5f2 100644 --- a/src/pages/cluster-management/components/support-gpus.tsx +++ b/src/pages/cluster-management/components/support-gpus.tsx @@ -76,14 +76,19 @@ const ProviderImage = ({ src, height }: { src: string; height?: number }) => { interface SupportedHardwareProps { onSelect?: (provider: string, item: any) => void; - current?: string; + // Single (legacy) or array of selected GPU driver keys (multi-select). + current?: string | string[]; clickable?: boolean; + // Set of GPU driver keys that are valid to pick. When provided, items + // outside this set render as disabled. Undefined means "no restriction". + availableKeys?: Set; } const SupportedHardware: React.FC = ({ onSelect, clickable, - current + current, + availableKeys }) => { const intl = useIntl(); const { userSettings } = useUserSettings(); @@ -199,13 +204,20 @@ const SupportedHardware: React.FC = ({ } ]; + const platformsWithDisabled = availableKeys + ? supportedHardPlatforms.map((p) => ({ + ...p, + disabled: !availableKeys.has(p.value) + })) + : supportedHardPlatforms; + return ( { - const runtime = GPUsConfigs[params.currentGPU || '']?.runtime || ''; - return `curl -k -L '${params.server}/${GPUSTACK_API_BASE_URL}/clusters/${params.clusterId}/manifests${runtime ? `?runtime=${runtime}` : ''}' \\ + const keys = + params.currentGPUs && params.currentGPUs.length > 0 + ? params.currentGPUs + : params.currentGPU + ? [params.currentGPU] + : []; + const runtimes = keys + .map((k) => GPUsConfigs[k]?.runtime) + .filter((r): r is string => !!r); + const query = runtimes.map((r) => `runtime=${r}`).join('&'); + return `curl -k -L '${params.server}/${GPUSTACK_API_BASE_URL}/clusters/${params.clusterId}/manifests${query ? `?${query}` : ''}' \\ --header 'Authorization: Bearer ${params.registrationToken}' | kubectl apply -f -`; }; diff --git a/src/pages/cluster-management/config/types.ts b/src/pages/cluster-management/config/types.ts index fa48277c..0217f369 100644 --- a/src/pages/cluster-management/config/types.ts +++ b/src/pages/cluster-management/config/types.ts @@ -68,6 +68,25 @@ export interface VolumeMount { }; } +export interface ImageCredential { + registry: string; + username: string; + password: string; +} + +export interface K8sOptions { + // Backend serializes K8sOptions with camelCase aliases (by_alias=True on + // the SQL JSON column). The top-level `k8s_options` field on the cluster + // stays snake_case, but everything inside follows the backend wire shape. + volumeMounts?: VolumeMount[]; + imageCredentials?: ImageCredential[]; + nodeSelector?: Record; + gpuVendorOverrides?: Record< + string, + { nodeSelector?: Record } + >; +} + export interface ClusterListItem { name: string; display_name: string; @@ -87,7 +106,7 @@ export interface ClusterListItem { state: ClusterStatusType; state_message: string; worker_pools: NodePoolListItem[]; - k8s_volume_mounts?: VolumeMount[]; + k8s_options?: K8sOptions; // Backend ClusterPublic carries this; admin-"All" namespace // resolution falls back to the cluster's owner Org name. owner_principal_id?: number; @@ -104,7 +123,7 @@ export interface ClusterFormData { server_url?: string; worker_config?: Record; worker_pools?: NodePoolFormData[]; - k8s_volume_mounts?: VolumeMount[]; + k8s_options?: K8sOptions; } export interface SystemConfig { diff --git a/src/pages/cluster-management/step-forms/advance-config.tsx b/src/pages/cluster-management/step-forms/advance-config.tsx index d9d52e00..e1ff7ce3 100644 --- a/src/pages/cluster-management/step-forms/advance-config.tsx +++ b/src/pages/cluster-management/step-forms/advance-config.tsx @@ -7,9 +7,13 @@ import { useIntl } from '@umijs/max'; import { Button, Form } from 'antd'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import styled from 'styled-components'; +import K8sPodSpec from '../components/k8s-pod-spec'; import K8SVolumeMount from '../components/k8s-volume-mount'; import { ProviderType, ProviderValueMap } from '../config'; -import { ClusterFormData as FormData } from '../config/types'; +import { + ClusterFormData as FormData, + ClusterListItem as ListItem +} from '../config/types'; import schema from '../config/worker-config.json'; import { dockerConfig, kubernetesConfig } from '../config/yaml-template'; @@ -28,8 +32,9 @@ const Title = styled.div` const ClusterAdvanceConfig: React.FC<{ action: PageActionType; provider: ProviderType; + currentData?: ListItem; ref?: any; -}> = forwardRef(({ action, provider }, ref) => { +}> = forwardRef(({ action, provider, currentData }, ref) => { const [form] = Form.useForm(); const intl = useIntl(); const editorRef = React.useRef(null); @@ -75,7 +80,12 @@ const ClusterAdvanceConfig: React.FC<{ {provider === ProviderValueMap.Kubernetes && ( - + <> + + + )} {intl.formatMessage({ id: 'clusters.create.workerConfig' })}