From 90cc6ec75334087a65387589d8db3579fb29e1bc Mon Sep 17 00:00:00 2001 From: jialin Date: Tue, 7 Jul 2026 20:01:09 +0800 Subject: [PATCH] fix(cluster): drive k8s cluster type from shared state Replace unreliable Form.useWatch on the unregistered gpuInstanceOptions path with explicit shared clusterType state. Fixes the type selector being unclickable and flickering, and ensures a model cluster no longer submits a stale gpuInstanceOptions payload. --- .../components/cluster-form.tsx | 17 ++++++- .../components/k8s-pod-spec.tsx | 48 ++++++++++--------- .../cluster-management/config/form-context.ts | 6 +++ 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/pages/cluster-management/components/cluster-form.tsx b/src/pages/cluster-management/components/cluster-form.tsx index 1d8f3f03..616bed8b 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -59,6 +59,12 @@ const ClusterForm: React.FC = forwardRef( const intl = useIntl(); const [activeKey, setActiveKey] = React.useState([]); const [submitAttempted, setSubmitAttempted] = useState(false); + // Single source of truth for the K8s cluster type, seeded from the cluster + // being edited. Shared via FormContext so the type selector and the + // GPU-only fields stay in sync deterministically (no cross-component watch). + const [clusterType, setClusterType] = useState<'model' | 'gpu'>(() => + currentData?.k8s_options?.gpuInstanceOptions ? 'gpu' : 'model' + ); const advanceConfigRef = React.useRef(null); const systemConfig = useAtomValue(systemConfigAtom); @@ -87,6 +93,13 @@ const ClusterForm: React.FC = forwardRef( const next: any = { ...opts }; + // "model" clusters must not carry GPU-instance config. The field's UI is + // 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; + } + const creds = opts.imageCredentials; if (Array.isArray(creds)) { next.imageCredentials = creds.map((c: any) => ({ @@ -220,7 +233,9 @@ const ClusterForm: React.FC = forwardRef( }; return ( - +
{ ); }; -// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth -// for whether GPU instances are enabled. Both the cluster-type selector -// (rendered up top) and the static-address field (rendered in the advanced -// section) watch this same path so they stay in sync without sharing local -// state. +// `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 @@ -170,21 +173,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; -// state is read via useWatch with `preserve: true` so it tracks updates made -// through setFieldValue. +// 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. export const ClusterTypeSelector: React.FC = () => { const intl = useIntl(); const form = Form.useFormInstance(); const { presetClusterType } = useStepsContext(); const labelId = useId(); - const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { - form, - preserve: true - }); - const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model'; + // 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 + // did not re-render reliably when cleared to undefined. + const { clusterType, setClusterType } = useFormContext(); + const value: 'model' | 'gpu' = clusterType ?? 'model'; - const handleSelect = (next: 'model' | 'gpu') => { + const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => { if (!form || next === value) return; if (next === 'gpu') { form.setFieldValue( @@ -194,7 +198,8 @@ export const ClusterTypeSelector: React.FC = () => { } else { form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined); } - }; + setClusterType?.(next); + }); const options: { key: 'model' | 'gpu'; @@ -261,14 +266,13 @@ export const ClusterTypeSelector: React.FC = () => { // default container registry and the worker config (节点配置). export const GpuInstancesStaticAddressForm: React.FC = () => { const intl = useIntl(); - // See note in ClusterTypeSelector: watch the full store so this field's - // visibility tracks the selector even before it has mounted its own - // Form.Item. - const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { - preserve: true - }); + // Visibility tracks the shared cluster-type state (see FormContext), so this + // field mounts/unmounts deterministically with the selector. Its Form.Item is + // the only thing keeping gpuInstanceOptions alive, so unmounting it here (with + // the form's preserve={false}) also clears that path from the store. + const { clusterType } = useFormContext(); - if (!enabled) { + if (clusterType !== 'gpu') { return null; } diff --git a/src/pages/cluster-management/config/form-context.ts b/src/pages/cluster-management/config/form-context.ts index 1f799778..d686f49c 100644 --- a/src/pages/cluster-management/config/form-context.ts +++ b/src/pages/cluster-management/config/form-context.ts @@ -5,6 +5,12 @@ import { ClusterListItem } from './types'; interface FormContextProps { currentData?: ClusterListItem; submitAttempted?: boolean; + // K8s cluster type. `gpuInstanceOptions` on the form is derived from this — + // the selector, the static-address field, and submit all read this single + // source of truth instead of independently watching the (unregistered) form + // path, which did not re-render reliably. + clusterType?: 'model' | 'gpu'; + setClusterType?: (type: 'model' | 'gpu') => void; } export const FormContext = createContext({});