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.
This commit is contained in:
@@ -59,6 +59,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [activeKey, setActiveKey] = React.useState<string[]>([]);
|
const [activeKey, setActiveKey] = React.useState<string[]>([]);
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
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<any>(null);
|
const advanceConfigRef = React.useRef<any>(null);
|
||||||
const systemConfig = useAtomValue(systemConfigAtom);
|
const systemConfig = useAtomValue(systemConfigAtom);
|
||||||
|
|
||||||
@@ -87,6 +93,13 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
|||||||
|
|
||||||
const next: any = { ...opts };
|
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;
|
const creds = opts.imageCredentials;
|
||||||
if (Array.isArray(creds)) {
|
if (Array.isArray(creds)) {
|
||||||
next.imageCredentials = creds.map((c: any) => ({
|
next.imageCredentials = creds.map((c: any) => ({
|
||||||
@@ -220,7 +233,9 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContext.Provider value={{ submitAttempted }}>
|
<FormContext.Provider
|
||||||
|
value={{ submitAttempted, clusterType, setClusterType }}
|
||||||
|
>
|
||||||
<Form
|
<Form
|
||||||
name="clusterForm"
|
name="clusterForm"
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { PageAction } from '@/config';
|
|||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
|
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { useMemoizedFn } from 'ahooks';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import React, { useEffect, useId, useMemo } from 'react';
|
import React, { useEffect, useId, useMemo } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { useFormContext } from '../config/form-context';
|
||||||
import { useStepsContext } from '../config/steps-context';
|
import { useStepsContext } from '../config/steps-context';
|
||||||
import { ClusterListItem as ListItem } from '../config/types';
|
import { ClusterListItem as ListItem } from '../config/types';
|
||||||
import ImageCredential from './image-credential';
|
import ImageCredential from './image-credential';
|
||||||
@@ -68,11 +70,12 @@ export const OperatorImageForm: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
|
// `gpuInstanceOptions` on `k8s_options` is the submitted representation of a
|
||||||
// for whether GPU instances are enabled. Both the cluster-type selector
|
// "gpu" cluster. Its presence is driven by the shared `clusterType` state (see
|
||||||
// (rendered up top) and the static-address field (rendered in the advanced
|
// FormContext) — the selector writes it, the static-address field mounts under
|
||||||
// section) watch this same path so they stay in sync without sharing local
|
// it, and submit strips it for "model". Kept out of any Form.useWatch because
|
||||||
// state.
|
// this path has no always-mounted Form.Item and watching it re-rendered
|
||||||
|
// unreliably.
|
||||||
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
|
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
|
||||||
@@ -170,21 +173,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 form — "model" clears it, "gpu" seeds it to {} (preserving any
|
||||||
// already-entered static address). No standalone form field is registered;
|
// already-entered static address). No standalone form field is registered; the
|
||||||
// state is read via useWatch with `preserve: true` so it tracks updates made
|
// selected card is tracked in local state (seeded from the form's initial
|
||||||
// through setFieldValue.
|
// value) and written back to the form on each click.
|
||||||
export const ClusterTypeSelector: React.FC = () => {
|
export const ClusterTypeSelector: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
const { presetClusterType } = useStepsContext();
|
const { presetClusterType } = useStepsContext();
|
||||||
const labelId = useId();
|
const labelId = useId();
|
||||||
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
// Cluster type is shared, explicit state (see FormContext): the click is the
|
||||||
form,
|
// source of truth. We update that state and mirror the choice onto the form
|
||||||
preserve: true
|
// for submission. This replaced a Form.useWatch on an unregistered path that
|
||||||
});
|
// did not re-render reliably when cleared to undefined.
|
||||||
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
|
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 (!form || next === value) return;
|
||||||
if (next === 'gpu') {
|
if (next === 'gpu') {
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
@@ -194,7 +198,8 @@ export const ClusterTypeSelector: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
|
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
|
||||||
}
|
}
|
||||||
};
|
setClusterType?.(next);
|
||||||
|
});
|
||||||
|
|
||||||
const options: {
|
const options: {
|
||||||
key: 'model' | 'gpu';
|
key: 'model' | 'gpu';
|
||||||
@@ -261,14 +266,13 @@ export const ClusterTypeSelector: React.FC = () => {
|
|||||||
// default container registry and the worker config (节点配置).
|
// default container registry and the worker config (节点配置).
|
||||||
export const GpuInstancesStaticAddressForm: React.FC = () => {
|
export const GpuInstancesStaticAddressForm: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
// See note in ClusterTypeSelector: watch the full store so this field's
|
// Visibility tracks the shared cluster-type state (see FormContext), so this
|
||||||
// visibility tracks the selector even before it has mounted its own
|
// field mounts/unmounts deterministically with the selector. Its Form.Item is
|
||||||
// Form.Item.
|
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
|
||||||
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
// the form's preserve={false}) also clears that path from the store.
|
||||||
preserve: true
|
const { clusterType } = useFormContext();
|
||||||
});
|
|
||||||
|
|
||||||
if (!enabled) {
|
if (clusterType !== 'gpu') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { ClusterListItem } from './types';
|
|||||||
interface FormContextProps {
|
interface FormContextProps {
|
||||||
currentData?: ClusterListItem;
|
currentData?: ClusterListItem;
|
||||||
submitAttempted?: boolean;
|
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<FormContextProps>({});
|
export const FormContext = createContext<FormContextProps>({});
|
||||||
|
|||||||
Reference in New Issue
Block a user