fix(cluster): ensure gpuInstanceOptions is submitted for gpu k8s cluster

This commit is contained in:
jialin
2026-07-09 20:36:52 +08:00
committed by jialin
parent 650091b57a
commit 0d5812ba88
3 changed files with 24 additions and 28 deletions
@@ -97,7 +97,14 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
// unmounted when model is selected, but strip it here too so the payload // unmounted when model is selected, but strip it here too so the payload
// never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice. // never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice.
if (clusterType === 'model') { if (clusterType === 'model') {
delete next.gpuInstanceOptions; next.gpuInstanceOptions = null;
} else if (clusterType === 'gpu' && !next.gpuInstanceOptions) {
// gpuInstanceOptions has no always-mounted Form.Item (its only child,
// the optional static address, may be unmounted or empty), so with
// preserve={false} onFinish's `values` can omit it. Read it straight
// from the store so a "gpu" cluster always carries the field.
next.gpuInstanceOptions =
form.getFieldValue(['k8s_options', 'gpuInstanceOptions']) ?? {};
} }
const creds = opts.imageCredentials; const creds = opts.imageCredentials;
@@ -70,14 +70,6 @@ export const OperatorImageForm: React.FC = () => {
); );
}; };
// `gpuInstanceOptions` on `k8s_options` is the submitted representation of a
// "gpu" cluster. Its presence is driven by the shared `clusterType` state (see
// FormContext) — the selector writes it, the static-address field mounts under
// it, and submit strips it for "model". Kept out of any Form.useWatch because
// this path has no always-mounted Form.Item and watching it re-rendered
// unreliably.
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends // Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and // in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an // typography. The only differences are the two-column grid layout and an
@@ -172,32 +164,22 @@ const RadioDot = styled.span<{ $active: boolean }>`
// Card-based selector for cluster type. The two options are mutually exclusive // Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions` // and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any // on the submitted payload. No standalone form field is registered; the click
// already-entered static address). No standalone form field is registered; the // only updates the shared `clusterType` state (see FormContext) — the payload's
// selected card is tracked in local state (seeded from the form's initial // `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// value) and written back to the form on each click. // normalizeOutgoing), and the static-address field mounts/unmounts off it.
export const ClusterTypeSelector: React.FC = () => { export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext(); const { presetClusterType } = useStepsContext();
const labelId = useId(); const labelId = useId();
// Cluster type is shared, explicit state (see FormContext): the click is the // Cluster type is shared, explicit state (see FormContext): the click is the
// source of truth. We update that state and mirror the choice onto the form // source of truth. This replaced a Form.useWatch on an unregistered path that
// for submission. This replaced a Form.useWatch on an unregistered path that
// did not re-render reliably when cleared to undefined. // did not re-render reliably when cleared to undefined.
const { clusterType, setClusterType } = useFormContext(); const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model'; const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => { const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (!form || next === value) return; if (next === value) return;
if (next === 'gpu') {
form.setFieldValue(
GPU_INSTANCE_OPTIONS_PATH,
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
);
} else {
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
}
setClusterType?.(next); setClusterType?.(next);
}); });
@@ -18,10 +18,17 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
console.log(values); console.log(values);
}; };
// Forward through to the live ClusterForm ref on each call instead of
// snapshotting its methods. ClusterForm rebuilds these closures whenever its
// internal state (e.g. clusterType) changes, but BasicForm does not re-render
// with it — a frozen snapshot would keep calling stale closures (reading the
// initial clusterType) and be null on the first render before the ref attaches.
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
validateFields: formRef.current?.validateFields, validateFields: (...args: any[]) =>
getFieldsValue: formRef.current?.getFieldsValue, formRef.current?.validateFields(...args),
submit: formRef.current?.submit getFieldsValue: (...args: any[]) =>
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
})); }));
return ( return (