import PluginExtraFields from '@/components/plugin-extra-fields'; import { modelNameReg, PageAction } from '@/config'; import { OPENAI_COMPATIBLE } from '@/config/settings'; import { ClusterStatusLabelMap, ClusterStatusValueMap } from '@/pages/cluster-management/config'; import { AutoTooltip, Input as CInput, Select as SealSelect, useAppUtils } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Form } from 'antd'; import { useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { DeployFormKeyMap, sourceOptions } from '../config'; import { useFormContext } from '../config/form-context'; import { FormData } from '../config/types'; import BackendForm from './backend'; import CatalogFrom from './catalog'; import CustomBackend from './custom-backend'; import LocalPathSource from './local-path-source'; import ModeField from './mode-field'; import OnlineSource from './online-source'; const ClusterOption = styled.span` display: flex; padding: 8px 0; width: 100%; flex-direction: column; border-bottom: 1px solid var(--ant-color-split); gap: 4px; .label { display: flex; align-items: center; gap: 8px; flex: 1; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } .dot { display: flex; width: 6px; height: 6px; background-color: var(--ant-color-warning); border-radius: 50%; &.ready { background-color: var(--ant-color-success); } } .item { width: max-content; display: flex; align-items: center; padding: 2px 0px; border-radius: 4px; font-weight: 400; gap: 8px; } .s-dot { width: 4px; height: 4px; margin: 0 4px; background-color: var(--ant-color-text-quaternary); border-radius: 50%; } `; interface BasicFormProps { sourceDisable?: boolean; sourceList?: Global.BaseOption[]; clusterList: Global.BaseOption< number, { provider: string; state: string; is_default: boolean; owner_principal_id?: number; workers: number; ready_workers: number; gpus: number; } >[]; handleClusterChange: (value: number) => void; onClusterSeed: (value: number) => void; onSourceChange?: (value: string) => void; } const BasicForm: React.FC = (props) => { const { sourceList, clusterList, sourceDisable, handleClusterChange, onClusterSeed, onSourceChange } = props; const intl = useIntl(); const form = Form.useFormInstance(); const { getRuleMessage } = useAppUtils(); const { onValuesChange, action, formKey } = useFormContext(); const handleOnSourceChange = (val: string) => { onSourceChange?.(val); }; const handleNameBlur = (e: React.FocusEvent) => { if (action === PageAction.EDIT) { const value = e.target.value; onValuesChange?.({ name: value }, form.getFieldsValue()); } }; // `organization_id` is owned by the create-scope picker slot; it only // appears when a platform admin is in the "All" view. When set, scope the // cluster dropdown to that org's own clusters — the backend derives the // deployment's owner from the chosen cluster, so this keeps them aligned. const scopeOrgId = Form.useWatch('organization_id', form); const clusterOptions = useMemo(() => { return clusterList ?.filter((item) => scopeOrgId == null ? true : item.owner_principal_id === scopeOrgId ) .map((item) => { return { label: item.state === ClusterStatusValueMap.Ready ? item.label : `${item.label} [${ClusterStatusLabelMap[item.state as string]}]`, value: item.value, state: item.state, is_default: item.is_default, workers: item.workers, ready_workers: item.ready_workers, gpus: item.gpus }; }); }, [clusterList, scopeOrgId]); // Keep the cluster selection consistent with the available (scoped) // options. The modal's open handler seeds a cluster, but that seed can be // empty (options not loaded yet) or point outside the current scope (the // scope id and the option list both settle after mount, and an org switch // re-scopes the list) — leaving the field blank even though a valid option // exists. Whenever the scope or the options change, drop an invalid/empty // selection and fall back to the scope's default cluster (then a Ready one, // then the first) so GPU/backend options refetch for it. A selection that's // still valid is left untouched, so a user's (or edit's) choice is kept. // Use the seed callback (not handleClusterChange) so this auto-pick refreshes // options without firing an evaluate request before a model is selected. useEffect(() => { if (!clusterOptions?.length) { return; } const current = form.getFieldValue('cluster_id'); const stillValid = clusterOptions.some((c) => c.value === current); if (current != null && stillValid) { return; } const next = clusterOptions.find((c) => c.is_default)?.value ?? clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready) ?.value ?? clusterOptions[0]?.value ?? null; if (next == null || next === current) { return; } form.setFieldValue('cluster_id', next); onClusterSeed?.(next); }, [clusterOptions, form, onClusterSeed]); const clusterOptionRender = (option: any) => { const { data } = option; return ( {data.label} 0 ? 'ready' : ''}`}> 0 ? 'ready' : ''}`} > {intl.formatMessage({ id: 'resources.nodes' })}: {data.ready_workers}/{data.workers} GPUs: {data.gpus} ); }; return ( <> data-field="name" name="name" rules={[ { required: true, message: getRuleMessage('input', 'common.table.name') }, { pattern: modelNameReg, message: intl.formatMessage({ id: 'models.form.rules.name' }) } ]} > name="source" hidden={formKey === DeployFormKeyMap.CATALOG} rules={[ { required: true, message: getRuleMessage('select', 'models.form.source') } ]} > { } name="cluster_id" rules={[ { required: true, message: getRuleMessage('select', 'clusters.title') } ]} > { } name="replicas" rules={[ { required: true, message: getRuleMessage('input', 'models.form.replicas') } ]} > name="description"> ); }; export default BasicForm;