feat(benchmark): scope the benchmark create form by organization
Add the organization picker to the benchmark create form (platform-admin "all organizations" view). Fetch the chosen org's clusters and keep only those it owns; refetch the org-scoped model / instance list on org change; clear a stale cluster or target when switching to an org with no clusters.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { modelNameReg, PageAction } from '@/config';
|
import { modelNameReg, PageAction } from '@/config';
|
||||||
import { ClusterStatusValueMap } from '@/pages/cluster-management/config';
|
import { ClusterStatusValueMap } from '@/pages/cluster-management/config';
|
||||||
|
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||||
import { useBenchmarkTargetInstance } from '@/pages/llmodels/hooks/use-run-benchmark';
|
import { useBenchmarkTargetInstance } from '@/pages/llmodels/hooks/use-run-benchmark';
|
||||||
import {
|
import {
|
||||||
Input as CInput,
|
Input as CInput,
|
||||||
@@ -8,7 +10,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 React, { useEffect } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import { useFormContext } from '../config/form-context';
|
import { useFormContext } from '../config/form-context';
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
import ModelInstanceForm from './model-instance';
|
import ModelInstanceForm from './model-instance';
|
||||||
@@ -20,31 +22,70 @@ const BasicForm: React.FC = () => {
|
|||||||
const { action, open, clusterList } = useFormContext();
|
const { action, open, clusterList } = useFormContext();
|
||||||
const { benchmarkTargetInstance } = useBenchmarkTargetInstance();
|
const { benchmarkTargetInstance } = useBenchmarkTargetInstance();
|
||||||
|
|
||||||
|
// `organization_id` is owned by the create-scope picker slot (admin "All"
|
||||||
|
// view). When a platform admin targets an org (and we're not pre-filling
|
||||||
|
// from a launched instance), fetch *that org's* clusters directly — the
|
||||||
|
// request header carries the chosen org — rather than filtering the
|
||||||
|
// page-level list, which is fetched once and may not include the org's
|
||||||
|
// clusters. The benchmark's owner is derived from the chosen cluster.
|
||||||
|
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||||
|
const orgScoped = scopeOrgId != null && !benchmarkTargetInstance.cluster_id;
|
||||||
|
const {
|
||||||
|
clusterList: scopedClusterList,
|
||||||
|
fetchClusterList: fetchScopedClusters
|
||||||
|
} = useQueryClusterList();
|
||||||
|
// The org-scoped fetch returns clusters *visible* to the org — its own plus
|
||||||
|
// any granted via cluster_access (and the platform principal can see a lot).
|
||||||
|
// A benchmark's owner is the chosen cluster's owner, so keep only clusters
|
||||||
|
// actually owned by the selected org. The owner filter also keeps this
|
||||||
|
// correct if the request header isn't applied (fetch falls back to all).
|
||||||
|
const effectiveClusterList = useMemo(() => {
|
||||||
|
if (!orgScoped) {
|
||||||
|
return clusterList || [];
|
||||||
|
}
|
||||||
|
return (scopedClusterList || []).filter(
|
||||||
|
(item: any) => item.owner_principal_id === scopeOrgId
|
||||||
|
);
|
||||||
|
}, [orgScoped, scopedClusterList, clusterList, scopeOrgId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (action === PageAction.CREATE && orgScoped) {
|
||||||
|
fetchScopedClusters({ page: -1 });
|
||||||
|
}
|
||||||
|
}, [scopeOrgId, orgScoped, action]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== PageAction.CREATE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clusterValue = (item: any) => item?.value ?? item?.id;
|
||||||
const initClusterId = (list: any[]) => {
|
const initClusterId = (list: any[]) => {
|
||||||
// Find default cluster
|
// Find default cluster
|
||||||
const defaultCluster = list?.find((item) => item.is_default);
|
const defaultCluster = list?.find((item) => item.is_default);
|
||||||
if (defaultCluster) {
|
if (defaultCluster) {
|
||||||
return defaultCluster.id;
|
return clusterValue(defaultCluster);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cluster_id =
|
const readyCluster = list?.find(
|
||||||
list?.find((item) => item.state === ClusterStatusValueMap.Ready)?.id ||
|
(item) => item.state === ClusterStatusValueMap.Ready
|
||||||
list?.[0]?.id;
|
|
||||||
|
|
||||||
return cluster_id;
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
clusterList &&
|
|
||||||
clusterList?.length > 0 &&
|
|
||||||
action === PageAction.CREATE
|
|
||||||
) {
|
|
||||||
form.setFieldValue(
|
|
||||||
'cluster_id',
|
|
||||||
benchmarkTargetInstance.cluster_id || initClusterId(clusterList)
|
|
||||||
);
|
);
|
||||||
|
return clusterValue(readyCluster) ?? clusterValue(list?.[0]);
|
||||||
|
};
|
||||||
|
const current = form.getFieldValue('cluster_id');
|
||||||
|
const stillValid = effectiveClusterList.some(
|
||||||
|
(item: any) => clusterValue(item) === current
|
||||||
|
);
|
||||||
|
if (stillValid) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [form, action, clusterList, benchmarkTargetInstance]);
|
// Re-pick within the (org-scoped) list. When the chosen org owns no
|
||||||
|
// clusters this resolves to undefined, clearing a stale cross-org cluster
|
||||||
|
// instead of leaving it selected.
|
||||||
|
form.setFieldValue(
|
||||||
|
'cluster_id',
|
||||||
|
benchmarkTargetInstance.cluster_id || initClusterId(effectiveClusterList)
|
||||||
|
);
|
||||||
|
}, [form, action, effectiveClusterList, benchmarkTargetInstance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -67,6 +108,7 @@ const BasicForm: React.FC = () => {
|
|||||||
required
|
required
|
||||||
></CInput.Input>
|
></CInput.Input>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="cluster_id"
|
name="cluster_id"
|
||||||
rules={[
|
rules={[
|
||||||
@@ -78,7 +120,7 @@ const BasicForm: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<SealSelect
|
<SealSelect
|
||||||
disabled={action === PageAction.EDIT}
|
disabled={action === PageAction.EDIT}
|
||||||
options={clusterList}
|
options={effectiveClusterList}
|
||||||
label={intl.formatMessage({ id: 'clusters.title' })}
|
label={intl.formatMessage({ id: 'clusters.title' })}
|
||||||
required
|
required
|
||||||
></SealSelect>
|
></SealSelect>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-lis
|
|||||||
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
|
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form, Tooltip } from 'antd';
|
import { Form, Tooltip } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useFormContext } from '../config/form-context';
|
import { useFormContext } from '../config/form-context';
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
|
|
||||||
@@ -45,6 +45,11 @@ const ModelInstanceForm: React.FC = () => {
|
|||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
const { getRuleMessage } = useAppUtils();
|
const { getRuleMessage } = useAppUtils();
|
||||||
const { action, open } = useFormContext();
|
const { action, open } = useFormContext();
|
||||||
|
// Owned by the create-scope picker slot (admin "All" view). The model list
|
||||||
|
// is tenant-scoped by the request header, so refetch it when the org
|
||||||
|
// changes so only the chosen org's models/instances are offered.
|
||||||
|
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||||
|
const prevScopeRef = useRef<number | null | undefined>(undefined);
|
||||||
const [modelList, setModelList] = React.useState<any[]>([]);
|
const [modelList, setModelList] = React.useState<any[]>([]);
|
||||||
const {
|
const {
|
||||||
loading: modelLoading,
|
loading: modelLoading,
|
||||||
@@ -158,14 +163,29 @@ const ModelInstanceForm: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && action === PageAction.CREATE) {
|
if (open && action === PageAction.CREATE) {
|
||||||
|
// On a genuine org change, clear the stale (possibly cross-org) target
|
||||||
|
// so the refetched list re-selects within the new org.
|
||||||
|
if (
|
||||||
|
prevScopeRef.current !== undefined &&
|
||||||
|
prevScopeRef.current !== scopeOrgId
|
||||||
|
) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
model_name: undefined,
|
||||||
|
model_id: undefined,
|
||||||
|
model_instance_name: undefined,
|
||||||
|
model_instance: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevScopeRef.current = scopeOrgId;
|
||||||
initModelInstance();
|
initModelInstance();
|
||||||
}
|
}
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
prevScopeRef.current = undefined;
|
||||||
cancelModelRequest();
|
cancelModelRequest();
|
||||||
cancelInstanceRequest();
|
cancelInstanceRequest();
|
||||||
clearBenchmarkTargetInstance();
|
clearBenchmarkTargetInstance();
|
||||||
}
|
}
|
||||||
}, [open, benchmarkTargetInstance, action]);
|
}, [open, benchmarkTargetInstance, action, scopeOrgId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
Reference in New Issue
Block a user