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:
@@ -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}
|
||||
|
||||
@@ -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 -`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
Reference in New Issue
Block a user