feat(models): scope deployment and model-file create by organization
Add the organization picker to the model deployment and model-file download forms (platform-admin "all organizations" view). Filter the cluster / worker pickers to clusters the chosen org owns, so the created resource's owner stays aligned with where it runs and a cross-org cluster can't be selected. Carry owner ids on the cluster / worker options to drive the filter.
This commit is contained in:
@@ -78,7 +78,12 @@ type AddModalProps = {
|
|||||||
deploymentType?: 'modelList' | 'modelFiles';
|
deploymentType?: 'modelList' | 'modelFiles';
|
||||||
clusterList: Global.BaseOption<
|
clusterList: Global.BaseOption<
|
||||||
number,
|
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;
|
onOk: (values: FormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -458,15 +463,24 @@ const AddModal: FC<AddModalProps> = (props) => {
|
|||||||
if (initialValues?.cluster_id) {
|
if (initialValues?.cluster_id) {
|
||||||
return 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
|
// Find default cluster
|
||||||
const defaultCluster = clusterList?.find((item) => item.is_default);
|
const defaultCluster = scopedList?.find((item) => item.is_default);
|
||||||
if (defaultCluster) {
|
if (defaultCluster) {
|
||||||
return defaultCluster.value;
|
return defaultCluster.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cluster_id =
|
const cluster_id =
|
||||||
clusterList?.find((item) => item.state === ClusterStatusValueMap.Ready)
|
scopedList?.find((item) => item.state === ClusterStatusValueMap.Ready)
|
||||||
?.value || clusterList?.[0]?.value;
|
?.value || scopedList?.[0]?.value;
|
||||||
|
|
||||||
return cluster_id;
|
return cluster_id;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { ModelFileFormData as FormData } from '@/pages/resources/config/types';
|
import { ModelFileFormData as FormData } from '@/pages/resources/config/types';
|
||||||
import {
|
import {
|
||||||
Input as CInput,
|
Input as CInput,
|
||||||
@@ -14,7 +15,8 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo
|
useMemo,
|
||||||
|
useRef
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { localPathTipsList, modelSourceMap, sourceOptions } from '../../config';
|
import { localPathTipsList, modelSourceMap, sourceOptions } from '../../config';
|
||||||
import { useGenerateWorkersModelFileOptions } from '../../hooks';
|
import { useGenerateWorkersModelFileOptions } from '../../hooks';
|
||||||
@@ -55,6 +57,20 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const localPath = Form.useWatch('local_path', form);
|
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<number | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const visibleWorkerOptions = useMemo(() => {
|
||||||
|
if (scopeOrgId == null) {
|
||||||
|
return workerOptions;
|
||||||
|
}
|
||||||
|
return (workerOptions || []).filter(
|
||||||
|
(cluster: any) => cluster.owner_principal_id === scopeOrgId
|
||||||
|
);
|
||||||
|
}, [workerOptions, scopeOrgId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -68,6 +84,19 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
|
|||||||
init();
|
init();
|
||||||
}, [workersList]);
|
}, [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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
form
|
form
|
||||||
}));
|
}));
|
||||||
@@ -183,6 +212,10 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
|
|||||||
></SealSelect>
|
></SealSelect>
|
||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields
|
||||||
|
name="CreateOrgScopeField"
|
||||||
|
context={{ action: 'create' }}
|
||||||
|
/>
|
||||||
{renderFieldsBySource}
|
{renderFieldsBySource}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="worker_id"
|
name="worker_id"
|
||||||
@@ -212,7 +245,7 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
|
|||||||
}}
|
}}
|
||||||
maxTagCount={1}
|
maxTagCount={1}
|
||||||
label={intl.formatMessage({ id: 'resources.worker' })}
|
label={intl.formatMessage({ id: 'resources.worker' })}
|
||||||
options={workerOptions}
|
options={visibleWorkerOptions}
|
||||||
showCheckedStrategy="SHOW_CHILD"
|
showCheckedStrategy="SHOW_CHILD"
|
||||||
optionNode={renderOptionNode}
|
optionNode={renderOptionNode}
|
||||||
getPopupContainer={(triggerNode) => triggerNode.parentNode}
|
getPopupContainer={(triggerNode) => triggerNode.parentNode}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { modelNameReg, PageAction } from '@/config';
|
import { modelNameReg, PageAction } from '@/config';
|
||||||
import { OPENAI_COMPATIBLE } from '@/config/settings';
|
import { OPENAI_COMPATIBLE } from '@/config/settings';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { DeployFormKeyMap, sourceOptions } from '../config';
|
import { DeployFormKeyMap, sourceOptions } from '../config';
|
||||||
import { useFormContext } from '../config/form-context';
|
import { useFormContext } from '../config/form-context';
|
||||||
@@ -76,6 +77,7 @@ interface BasicFormProps {
|
|||||||
provider: string;
|
provider: string;
|
||||||
state: string;
|
state: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
owner_principal_id?: number;
|
||||||
workers: number;
|
workers: number;
|
||||||
ready_workers: number;
|
ready_workers: number;
|
||||||
gpus: number;
|
gpus: number;
|
||||||
@@ -109,20 +111,65 @@ const BasicForm: React.FC<BasicFormProps> = (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<number | null | undefined>(undefined);
|
||||||
|
|
||||||
const clusterOptions = useMemo(() => {
|
const clusterOptions = useMemo(() => {
|
||||||
return clusterList?.map((item) => {
|
return clusterList
|
||||||
return {
|
?.filter((item) =>
|
||||||
label:
|
scopeOrgId == null ? true : item.owner_principal_id === scopeOrgId
|
||||||
item.state === ClusterStatusValueMap.Ready
|
)
|
||||||
? item.label
|
.map((item) => {
|
||||||
: `${item.label} [${ClusterStatusLabelMap[item.state as string]}]`,
|
return {
|
||||||
value: item.value,
|
label:
|
||||||
workers: item.workers,
|
item.state === ClusterStatusValueMap.Ready
|
||||||
ready_workers: item.ready_workers,
|
? item.label
|
||||||
gpus: item.gpus
|
: `${item.label} [${ClusterStatusLabelMap[item.state as string]}]`,
|
||||||
};
|
value: item.value,
|
||||||
});
|
state: item.state,
|
||||||
}, [clusterList]);
|
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 clusterOptionRender = (option: any) => {
|
||||||
const { data } = option;
|
const { data } = option;
|
||||||
@@ -181,6 +228,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
|||||||
required
|
required
|
||||||
></CInput.Input>
|
></CInput.Input>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="source"
|
name="source"
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ export const useGenerateWorkerOptions = () => {
|
|||||||
CascaderOption<{ state: string }>[]
|
CascaderOption<{ state: string }>[]
|
||||||
>([]);
|
>([]);
|
||||||
const [clusterList, setClusterList] = useState<
|
const [clusterList, setClusterList] = useState<
|
||||||
Global.BaseOption<number, { provider: string; state: string | number }>[]
|
Global.BaseOption<
|
||||||
|
number,
|
||||||
|
{ provider: string; state: string | number; owner_principal_id?: number }
|
||||||
|
>[]
|
||||||
>([]);
|
>([]);
|
||||||
const [workersList, setWorkersList] = useState<
|
const [workersList, setWorkersList] = useState<
|
||||||
Global.BaseOption<
|
Global.BaseOption<
|
||||||
@@ -165,6 +168,11 @@ export const useGenerateWorkerOptions = () => {
|
|||||||
label: cluster.name,
|
label: cluster.name,
|
||||||
value: cluster.id,
|
value: cluster.id,
|
||||||
parent: true,
|
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
|
children: workerList
|
||||||
.filter(
|
.filter(
|
||||||
(worker) =>
|
(worker) =>
|
||||||
@@ -219,6 +227,7 @@ export const useGenerateWorkerOptions = () => {
|
|||||||
provider: item.provider as string,
|
provider: item.provider as string,
|
||||||
state: item.state,
|
state: item.state,
|
||||||
is_default: item.is_default,
|
is_default: item.is_default,
|
||||||
|
owner_principal_id: item.owner_principal_id,
|
||||||
workers: item.workers,
|
workers: item.workers,
|
||||||
ready_workers: item.ready_workers,
|
ready_workers: item.ready_workers,
|
||||||
gpus: item.gpus
|
gpus: item.gpus
|
||||||
@@ -245,7 +254,12 @@ export default function useFormInitialValues() {
|
|||||||
const [clusterList, setClusterList] = useState<
|
const [clusterList, setClusterList] = useState<
|
||||||
Global.BaseOption<
|
Global.BaseOption<
|
||||||
number,
|
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,
|
provider: item.provider as string,
|
||||||
state: item.state,
|
state: item.state,
|
||||||
is_default: item.is_default,
|
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,
|
workers: item.workers,
|
||||||
ready_workers: item.ready_workers,
|
ready_workers: item.ready_workers,
|
||||||
gpus: item.gpus
|
gpus: item.gpus
|
||||||
|
|||||||
Reference in New Issue
Block a user