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
// never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice.
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;
@@ -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
// in with surrounding form fields: same border, radius, padding, and
// 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
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
// already-entered static address). No standalone form field is registered; the
// selected card is tracked in local state (seeded from the form's initial
// value) and written back to the form on each click.
// on the submitted payload. No standalone form field is registered; the click
// only updates the shared `clusterType` state (see FormContext) — the payload's
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// normalizeOutgoing), and the static-address field mounts/unmounts off it.
export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext();
const labelId = useId();
// 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
// for submission. This replaced a Form.useWatch on an unregistered path that
// source of truth. This replaced a Form.useWatch on an unregistered path that
// did not re-render reliably when cleared to undefined.
const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (!form || 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);
}
if (next === value) return;
setClusterType?.(next);
});
@@ -18,10 +18,17 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
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, () => ({
validateFields: formRef.current?.validateFields,
getFieldsValue: formRef.current?.getFieldsValue,
submit: formRef.current?.submit
validateFields: (...args: any[]) =>
formRef.current?.validateFields(...args),
getFieldsValue: (...args: any[]) =>
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
}));
return (