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:
@@ -3,13 +3,10 @@ import {
|
||||
GPUDriverMap,
|
||||
GPUsConfigs
|
||||
} from '@/pages/resources/config/gpu-driver';
|
||||
import { BulbOutlined } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Alert, Tag } from 'antd';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { queryClusterItem } from '../../apis';
|
||||
import { Tag } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -26,75 +23,25 @@ const buildWorkerCommand = (
|
||||
});
|
||||
|
||||
const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
const { stepList, registerField, updateField, provider, registrationInfo } =
|
||||
const { stepList, registerField, updateField, provider } =
|
||||
useAddWorkerContext();
|
||||
const intl = useIntl();
|
||||
|
||||
const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1;
|
||||
|
||||
// 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;
|
||||
// K8s clusters render one worker DaemonSet per requested GPU runtime and
|
||||
// derive each DaemonSet's nodeSelector from the vendor's PCI-presence label
|
||||
// at manifest time, so multiple vendors can be registered without any
|
||||
// per-cluster override config. Multi-select is therefore always available
|
||||
// for the Kubernetes provider; other providers stay single-select.
|
||||
const multiCapable = provider === ProviderValueMap.Kubernetes;
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([
|
||||
GPUDriverMap.NVIDIA
|
||||
]);
|
||||
|
||||
const isMultiActive = useMemo(
|
||||
() => multiCapable && selectedKeys.some((k) => overrideKeys.has(k)),
|
||||
[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;
|
||||
// No vendor is gated anymore — every card stays selectable.
|
||||
const availableKeys = undefined;
|
||||
|
||||
// Cache vendor metadata (label/link from SupportedGPUs items) so we can
|
||||
// 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) => {
|
||||
// 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,
|
||||
@@ -142,21 +85,13 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
// 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];
|
||||
// K8s clusters support multiple GPU runtimes, so accumulate picks.
|
||||
// Other providers stay single-select and replace the current pick.
|
||||
if (multiCapable) return [...prev, 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 (
|
||||
<StepCollapse
|
||||
disabled={disabled}
|
||||
@@ -182,17 +117,6 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
</Title>
|
||||
}
|
||||
>
|
||||
{showSingleOnlyHint && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<BulbOutlined />}
|
||||
style={{ marginBottom: 8 }}
|
||||
message={intl.formatMessage({
|
||||
id: 'clusters.addworker.selectGPU.singleOnly'
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<SupportedGPUs
|
||||
onSelect={handleSelect}
|
||||
current={selectedKeys}
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
Textarea as SealTextArea
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form, message } from 'antd';
|
||||
import { Form } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { ProviderType, ProviderValueMap } from '../config';
|
||||
import {
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from '../config/types';
|
||||
import AdvanceConfig from '../step-forms/advance-config';
|
||||
import CloudProvider from './cloud-provider-form';
|
||||
import K8sPodSpec from './k8s-pod-spec';
|
||||
|
||||
type AddModalProps = {
|
||||
action: PageActionType;
|
||||
@@ -34,6 +34,11 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
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 systemConfig = useAtomValue(systemConfigAtom);
|
||||
|
||||
@@ -54,94 +59,48 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}
|
||||
}, [activeKey, action]);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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.
|
||||
// The backend models the optional k8s_options string knobs as
|
||||
// Optional[str] and treats null/absent as "use the server default" or
|
||||
// "no auth". Coerce empty form values to null before sending so a blank
|
||||
// input is unambiguous rather than an empty string that defeats fallbacks.
|
||||
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 || null,
|
||||
password: c?.password || null
|
||||
}));
|
||||
return {
|
||||
...values,
|
||||
k8s_options: { ...values.k8s_options, imageCredentials: fixed }
|
||||
};
|
||||
const opts = values?.k8s_options;
|
||||
if (!opts) return values;
|
||||
|
||||
const next: any = { ...opts };
|
||||
|
||||
const creds = opts.imageCredentials;
|
||||
if (Array.isArray(creds)) {
|
||||
next.imageCredentials = creds.map((c: any) => ({
|
||||
...c,
|
||||
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 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.
|
||||
// objects (e.g. `k8s_options` would lose values 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,
|
||||
@@ -236,21 +195,11 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
validateFields: async () => {
|
||||
// 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.
|
||||
// setFieldValue on non-registered paths 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()
|
||||
);
|
||||
@@ -314,6 +263,37 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
></SealTextArea>
|
||||
</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
|
||||
accordion={false}
|
||||
activeKey={activeKey}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { PageActionType } from '@/config/types';
|
||||
import {
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Input as CInput,
|
||||
CollapseContainer,
|
||||
LabelSelector,
|
||||
Select as SealSelect,
|
||||
useAppUtils
|
||||
} from '@gpustack/core-ui';
|
||||
import { Input as CInput, LabelSelector, 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 { Button, Form, Switch, Tooltip } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
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`
|
||||
display: flex;
|
||||
@@ -28,13 +23,6 @@ const Title = styled.div`
|
||||
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;
|
||||
`;
|
||||
@@ -182,106 +170,107 @@ const NodeSelectorForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const vendorOptions = Object.values(GPUsConfigs)
|
||||
.filter((c) => !!c.gpuVendor)
|
||||
.map((c) => ({ label: c.label, value: c.gpuVendor as string }));
|
||||
// Render namespace. Kept as the first field of the section so the most
|
||||
// fundamental K8s deployment knob is set before the rest.
|
||||
const NamespaceForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const GpuVendorOverridesForm: React.FC<{
|
||||
initialValue?: Record<string, { nodeSelector?: Record<string, string> }>;
|
||||
return (
|
||||
<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 }) => {
|
||||
const intl = useIntl();
|
||||
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.
|
||||
// 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);
|
||||
const writeForm = (en: boolean, addr: string) => {
|
||||
form.setFieldValue(
|
||||
['k8s_options', 'gpuInstanceOptions'],
|
||||
en ? { gpuInstancesAccessStaticAddress: addr } : undefined
|
||||
);
|
||||
};
|
||||
|
||||
// On mount: if we seeded with an initialValue, mirror it into the form so
|
||||
// submit collects it. (When seeded, initializedRef is already true.)
|
||||
// Mirror a seeded initial value into the form on mount so submit collects it.
|
||||
useEffect(() => {
|
||||
if (initialValue && Object.keys(initialValue).length > 0) {
|
||||
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
|
||||
if (initialValue) {
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
// Adopt currentData arriving after mount (async edit load), once. After the
|
||||
// user has interacted (`initializedRef`), local state owns the section.
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
if (initialValue && Object.keys(initialValue).length > 0) {
|
||||
setOverrides(initialValue);
|
||||
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
|
||||
if (initialValue) {
|
||||
setEnabled(true);
|
||||
setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialValue, form]);
|
||||
}, [initialValue]);
|
||||
|
||||
// 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
|
||||
);
|
||||
const handleToggle = (checked: boolean) => {
|
||||
initializedRef.current = true;
|
||||
setEnabled(checked);
|
||||
if (!checked) {
|
||||
setAddress('');
|
||||
}
|
||||
writeForm(checked, checked ? address : '');
|
||||
};
|
||||
|
||||
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());
|
||||
const handleAddressChange = (e: any) => {
|
||||
const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
|
||||
setAddress(next);
|
||||
writeForm(true, next);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -290,122 +279,48 @@ const GpuVendorOverridesForm: React.FC<{
|
||||
<div className="flex-center gap-8">
|
||||
<span className="flex-center gap-4">
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'clusters.gpuVendorOverrides.title' })}
|
||||
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'clusters.gpuVendorOverrides.tip'
|
||||
})}
|
||||
title={intl.formatMessage({ id: 'clusters.gpuInstances.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>
|
||||
<Switch checked={enabled} onChange={handleToggle} />
|
||||
</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>
|
||||
{enabled && (
|
||||
<CInput.Input
|
||||
isInFormItems={false}
|
||||
value={address}
|
||||
onChange={handleAddressChange}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.gpuInstances.staticAddress'
|
||||
})}
|
||||
description={intl.formatMessage({
|
||||
id: 'clusters.gpuInstances.staticAddress.tip'
|
||||
})}
|
||||
></CInput.Input>
|
||||
)}
|
||||
</SectionWrap>
|
||||
);
|
||||
};
|
||||
|
||||
type GpuVendorOverridesValue = Record<
|
||||
string,
|
||||
{ nodeSelector?: Record<string, string> }
|
||||
>;
|
||||
|
||||
const K8sPodSpec: React.FC<{
|
||||
initialOverrides?: GpuVendorOverridesValue;
|
||||
}> = ({ initialOverrides }) => {
|
||||
action: PageActionType;
|
||||
initialGpuInstanceOptions?: GpuInstanceOptions;
|
||||
}> = ({ action, initialGpuInstanceOptions }) => {
|
||||
return (
|
||||
<>
|
||||
<NamespaceForm />
|
||||
<K8SVolumeMount action={action}></K8SVolumeMount>
|
||||
<ImageCredentialsForm />
|
||||
<NodeSelectorForm />
|
||||
<GpuVendorOverridesForm initialValue={initialOverrides} />
|
||||
<OperatorImageForm />
|
||||
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,17 +74,31 @@ export interface ImageCredential {
|
||||
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 {
|
||||
// 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[];
|
||||
// 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>;
|
||||
gpuVendorOverrides?: Record<
|
||||
string,
|
||||
{ nodeSelector?: Record<string, string> }
|
||||
>;
|
||||
// Override for the gpustack-operator container image. Falls back to the
|
||||
// server's default when unset.
|
||||
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 {
|
||||
@@ -93,6 +107,10 @@ export interface ClusterListItem {
|
||||
is_default: boolean;
|
||||
description: string;
|
||||
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;
|
||||
credential_id: number;
|
||||
created_at: string;
|
||||
@@ -122,6 +140,7 @@ export interface ClusterFormData {
|
||||
region: string;
|
||||
server_url?: string;
|
||||
worker_config?: Record<string, any>;
|
||||
system_default_container_registry?: string | null;
|
||||
worker_pools?: NodePoolFormData[];
|
||||
k8s_options?: K8sOptions;
|
||||
}
|
||||
|
||||
@@ -26,15 +26,6 @@
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Gateway mode",
|
||||
@@ -53,10 +44,6 @@
|
||||
"type": "string",
|
||||
"description": "Service discovery name"
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Kubernetes namespace"
|
||||
},
|
||||
"huggingface_token": {
|
||||
"type": "string",
|
||||
"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"
|
||||
# image_name_override: "gpustack/gpustack:dev"
|
||||
# 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_discovery_name: "worker"
|
||||
# namespace: "gpustack-system"
|
||||
# worker_port: 10150
|
||||
# worker_metrics_port: 10150
|
||||
# service_port_range: "40000-40063"
|
||||
|
||||
@@ -7,8 +7,6 @@ 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,
|
||||
@@ -90,14 +88,6 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
>
|
||||
<CInput.TextArea required={false} trim={false}></CInput.TextArea>
|
||||
</Form.Item>
|
||||
{provider === ProviderValueMap.Kubernetes && (
|
||||
<>
|
||||
<K8SVolumeMount action={action}></K8SVolumeMount>
|
||||
<K8sPodSpec
|
||||
initialOverrides={currentData?.k8s_options?.gpuVendorOverrides}
|
||||
></K8sPodSpec>
|
||||
</>
|
||||
)}
|
||||
<Title>
|
||||
{intl.formatMessage({ id: 'clusters.create.workerConfig' })}
|
||||
</Title>
|
||||
|
||||
Reference in New Issue
Block a user