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.
This commit is contained in:
Yuxing Deng
2026-05-26 16:54:14 +08:00
parent 91c3d0b814
commit acb90531b2
21 changed files with 1023 additions and 112 deletions
@@ -96,6 +96,28 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (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 (
<AddWorkerContext.Provider
value={{
@@ -123,16 +145,20 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
!stepList.includes(StepNamesMap.SelectCluster)) && (
<>
<SelectVendor disabled={disabled}></SelectVendor>
<CheckEnvironment disabled={disabled}></CheckEnvironment>
<CheckEnvironment disabled={downstreamDisabled}></CheckEnvironment>
{provider === ProviderValueMap.Kubernetes && (
<K8sRunCommand disabled={disabled}></K8sRunCommand>
<K8sRunCommand disabled={downstreamDisabled}></K8sRunCommand>
)}
{provider === ProviderValueMap.Docker && (
<>
<SpecifyArguments disabled={disabled}></SpecifyArguments>
<DockerRunCommand disabled={disabled}></DockerRunCommand>
<SpecifyArguments
disabled={downstreamDisabled}
></SpecifyArguments>
<DockerRunCommand
disabled={downstreamDisabled}
></DockerRunCommand>
</>
)}
</>
@@ -11,6 +11,7 @@ const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ 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<AddWorkerStepProps> = ({ disabled }) => {
<Typography.Paragraph style={{ marginBottom: 8 }}>
{intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })}
</Typography.Paragraph>
<CheckEnvCommand provider={provider} currentGPU={currentGPU} />
<CheckEnvCommand
provider={provider}
currentGPU={currentGPU}
currentGPUs={currentGPUs}
/>
</StepCollapse>
);
};
@@ -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: {
@@ -12,6 +12,7 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const stepIndex = stepList.indexOf(StepNamesMap.RunCommand) + 1;
const currentGPU = summary.get('currentGPU') || '';
const currentGPUs: string[] = summary.get('selectedGPUs') || [];
return (
<StepCollapse
@@ -36,6 +37,7 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
<RegisterClusterInner
registrationInfo={registrationInfo}
currentGPU={currentGPU}
currentGPUs={currentGPUs}
/>
</StepCollapse>
);
@@ -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<AddWorkerStepProps> = ({ 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<string>(
// 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<string[]>([]);
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<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[]>([
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<Record<string, { label: string; link: string }>>(
{}
);
// 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 (
<StepCollapse
@@ -58,12 +165,38 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
<Title>
{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>
)}
</Title>
}
>
{showSingleOnlyHint && (
<Alert
type="info"
showIcon
icon={<BulbOutlined />}
style={{ marginBottom: 8 }}
message={intl.formatMessage({
id: 'clusters.addworker.selectGPU.singleOnly'
})}
/>
)}
<SupportedGPUs
onSelect={handleSelectProvider}
current={currentGPU}
onSelect={handleSelect}
current={selectedKeys}
availableKeys={availableKeys}
clickable={true}
/>
</StepCollapse>
@@ -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<ViewModalProps> = ({
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 (
<HighlightCode
@@ -1,3 +1,4 @@
import { systemConfigAtom } from '@/atoms/system';
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
@@ -8,7 +9,9 @@ import {
Textarea as SealTextArea
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { Form, message } from 'antd';
import { useAtomValue } from 'jotai';
import _ from 'lodash';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import {
@@ -32,6 +35,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
const advanceConfigRef = React.useRef<any>(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<AddModalProps> = 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<AddModalProps> = 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<AddModalProps> = 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<AddModalProps> = forwardRef(
<AdvanceConfig
action={action}
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
)
@@ -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<string, any> = {
root: {
@@ -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 (
<SectionWrap>
<Form.List name={['k8s_options', 'imageCredentials']}>
{(fields, { add, remove }) => (
<>
<Title>
<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>
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{fields.map(({ key, name }) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 12,
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 12
}}
>
<Form.Item
name={[name, 'registry']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.imageCredentials.registry'
)
}
]}
style={{ marginBottom: 0 }}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'clusters.imageCredentials.registry'
})}
></CInput.Input>
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'username']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({
id: 'clusters.imageCredentials.username'
})}
></CInput.Input>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'password']}
style={{ marginBottom: 0 }}
>
<CInput.Password
label={intl.formatMessage({
id: 'clusters.imageCredentials.password'
})}
></CInput.Password>
</Form.Item>
</div>
</div>
</div>
<Button
size="small"
shape="circle"
style={{ marginTop: 8 }}
onClick={() => remove(name)}
>
<MinusOutlined />
</Button>
</div>
))}
</div>
</>
)}
</Form.List>
</SectionWrap>
);
};
const NodeSelectorForm: React.FC = () => {
const intl = useIntl();
return (
<SectionWrap>
<Title>
<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>
</Title>
<Form.Item name={['k8s_options', 'nodeSelector']}>
<LabelSelector
label={intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
></LabelSelector>
</Form.Item>
</SectionWrap>
);
};
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<string, { nodeSelector?: Record<string, string> }>;
}> = ({ 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<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
// 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<string, any>) => {
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<string, string>
) => {
writeOverrides({
...overrides,
[vendor]: { ...overrides[vendor], nodeSelector: labels }
});
};
const onToggle = (open: boolean, key: string) => {
setCollapseKey(open ? new Set([key]) : new Set());
};
return (
<SectionWrap>
<Title>
<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>
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{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 (
<div
key={vendor}
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>
);
};
type GpuVendorOverridesValue = Record<
string,
{ nodeSelector?: Record<string, string> }
>;
const K8sPodSpec: React.FC<{
initialOverrides?: GpuVendorOverridesValue;
}> = ({ initialOverrides }) => {
return (
<>
<ImageCredentialsForm />
<NodeSelectorForm />
<GpuVendorOverridesForm initialValue={initialOverrides} />
</>
);
};
export default K8sPodSpec;
@@ -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<Set<number | string>>(
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'
}}
>
<Form.List name="k8s_volume_mounts">
<Form.List name={['k8s_options', 'volumeMounts']}>
{(fields, { remove }) => {
const list = form.getFieldValue('k8s_volume_mounts') || [];
const list =
form.getFieldValue(['k8s_options', 'volumeMounts']) || [];
return fields.map(({ name }) => {
const item = list[name] || {};
@@ -68,7 +68,8 @@ interface ProviderCatalogProps {
onSelect?: (provider: string, item: any) => void;
groupIcons?: Record<string, string>;
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<ProviderCatalogProps> = ({
<TemplateCard
height={height}
onClick={() => 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)}
@@ -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<AddModalProps> = ({
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 (
<div>
@@ -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<string>;
}
const SupportedHardware: React.FC<SupportedHardwareProps> = ({
onSelect,
clickable,
current
current,
availableKeys
}) => {
const intl = useIntl();
const { userSettings } = useUserSettings();
@@ -199,13 +204,20 @@ const SupportedHardware: React.FC<SupportedHardwareProps> = ({
}
];
const platformsWithDisabled = availableKeys
? supportedHardPlatforms.map((p) => ({
...p,
disabled: !availableKeys.has(p.value)
}))
: supportedHardPlatforms;
return (
<Box className={userSettings?.theme === 'realDark' ? 'dark-theme' : ''}>
<ProviderCatalog
onSelect={onSelect}
height={60}
current={current}
dataList={supportedHardPlatforms}
dataList={platformsWithDisabled}
clickable={clickable}
showTooltip={true}
cols={5}
+15 -2
View File
@@ -44,13 +44,26 @@ export const ProviderLabelMap = {
};
export const generateK8sRegisterCommand = (params: {
// Either a single GPU driver key (legacy single-select) or an array of
// keys (multi-vendor mode). Both feed into a list of runtimes for the
// ?runtime=... query parameters the backend accepts (repeatable).
currentGPU?: string;
currentGPUs?: string[];
server: string;
clusterId: number | null;
registrationToken: string;
}) => {
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 -`;
};
+21 -2
View File
@@ -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<string, string>;
gpuVendorOverrides?: Record<
string,
{ nodeSelector?: Record<string, string> }
>;
}
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<string, any>;
worker_pools?: NodePoolFormData[];
k8s_volume_mounts?: VolumeMount[];
k8s_options?: K8sOptions;
}
export interface SystemConfig {
@@ -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<any>(null);
@@ -75,7 +80,12 @@ const ClusterAdvanceConfig: React.FC<{
<CInput.TextArea required={false} trim={false}></CInput.TextArea>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<K8SVolumeMount action={action}></K8SVolumeMount>
<>
<K8SVolumeMount action={action}></K8SVolumeMount>
<K8sPodSpec
initialOverrides={currentData?.k8s_options?.gpuVendorOverrides}
></K8sPodSpec>
</>
)}
<Title>
{intl.formatMessage({ id: 'clusters.create.workerConfig' })}