feat(cluster): update K8s cluster form for new k8s_options API

Align the Kubernetes cluster create/edit page with the backend's
promotion of operator/K8s knobs out of worker_config and the removal
of gpuVendorOverrides.

- k8s_options: drop gpuVendorOverrides; add operatorImage, namespace,
  and gpuInstanceOptions (presence = GPU instances enabled, carrying an
  optional gpuInstancesAccessStaticAddress).
- Add a top-level system_default_container_registry to the cluster type
  (promoted out of worker_config on the backend).
- Group all k8s_options fields into a new top-level "K8s Deployment
  Options" collapsible section (sibling of Advanced, rendered above it),
  with namespace first followed by volume mounts, image credentials,
  node selector, operator image, and GPU instances.
- Drive the GPU instances toggle from local state instead of
  Form.useWatch (an unregistered nested path never re-rendered, leaving
  the switch unresponsive); use antd's borderless Switch and keep the
  label/switch grouped together. Namespace gains a gpustack-system
  placeholder.
- Remove the gpuVendorOverrides validation from the form and the stale
  operator_image / namespace / gpu_instances_access_static_address hints
  from the worker_config YAML template and JSON schema.
- Add-worker GPU picker: always allow multi-select for K8s clusters
  (runtime node selectors are now auto-derived), dropping the
  override-gating, cluster fetch, and single-only hint.
- Update locales (en/zh/ja/ru/tr) for the removed and added keys.
This commit is contained in:
Yuxing Deng
2026-05-29 17:56:01 +08:00
parent a80b889760
commit fcd1d01e83
12 changed files with 283 additions and 484 deletions
+12 -14
View File
@@ -70,8 +70,6 @@ export default {
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.checkEnv': 'Check Environment',
'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.specifyArgs': 'Specify Arguments',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
@@ -164,6 +162,7 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -172,16 +171,15 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', '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.operatorImage.title': 'Operator Image',
'clusters.gpuVendorOverrides.validate.emptySelector': 'clusters.operatorImage.tip':
'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.', 'Override for the GPUStack Operator container image. Leave empty to use the server default.',
'clusters.gpuVendorOverrides.validate.duplicate': 'clusters.namespace.title': 'Namespace',
'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.namespace.tip':
'clusters.gpuVendorOverrides.validate.keyConflict': 'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'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.gpuInstances.title': 'GPU Instances',
'clusters.gpuVendorOverrides.tip': 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuVendorOverrides.add': 'Add Override', 'clusters.gpuInstances.staticAddress.tip':
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
+12 -14
View File
@@ -70,8 +70,6 @@ export default {
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.checkEnv': 'Check Environment',
'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.specifyArgs': 'Specify Arguments',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
@@ -164,6 +162,7 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -172,18 +171,17 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', '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.operatorImage.title': 'Operator Image',
'clusters.gpuVendorOverrides.validate.emptySelector': 'clusters.operatorImage.tip':
'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.', 'Override for the GPUStack Operator container image. Leave empty to use the server default.',
'clusters.gpuVendorOverrides.validate.duplicate': 'clusters.namespace.title': 'Namespace',
'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.namespace.tip':
'clusters.gpuVendorOverrides.validate.keyConflict': 'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'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.gpuInstances.title': 'GPU Instances',
'clusters.gpuVendorOverrides.tip': 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuVendorOverrides.add': 'Add Override', 'clusters.gpuInstances.staticAddress.tip':
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
// ========== To-Do: Translate Keys (Remove After Translation) ========== // ========== To-Do: Translate Keys (Remove After Translation) ==========
+12 -14
View File
@@ -70,8 +70,6 @@ export default {
'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.', 'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.',
'clusters.addworker.selectGPU': 'Выбрать производителя GPU', 'clusters.addworker.selectGPU': 'Выбрать производителя GPU',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Проверить окружение', 'clusters.addworker.checkEnv': 'Проверить окружение',
'clusters.addworker.specifyArgs': 'Указать аргументы', 'clusters.addworker.specifyArgs': 'Указать аргументы',
'clusters.addworker.runCommand': 'Выполнить команду', 'clusters.addworker.runCommand': 'Выполнить команду',
@@ -165,6 +163,7 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -173,18 +172,17 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', '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.operatorImage.title': 'Operator Image',
'clusters.gpuVendorOverrides.validate.emptySelector': 'clusters.operatorImage.tip':
'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.', 'Override for the GPUStack Operator container image. Leave empty to use the server default.',
'clusters.gpuVendorOverrides.validate.duplicate': 'clusters.namespace.title': 'Namespace',
'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.namespace.tip':
'clusters.gpuVendorOverrides.validate.keyConflict': 'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'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.gpuInstances.title': 'GPU Instances',
'clusters.gpuVendorOverrides.tip': 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuVendorOverrides.add': 'Add Override', 'clusters.gpuInstances.staticAddress.tip':
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
// ========== To-Do: Translate Keys (Remove After Translation) ========== // ========== To-Do: Translate Keys (Remove After Translation) ==========
+12 -14
View File
@@ -70,8 +70,6 @@ export default {
'<span class="bold-text">Docker dışı</span> kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.', '<span class="bold-text">Docker dışı</span> 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': 'GPU Üreticisi Seç',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters 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.checkEnv': 'Ortamı Kontrol Et',
'clusters.addworker.specifyArgs': 'Argümanları Belirle', 'clusters.addworker.specifyArgs': 'Argümanları Belirle',
'clusters.addworker.runCommand': 'Komutu Çalıştır', 'clusters.addworker.runCommand': 'Komutu Çalıştır',
@@ -165,6 +163,7 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -173,16 +172,15 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', '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.operatorImage.title': 'Operator Image',
'clusters.gpuVendorOverrides.validate.emptySelector': 'clusters.operatorImage.tip':
'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.', 'Override for the GPUStack Operator container image. Leave empty to use the server default.',
'clusters.gpuVendorOverrides.validate.duplicate': 'clusters.namespace.title': 'Namespace',
'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.namespace.tip':
'clusters.gpuVendorOverrides.validate.keyConflict': 'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'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.gpuInstances.title': 'GPU Instances',
'clusters.gpuVendorOverrides.tip': 'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuVendorOverrides.add': 'Add Override', 'clusters.gpuInstances.staticAddress.tip':
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor', 'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
+12 -14
View File
@@ -68,8 +68,6 @@ export default {
'<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。', '<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。',
'clusters.addworker.selectGPU': '选择 GPU 厂商', 'clusters.addworker.selectGPU': '选择 GPU 厂商',
'clusters.addworker.selectGPU.multiTag': '可多选', 'clusters.addworker.selectGPU.multiTag': '可多选',
'clusters.addworker.selectGPU.singleOnly':
'当前所选厂商未在集群的 GPU 厂商覆盖(override node selector)中配置,仅支持单选。如需多选,请先选择 override 中已配置的厂商。',
'clusters.addworker.checkEnv': '检查环境', 'clusters.addworker.checkEnv': '检查环境',
'clusters.addworker.specifyArgs': '指定参数', 'clusters.addworker.specifyArgs': '指定参数',
'clusters.addworker.runCommand': '运行指令', 'clusters.addworker.runCommand': '运行指令',
@@ -157,6 +155,7 @@ export default {
'clusters.volume.configMap.name': '配置名称', 'clusters.volume.configMap.name': '配置名称',
'clusters.volume.configMap.optional': '可选', 'clusters.volume.configMap.optional': '可选',
'clusters.volume.add': '添加卷挂载', 'clusters.volume.add': '添加卷挂载',
'clusters.k8sOptions.title': 'K8s 部署选项',
'clusters.imageCredentials.title': '镜像仓库凭证', 'clusters.imageCredentials.title': '镜像仓库凭证',
'clusters.imageCredentials.add': '添加凭证', 'clusters.imageCredentials.add': '添加凭证',
'clusters.imageCredentials.registry': '镜像仓库地址', 'clusters.imageCredentials.registry': '镜像仓库地址',
@@ -165,16 +164,15 @@ export default {
'clusters.nodeSelector.title': '节点选择器', 'clusters.nodeSelector.title': '节点选择器',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'应用到每个 worker DaemonSet 的 Pod nodeSelector,只有标签匹配的节点才会被调度运行 worker。', '应用到每个 worker DaemonSet 的 Pod nodeSelector,只有标签匹配的节点才会被调度运行 worker。',
'clusters.gpuVendorOverrides.title': 'GPU 厂商覆盖配置', 'clusters.operatorImage.title': 'Operator 镜像',
'clusters.gpuVendorOverrides.validate.emptySelector': 'clusters.operatorImage.tip':
'{vendor} 的覆盖配置至少需要一项 nodeSelector —— 空覆盖会让多 vendor manifest 无法定位该 runtime 的节点。', 'GPUStack Operator 容器镜像的覆盖值。留空则使用服务端默认值。',
'clusters.gpuVendorOverrides.validate.duplicate': 'clusters.namespace.title': '命名空间',
'{v1} 与 {v2} 的覆盖配置使用了完全相同的 nodeSelector。每个 vendor 必须对应不同的节点集,否则它们的 worker DaemonSet 会相互争抢同一批节点。', 'clusters.namespace.tip':
'clusters.gpuVendorOverrides.validate.keyConflict': '集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
'{vendor} 的覆盖配置重用了基础节点选择器中已有的 key({keys})—— 这会让 CPU worker 同时"要求"和"禁止"这些 key,永远无法被调度。', 'clusters.gpuInstances.title': 'GPU 实例',
'clusters.gpuVendorOverrides.tip': 'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
'集群中存在多个 GPU 厂商时必填。每项的 nodeSelector 将该厂商的 worker DaemonSet 固定到匹配的节点,同时 CPU worker 通过 DoesNotExist 节点亲和性避开这些节点。', 'clusters.gpuInstances.staticAddress': '静态访问地址',
'clusters.gpuVendorOverrides.add': '添加覆盖配置', 'clusters.gpuInstances.staticAddress.tip':
'clusters.gpuVendorOverrides.vendor': 'GPU 厂商', 'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。'
'clusters.gpuVendorOverrides.nodeSelector': '节点选择器'
}; };
@@ -3,13 +3,10 @@ import {
GPUDriverMap, GPUDriverMap,
GPUsConfigs GPUsConfigs
} from '@/pages/resources/config/gpu-driver'; } from '@/pages/resources/config/gpu-driver';
import { BulbOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Alert, Tag } from 'antd'; import { Tag } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { queryClusterItem } from '../../apis';
import { ProviderValueMap } from '../../config'; import { ProviderValueMap } from '../../config';
import { ClusterListItem } from '../../config/types';
import SupportedGPUs from '../support-gpus'; import SupportedGPUs from '../support-gpus';
import { useAddWorkerContext } from './add-worker-context'; import { useAddWorkerContext } from './add-worker-context';
import { AddWorkerStepProps, StepNamesMap } from './config'; import { AddWorkerStepProps, StepNamesMap } from './config';
@@ -26,75 +23,25 @@ const buildWorkerCommand = (
}); });
const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => { const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const { stepList, registerField, updateField, provider, registrationInfo } = const { stepList, registerField, updateField, provider } =
useAddWorkerContext(); useAddWorkerContext();
const intl = useIntl(); const intl = useIntl();
const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1; const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1;
// Pull the cluster so we know whether gpuVendorOverrides was configured. // K8s clusters render one worker DaemonSet per requested GPU runtime and
// The K8s register flow gates multi-select on that being present, and // derive each DaemonSet's nodeSelector from the vendor's PCI-presence label
// restricts available runtimes to its keys. // at manifest time, so multiple vendors can be registered without any
const [overrideRuntimes, setOverrideRuntimes] = useState<string[]>([]); // per-cluster override config. Multi-select is therefore always available
useEffect(() => { // for the Kubernetes provider; other providers stay single-select.
const id = registrationInfo?.cluster_id; const multiCapable = provider === ProviderValueMap.Kubernetes;
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<string>();
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<string[]>([ const [selectedKeys, setSelectedKeys] = useState<string[]>([
GPUDriverMap.NVIDIA GPUDriverMap.NVIDIA
]); ]);
const isMultiActive = useMemo( // No vendor is gated anymore — every card stays selectable.
() => multiCapable && selectedKeys.some((k) => overrideKeys.has(k)), const availableKeys = undefined;
[multiCapable, selectedKeys, overrideKeys]
);
// 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;
// Cache vendor metadata (label/link from SupportedGPUs items) so we can // Cache vendor metadata (label/link from SupportedGPUs items) so we can
// rebuild workerCommand on toggle without re-clicking the card. // rebuild workerCommand on toggle without re-clicking the card.
@@ -126,10 +73,6 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
}, []); }, []);
const handleSelect = (key: string, item: any) => { 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) { if (item) {
itemMetaRef.current[key] = { itemMetaRef.current[key] = {
label: item.label, label: item.label,
@@ -142,21 +85,13 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
// Clicking a selected card always toggles it off. // Clicking a selected card always toggles it off.
return prev.filter((v) => v !== key); return prev.filter((v) => v !== key);
} }
// Multi-add only when the current state already includes an override // K8s clusters support multiple GPU runtimes, so accumulate picks.
// pick. Otherwise (still in single-select land), replace. // Other providers stay single-select and replace the current pick.
if (isMultiActive) return [...prev, key]; if (multiCapable) return [...prev, key];
return [key]; return [key];
}); });
}; };
// 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 ( return (
<StepCollapse <StepCollapse
disabled={disabled} disabled={disabled}
@@ -182,17 +117,6 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
</Title> </Title>
} }
> >
{showSingleOnlyHint && (
<Alert
type="info"
showIcon
icon={<BulbOutlined />}
style={{ marginBottom: 8 }}
message={intl.formatMessage({
id: 'clusters.addworker.selectGPU.singleOnly'
})}
/>
)}
<SupportedGPUs <SupportedGPUs
onSelect={handleSelect} onSelect={handleSelect}
current={selectedKeys} current={selectedKeys}
@@ -9,9 +9,8 @@ import {
Textarea as SealTextArea Textarea as SealTextArea
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form, message } from 'antd'; import { Form } from 'antd';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import _ from 'lodash';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import { ProviderType, ProviderValueMap } from '../config'; import { ProviderType, ProviderValueMap } from '../config';
import { import {
@@ -20,6 +19,7 @@ import {
} from '../config/types'; } from '../config/types';
import AdvanceConfig from '../step-forms/advance-config'; import AdvanceConfig from '../step-forms/advance-config';
import CloudProvider from './cloud-provider-form'; import CloudProvider from './cloud-provider-form';
import K8sPodSpec from './k8s-pod-spec';
type AddModalProps = { type AddModalProps = {
action: PageActionType; action: PageActionType;
@@ -34,6 +34,11 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const [form] = Form.useForm(); const [form] = Form.useForm();
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); const [activeKey, setActiveKey] = React.useState<string[]>([]);
// K8s deployment options is its own top-level section (sibling of Advanced),
// open by default so the fields are visible without an extra click.
const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
'k8sOptions'
]);
const advanceConfigRef = React.useRef<any>(null); const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
@@ -54,94 +59,48 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
} }
}, [activeKey, action]); }, [activeKey, action]);
// Mirror the backend's `_validate_multi_vendor_overrides` check so the // The backend models the optional k8s_options string knobs as
// user sees the problem at save time instead of as a 400 when they // Optional[str] and treats null/absent as "use the server default" or
// run the register command. Returns the i18n'd message of the first // "no auth". Coerce empty form values to null before sending so a blank
// problem found, or null when clean. // input is unambiguous rather than an empty string that defeats fallbacks.
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;
// 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 normalizeOutgoing = (values: any): any => {
const creds = values?.k8s_options?.imageCredentials; const opts = values?.k8s_options;
if (!Array.isArray(creds)) return values; if (!opts) return values;
const fixed = creds.map((c: any) => ({
...c, const next: any = { ...opts };
username: c?.username || null,
password: c?.password || null const creds = opts.imageCredentials;
})); if (Array.isArray(creds)) {
return { next.imageCredentials = creds.map((c: any) => ({
...values, ...c,
k8s_options: { ...values.k8s_options, imageCredentials: fixed } username: c?.username || null,
}; password: c?.password || null
}));
}
next.operatorImage = opts.operatorImage || null;
next.namespace = opts.namespace || null;
// Presence of gpuInstanceOptions is the enable flag; keep it only when
// the toggle left an object behind, coercing a blank address to null.
if (opts.gpuInstanceOptions) {
next.gpuInstanceOptions = {
gpuInstancesAccessStaticAddress:
opts.gpuInstanceOptions.gpuInstancesAccessStaticAddress || null
};
}
return { ...values, k8s_options: next };
}; };
const handleOnFinish = (_values: FormData) => { const handleOnFinish = (_values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue()); const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
// antd's onFinish only delivers values for registered Form.Items. // antd's onFinish only delivers values for registered Form.Items.
// Spreading those on top of `getFieldsValue(true)` clobbers nested // Spreading those on top of `getFieldsValue(true)` clobbers nested
// objects (e.g. `k8s_options` would lose `gpuVendorOverrides`, which is // objects (e.g. `k8s_options` would lose values set via setFieldValue),
// only set via setFieldValue), so we go straight to the full store. // so we go straight to the full store.
const fullValues = form.getFieldsValue(true); const fullValues = form.getFieldsValue(true);
const overridesErr = validateGpuVendorOverrides(fullValues);
if (overridesErr) {
message.error(overridesErr);
return;
}
onFinish( onFinish(
normalizeOutgoing({ normalizeOutgoing({
...fullValues, ...fullValues,
@@ -236,21 +195,11 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
validateFields: async () => { validateFields: async () => {
// Run validation first to display any field errors. Then read the // Run validation first to display any field errors. Then read the
// FULL store via `getFieldsValue(true)` so values that were set via // FULL store via `getFieldsValue(true)` so values that were set via
// setFieldValue on non-registered paths (e.g. gpuVendorOverrides) // setFieldValue on non-registered paths are still included in what we
// are still included in what we hand to the API. // hand to the API.
await form.validateFields(); await form.validateFields();
const values = form.getFieldsValue(true); 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( const workerConfig = yaml2Json(
advanceConfigRef.current?.getYamlValue() advanceConfigRef.current?.getYamlValue()
); );
@@ -314,6 +263,37 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
></SealTextArea> ></SealTextArea>
</Form.Item> </Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<CollapsePanel
accordion={false}
activeKey={k8sActiveKey}
onChange={(keys) =>
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
}
items={[
{
key: 'k8sOptions',
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
forceRender: true,
children: (
// Key by cluster id so the section fully remounts when the
// active cluster changes. GpuInstanceOptionsForm seeds its
// local state from initialValue only once (initializedRef),
// so without a remount a reused form instance could carry a
// previous cluster's GPU instance config into the next one.
<K8sPodSpec
key={currentData?.id ?? 'new'}
action={action}
initialGpuInstanceOptions={
currentData?.k8s_options?.gpuInstanceOptions
}
></K8sPodSpec>
)
}
]}
></CollapsePanel>
)}
<CollapsePanel <CollapsePanel
accordion={false} accordion={false}
activeKey={activeKey} activeKey={activeKey}
@@ -1,21 +1,16 @@
import { PageActionType } from '@/config/types';
import { import {
MinusOutlined, MinusOutlined,
PlusOutlined, PlusOutlined,
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui';
Input as CInput,
CollapseContainer,
LabelSelector,
Select as SealSelect,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Form, Tooltip } from 'antd'; import { Button, Form, Switch, Tooltip } from 'antd';
import _ from 'lodash'; import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { GPUsConfigs } from '../../resources/config/gpu-driver'; import { GpuInstanceOptions } from '../config/types';
import K8SVolumeMount from './k8s-volume-mount';
const Title = styled.div` const Title = styled.div`
display: flex; display: flex;
@@ -28,13 +23,6 @@ const Title = styled.div`
padding-bottom: 8px; padding-bottom: 8px;
`; `;
const Label = styled.span`
display: flex;
align-items: center;
gap: 4px;
color: var(--ant-color-text-secondary);
`;
const SectionWrap = styled.div` const SectionWrap = styled.div`
margin-bottom: 16px; margin-bottom: 16px;
`; `;
@@ -182,106 +170,107 @@ const NodeSelectorForm: React.FC = () => {
); );
}; };
const vendorOptions = Object.values(GPUsConfigs) // Render namespace. Kept as the first field of the section so the most
.filter((c) => !!c.gpuVendor) // fundamental K8s deployment knob is set before the rest.
.map((c) => ({ label: c.label, value: c.gpuVendor as string })); const NamespaceForm: React.FC = () => {
const intl = useIntl();
const GpuVendorOverridesForm: React.FC<{ return (
initialValue?: Record<string, { nodeSelector?: Record<string, string> }>; <SectionWrap>
<Form.Item
name={['k8s_options', 'namespace']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.namespace.title' })}
description={intl.formatMessage({ id: 'clusters.namespace.tip' })}
placeholder="gpustack-system"
></CInput.Input>
</Form.Item>
</SectionWrap>
);
};
// Operator-image override. A plain string knob that used to ride along inside
// worker_config; it now lives directly on k8s_options.
const OperatorImageForm: React.FC = () => {
const intl = useIntl();
return (
<SectionWrap>
<Form.Item
name={['k8s_options', 'operatorImage']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })}
description={intl.formatMessage({ id: 'clusters.operatorImage.tip' })}
></CInput.Input>
</Form.Item>
</SectionWrap>
);
};
// GPU-instance support. The backend treats the mere presence of
// `gpuInstanceOptions` as the enable flag, so the switch toggles the whole
// object in/out of the form rather than setting a boolean field; the static
// address (optional even when enabled) is nested underneath.
//
// We drive the toggle from local state (not Form.useWatch) because the
// gpuInstanceOptions path has no registered Form.Item of its own — useWatch
// doesn't reliably re-render on setFieldValue for such paths, which left the
// switch unresponsive. Local state owns the visible state and we mirror it
// into the form via setFieldValue so submit still collects it.
const GpuInstanceOptionsForm: React.FC<{
initialValue?: GpuInstanceOptions;
}> = ({ initialValue }) => { }> = ({ initialValue }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const [enabled, setEnabled] = useState<boolean>(!!initialValue);
const [address, setAddress] = useState<string>(
initialValue?.gpuInstancesAccessStaticAddress || ''
);
const initializedRef = useRef<boolean>(!!initialValue);
// Single source of truth for the cards while the user is editing. const writeForm = (en: boolean, addr: string) => {
// We mirror it into the form via setFieldValue so submit picks it up. form.setFieldValue(
// Seed local state directly from the parent's currentData — Form.useWatch ['k8s_options', 'gpuInstanceOptions'],
// on a non-registered nested path proved unreliable for picking up en ? { gpuInstancesAccessStaticAddress: addr } : undefined
// initialValues, so we cut out that indirection. );
const [overrides, setOverrides] = useState< };
Record<string, { nodeSelector?: Record<string, string> }>
>(() => initialValue || {});
const [collapseKey, setCollapseKey] = useState<Set<string>>(new Set());
const initializedRef = useRef(!!initialValue);
// On mount: if we seeded with an initialValue, mirror it into the form so // Mirror a seeded initial value into the form on mount so submit collects it.
// submit collects it. (When seeded, initializedRef is already true.)
useEffect(() => { useEffect(() => {
if (initialValue && Object.keys(initialValue).length > 0) { if (initialValue) {
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue); writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
} }
}, []); }, []);
// If the parent currentData arrives after mount (e.g. async fetch), adopt // Adopt currentData arriving after mount (async edit load), once. After the
// it once. After the user has interacted (`initializedRef`), local state // user has interacted (`initializedRef`), local state owns the section.
// owns the visible list.
useEffect(() => { useEffect(() => {
if (initializedRef.current) return; if (initializedRef.current) return;
if (initialValue && Object.keys(initialValue).length > 0) { if (initialValue) {
setOverrides(initialValue); setEnabled(true);
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue); setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
initializedRef.current = true; initializedRef.current = true;
} }
}, [initialValue, form]); }, [initialValue]);
// Once the user has touched this section, keep the form mirror in sync const handleToggle = (checked: boolean) => {
// 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<string, any>) => {
setOverrides(next);
form.setFieldValue(
['k8s_options', 'gpuVendorOverrides'],
Object.keys(next).length > 0 ? next : undefined
);
initializedRef.current = true; initializedRef.current = true;
setEnabled(checked);
if (!checked) {
setAddress('');
}
writeForm(checked, checked ? address : '');
}; };
const handleAdd = () => { const handleAddressChange = (e: any) => {
if (availableVendors.length === 0) return; const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
const next = availableVendors[0].value; setAddress(next);
writeOverrides({ ...overrides, [next]: { nodeSelector: {} } }); writeForm(true, next);
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<string, string>
) => {
writeOverrides({
...overrides,
[vendor]: { ...overrides[vendor], nodeSelector: labels }
});
};
const onToggle = (open: boolean, key: string) => {
setCollapseKey(open ? new Set([key]) : new Set());
}; };
return ( return (
@@ -290,122 +279,48 @@ const GpuVendorOverridesForm: React.FC<{
<div className="flex-center gap-8"> <div className="flex-center gap-8">
<span className="flex-center gap-4"> <span className="flex-center gap-4">
<span> <span>
{intl.formatMessage({ id: 'clusters.gpuVendorOverrides.title' })} {intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
</span> </span>
<Tooltip <Tooltip
title={intl.formatMessage({ title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
id: 'clusters.gpuVendorOverrides.tip'
})}
> >
<QuestionCircleOutlined <QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-secondary)' }} style={{ color: 'var(--ant-color-text-secondary)' }}
/> />
</Tooltip> </Tooltip>
</span> </span>
<Button <Switch checked={enabled} onChange={handleToggle} />
type="link"
onClick={handleAdd}
disabled={availableVendors.length === 0}
>
<PlusOutlined />{' '}
{intl.formatMessage({ id: 'clusters.gpuVendorOverrides.add' })}
</Button>
</div> </div>
</Title> </Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> {enabled && (
{vendorKeys.map((vendor) => { <CInput.Input
const optionsForThisRow = [ isInFormItems={false}
...vendorOptions.filter( value={address}
(opt) => opt.value === vendor || !vendorKeys.includes(opt.value) onChange={handleAddressChange}
) label={intl.formatMessage({
]; id: 'clusters.gpuInstances.staticAddress'
const vendorLabel = })}
vendorOptions.find((o) => o.value === vendor)?.label || vendor; description={intl.formatMessage({
return ( id: 'clusters.gpuInstances.staticAddress.tip'
<div })}
key={vendor} ></CInput.Input>
style={{ )}
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<CollapseContainer
collapsible={true}
showExpandIcon={true}
open={collapseKey.has(vendor)}
onToggle={(open: boolean) => onToggle(open, vendor)}
styles={{
body: collapseKey.has(vendor) ? { padding: 16 } : {},
content: { paddingTop: 0 },
header: { backgroundColor: 'unset' }
}}
title={
<Label>
<span>
{intl.formatMessage({
id: 'clusters.gpuVendorOverrides.vendor'
})}
:
</span>
<span>{vendorLabel}</span>
</Label>
}
right={
<Button
size="small"
shape="circle"
onClick={() => handleRemove(vendor)}
>
<MinusOutlined />
</Button>
}
>
<div style={{ marginBottom: 16, width: '100%' }}>
<SealSelect
isInFormItems={false}
required
style={{ width: '100%' }}
label={intl.formatMessage({
id: 'clusters.gpuVendorOverrides.vendor'
})}
value={vendor}
options={optionsForThisRow}
onChange={(value: string) =>
handleVendorChange(vendor, value)
}
></SealSelect>
</div>
<LabelSelector
label={intl.formatMessage({
id: 'clusters.gpuVendorOverrides.nodeSelector'
})}
value={overrides[vendor]?.nodeSelector || {}}
onChange={(labels) =>
handleNodeSelectorChange(vendor, labels)
}
></LabelSelector>
</CollapseContainer>
</div>
);
})}
</div>
</SectionWrap> </SectionWrap>
); );
}; };
type GpuVendorOverridesValue = Record<
string,
{ nodeSelector?: Record<string, string> }
>;
const K8sPodSpec: React.FC<{ const K8sPodSpec: React.FC<{
initialOverrides?: GpuVendorOverridesValue; action: PageActionType;
}> = ({ initialOverrides }) => { initialGpuInstanceOptions?: GpuInstanceOptions;
}> = ({ action, initialGpuInstanceOptions }) => {
return ( return (
<> <>
<NamespaceForm />
<K8SVolumeMount action={action}></K8SVolumeMount>
<ImageCredentialsForm /> <ImageCredentialsForm />
<NodeSelectorForm /> <NodeSelectorForm />
<GpuVendorOverridesForm initialValue={initialOverrides} /> <OperatorImageForm />
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
</> </>
); );
}; };
+23 -4
View File
@@ -74,17 +74,31 @@ export interface ImageCredential {
password: string; password: string;
} }
export interface GpuInstanceOptions {
// The mere presence of `gpuInstanceOptions` on `k8s_options` signals
// "GPU instances enabled" for the cluster — absence opts the cluster out,
// so there's no separate boolean flag on the wire.
gpuInstancesAccessStaticAddress?: string | null;
}
export interface K8sOptions { export interface K8sOptions {
// Backend serializes K8sOptions with camelCase aliases (by_alias=True on // Backend serializes K8sOptions with camelCase aliases (by_alias=True on
// the SQL JSON column). The top-level `k8s_options` field on the cluster // the SQL JSON column). The top-level `k8s_options` field on the cluster
// stays snake_case, but everything inside follows the backend wire shape. // stays snake_case, but everything inside follows the backend wire shape.
volumeMounts?: VolumeMount[]; volumeMounts?: VolumeMount[];
imageCredentials?: ImageCredential[]; imageCredentials?: ImageCredential[];
// Base nodeSelector applied to every worker DaemonSet; each per-runtime
// DaemonSet additionally gets a vendor PCI-presence label merged on top at
// render time, so per-vendor overrides are no longer configured here.
nodeSelector?: Record<string, string>; nodeSelector?: Record<string, string>;
gpuVendorOverrides?: Record< // Override for the gpustack-operator container image. Falls back to the
string, // server's default when unset.
{ nodeSelector?: Record<string, string> } operatorImage?: string | null;
>; // GPU-instance support knobs; presence enables GPU instance handling.
gpuInstanceOptions?: GpuInstanceOptions;
// Kubernetes namespace the cluster's manifests render into. Falls back to
// `gpustack-system` at render time when unset.
namespace?: string | null;
} }
export interface ClusterListItem { export interface ClusterListItem {
@@ -93,6 +107,10 @@ export interface ClusterListItem {
is_default: boolean; is_default: boolean;
description: string; description: string;
worker_config: Record<string, any>; worker_config: Record<string, any>;
// Per-cluster default container registry, promoted out of worker_config to
// a top-level column on the backend (image resolution / registration token
// read it directly). Falls back to the server default when unset.
system_default_container_registry?: string | null;
provider: ProviderType; provider: ProviderType;
credential_id: number; credential_id: number;
created_at: string; created_at: string;
@@ -122,6 +140,7 @@ export interface ClusterFormData {
region: string; region: string;
server_url?: string; server_url?: string;
worker_config?: Record<string, any>; worker_config?: Record<string, any>;
system_default_container_registry?: string | null;
worker_pools?: NodePoolFormData[]; worker_pools?: NodePoolFormData[];
k8s_options?: K8sOptions; k8s_options?: K8sOptions;
} }
@@ -26,15 +26,6 @@
"type": "string", "type": "string",
"description": "Container image repository" "description": "Container image repository"
}, },
"operator_image": {
"type": "string",
"description": "GPUStack operator image, format: <repo>:<tag>",
"examples": ["gpustack/gpustack-operator:<related version>"]
},
"gpu_instances_access_static_address": {
"type": "string",
"description": "Static address used to access GPU instances"
},
"gateway_mode": { "gateway_mode": {
"type": "string", "type": "string",
"description": "Gateway mode", "description": "Gateway mode",
@@ -53,10 +44,6 @@
"type": "string", "type": "string",
"description": "Service discovery name" "description": "Service discovery name"
}, },
"namespace": {
"type": "string",
"description": "Kubernetes namespace"
},
"huggingface_token": { "huggingface_token": {
"type": "string", "type": "string",
"description": "Hugging Face access token" "description": "Hugging Face access token"
@@ -56,16 +56,10 @@ export const kubernetesConfig = `# This is a template for worker_config.
# system_default_container_registry: "docker.io" # system_default_container_registry: "docker.io"
# image_name_override: "gpustack/gpustack:dev" # image_name_override: "gpustack/gpustack:dev"
# image_repo: "gpustack/gpustack" # image_repo: "gpustack/gpustack"
# operator_image: "gpustack/gpustack-operator:<related version>
# ========= GPU instances ===========
# gpu_instances_access_static_address: <ip or dns name>
# ========= service & networking =========== # ========= service & networking ===========
# service_discovery_name: "worker" # service_discovery_name: "worker"
# namespace: "gpustack-system"
# worker_port: 10150 # worker_port: 10150
# worker_metrics_port: 10150 # worker_metrics_port: 10150
# service_port_range: "40000-40063" # service_port_range: "40000-40063"
@@ -7,8 +7,6 @@ import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd'; import { Button, Form } from 'antd';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import styled from 'styled-components'; 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 { ProviderType, ProviderValueMap } from '../config';
import { import {
ClusterFormData as FormData, ClusterFormData as FormData,
@@ -90,14 +88,6 @@ const ClusterAdvanceConfig: React.FC<{
> >
<CInput.TextArea required={false} trim={false}></CInput.TextArea> <CInput.TextArea required={false} trim={false}></CInput.TextArea>
</Form.Item> </Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<>
<K8SVolumeMount action={action}></K8SVolumeMount>
<K8sPodSpec
initialOverrides={currentData?.k8s_options?.gpuVendorOverrides}
></K8sPodSpec>
</>
)}
<Title> <Title>
{intl.formatMessage({ id: 'clusters.create.workerConfig' })} {intl.formatMessage({ id: 'clusters.create.workerConfig' })}
</Title> </Title>