diff --git a/src/pages/llmodels/components/deployment/deploy-modal.tsx b/src/pages/llmodels/components/deployment/deploy-modal.tsx index 0a2f9255..b9922ba1 100644 --- a/src/pages/llmodels/components/deployment/deploy-modal.tsx +++ b/src/pages/llmodels/components/deployment/deploy-modal.tsx @@ -78,7 +78,12 @@ type AddModalProps = { deploymentType?: 'modelList' | 'modelFiles'; clusterList: Global.BaseOption< number, - { provider: string; state: string | number; is_default: boolean } + { + provider: string; + state: string | number; + is_default: boolean; + owner_principal_id?: number; + } >[]; onOk: (values: FormData) => void; onCancel: () => void; @@ -458,15 +463,24 @@ const AddModal: FC = (props) => { if (initialValues?.cluster_id) { return initialValues.cluster_id; } + // When a platform admin has targeted an org via the create-scope picker, + // seed the cluster from that org's own clusters so the initial selection + // matches the (org-filtered) dropdown the form renders. + const scopeOrgId = form.current?.getFieldValue?.('organization_id'); + const scopedList = + scopeOrgId == null + ? clusterList + : clusterList?.filter((item) => item.owner_principal_id === scopeOrgId); + // Find default cluster - const defaultCluster = clusterList?.find((item) => item.is_default); + const defaultCluster = scopedList?.find((item) => item.is_default); if (defaultCluster) { return defaultCluster.value; } const cluster_id = - clusterList?.find((item) => item.state === ClusterStatusValueMap.Ready) - ?.value || clusterList?.[0]?.value; + scopedList?.find((item) => item.state === ClusterStatusValueMap.Ready) + ?.value || scopedList?.[0]?.value; return cluster_id; }; diff --git a/src/pages/llmodels/components/download/target-form.tsx b/src/pages/llmodels/components/download/target-form.tsx index dc392282..bc52319e 100644 --- a/src/pages/llmodels/components/download/target-form.tsx +++ b/src/pages/llmodels/components/download/target-form.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { ModelFileFormData as FormData } from '@/pages/resources/config/types'; import { Input as CInput, @@ -14,7 +15,8 @@ import React, { forwardRef, useEffect, useImperativeHandle, - useMemo + useMemo, + useRef } from 'react'; import { localPathTipsList, modelSourceMap, sourceOptions } from '../../config'; import { useGenerateWorkersModelFileOptions } from '../../hooks'; @@ -55,6 +57,20 @@ const TargetForm: React.FC = forwardRef((props, ref) => { const intl = useIntl(); const [form] = Form.useForm(); const localPath = Form.useWatch('local_path', form); + // Owned by the create-scope picker slot (admin "All" view). When set, scope + // the worker picker to clusters owned by that org — a model file's owner is + // derived from the target worker's cluster, so this keeps them aligned. + const scopeOrgId = Form.useWatch('organization_id', form); + const prevScopeRef = useRef(undefined); + + const visibleWorkerOptions = useMemo(() => { + if (scopeOrgId == null) { + return workerOptions; + } + return (workerOptions || []).filter( + (cluster: any) => cluster.owner_principal_id === scopeOrgId + ); + }, [workerOptions, scopeOrgId]); useEffect(() => { const init = async () => { @@ -68,6 +84,19 @@ const TargetForm: React.FC = forwardRef((props, ref) => { init(); }, [workersList]); + // On a genuine org change, drop the now-out-of-scope worker selection. + useEffect(() => { + if (prevScopeRef.current === undefined) { + prevScopeRef.current = scopeOrgId; + return; + } + if (prevScopeRef.current === scopeOrgId) { + return; + } + prevScopeRef.current = scopeOrgId; + form.setFieldValue('worker_id', undefined); + }, [scopeOrgId]); + useImperativeHandle(ref, () => ({ form })); @@ -183,6 +212,10 @@ const TargetForm: React.FC = forwardRef((props, ref) => { > } + {renderFieldsBySource} = forwardRef((props, ref) => { }} maxTagCount={1} label={intl.formatMessage({ id: 'resources.worker' })} - options={workerOptions} + options={visibleWorkerOptions} showCheckedStrategy="SHOW_CHILD" optionNode={renderOptionNode} getPopupContainer={(triggerNode) => triggerNode.parentNode} diff --git a/src/pages/llmodels/forms/basic.tsx b/src/pages/llmodels/forms/basic.tsx index bddb56a4..b76b1e7d 100644 --- a/src/pages/llmodels/forms/basic.tsx +++ b/src/pages/llmodels/forms/basic.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { modelNameReg, PageAction } from '@/config'; import { OPENAI_COMPATIBLE } from '@/config/settings'; import { @@ -12,7 +13,7 @@ import { } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Form } from 'antd'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { DeployFormKeyMap, sourceOptions } from '../config'; import { useFormContext } from '../config/form-context'; @@ -76,6 +77,7 @@ interface BasicFormProps { provider: string; state: string; is_default: boolean; + owner_principal_id?: number; workers: number; ready_workers: number; gpus: number; @@ -109,20 +111,65 @@ const BasicForm: React.FC = (props) => { } }; + // `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 prevScopeRef = useRef(undefined); + const clusterOptions = useMemo(() => { - return clusterList?.map((item) => { - return { - label: - item.state === ClusterStatusValueMap.Ready - ? item.label - : `${item.label} [${ClusterStatusLabelMap[item.state as string]}]`, - value: item.value, - workers: item.workers, - ready_workers: item.ready_workers, - gpus: item.gpus - }; - }); - }, [clusterList]); + 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]); + + // On a genuine org change (not the initial value — the modal's open + // handler seeds the first cluster), drop a now-out-of-scope cluster and + // re-pick within the new org so GPU/backend options refetch for it. + useEffect(() => { + if (prevScopeRef.current === undefined) { + prevScopeRef.current = scopeOrgId; + return; + } + if (prevScopeRef.current === scopeOrgId) { + return; + } + prevScopeRef.current = scopeOrgId; + + const current = form.getFieldValue('cluster_id'); + const stillValid = clusterOptions?.some((c) => c.value === current); + if (stillValid) { + return; + } + const next = + clusterOptions?.find((c) => c.is_default)?.value ?? + clusterOptions?.find((c) => c.state === ClusterStatusValueMap.Ready) + ?.value ?? + clusterOptions?.[0]?.value ?? + null; + form.setFieldValue('cluster_id', next ?? null); + if (next != null) { + handleClusterChange?.(next as number); + } + // The prevScopeRef guard above makes this a no-op unless scopeOrgId + // actually changed, so listing the other deps is safe (no extra runs). + }, [scopeOrgId, clusterOptions, form, handleClusterChange]); const clusterOptionRender = (option: any) => { const { data } = option; @@ -181,6 +228,7 @@ const BasicForm: React.FC = (props) => { required > + name="source" diff --git a/src/pages/llmodels/hooks/use-form-initial-values.ts b/src/pages/llmodels/hooks/use-form-initial-values.ts index d3ed75af..d5b97b02 100644 --- a/src/pages/llmodels/hooks/use-form-initial-values.ts +++ b/src/pages/llmodels/hooks/use-form-initial-values.ts @@ -145,7 +145,10 @@ export const useGenerateWorkerOptions = () => { CascaderOption<{ state: string }>[] >([]); const [clusterList, setClusterList] = useState< - Global.BaseOption[] + Global.BaseOption< + number, + { provider: string; state: string | number; owner_principal_id?: number } + >[] >([]); const [workersList, setWorkersList] = useState< Global.BaseOption< @@ -165,6 +168,11 @@ export const useGenerateWorkerOptions = () => { label: cluster.name, value: cluster.id, parent: true, + // Carried so the download form can scope the worker picker to a + // chosen org (admin "All" view). A model file's owner is derived + // from the target worker's cluster, so filtering by the cluster's + // owner keeps them aligned. + owner_principal_id: cluster.owner_principal_id, children: workerList .filter( (worker) => @@ -219,6 +227,7 @@ export const useGenerateWorkerOptions = () => { provider: item.provider as string, state: item.state, is_default: item.is_default, + owner_principal_id: item.owner_principal_id, workers: item.workers, ready_workers: item.ready_workers, gpus: item.gpus @@ -245,7 +254,12 @@ export default function useFormInitialValues() { const [clusterList, setClusterList] = useState< Global.BaseOption< number, - { provider: string; state: string; is_default: boolean } + { + provider: string; + state: string; + is_default: boolean; + owner_principal_id?: number; + } >[] >([]); @@ -262,6 +276,11 @@ export default function useFormInitialValues() { provider: item.provider as string, state: item.state, is_default: item.is_default, + // Carried so the deploy form can scope the cluster dropdown to a + // chosen org (admin "All" view). The created deployment's owner is + // derived from the picked cluster, so filtering to owned clusters + // keeps owner and cluster aligned. + owner_principal_id: item.owner_principal_id, workers: item.workers, ready_workers: item.ready_workers, gpus: item.gpus