feat(gpu-service): scope create forms by the selected organization

In the platform-admin "all organizations" view, add an organization picker
(the CreateOrgScopeField slot) to the SSH public key, storage type,
storage, GPU instance and instance-template create forms, placed below the
name / display-name fields. Drop the hidden owner field and let the owner
derive from the request context, matching the model-route form.

For GPU instances, scope the instance-type list to clusters the chosen org
owns (client-side, by cluster owner) so an instance can't be scheduled onto
another org's cluster; when the org owns none, show "no instance type
available" and clear the selection, cluster and CPU/memory fields. Instance
templates gain a Global level (NULL owner) and an owner tag on the card.
This commit is contained in:
gitlawr
2026-06-03 10:14:50 +08:00
committed by jialin
parent d2d82ee5ee
commit fbb707d014
17 changed files with 272 additions and 90 deletions
+1
View File
@@ -97,6 +97,7 @@ export default {
'gpuservice.instance.templates': 'Instance Templates',
'gpuservice.instance.section.storage': 'Storage',
'gpuservice.instance.type.required': 'Please select an instance type',
'gpuservice.instance.type.noAvailable': 'No instance type available',
'gpuservice.instance.gpuCount': 'GPU Count',
'gpuservice.instance.gpuCount.required': 'Please enter the GPU count',
'gpuservice.instance.gpuCount.max':
+2
View File
@@ -96,6 +96,8 @@ export default {
'gpuservice.instance.templates': 'インスタンステンプレート',
'gpuservice.instance.section.storage': 'ストレージボリューム',
'gpuservice.instance.type.required': 'インスタンスタイプを選択してください',
'gpuservice.instance.type.noAvailable':
'利用可能なインスタンスタイプがありません',
'gpuservice.instance.gpuCount': 'GPU 数',
'gpuservice.instance.gpuCount.required': 'GPU 数を入力してください',
'gpuservice.instance.gpuCount.max':
+1
View File
@@ -100,6 +100,7 @@ export default {
'gpuservice.instance.templates': 'Шаблоны экземпляров',
'gpuservice.instance.section.storage': 'Том хранилища',
'gpuservice.instance.type.required': 'Выберите тип экземпляра',
'gpuservice.instance.type.noAvailable': 'Нет доступных типов экземпляров',
'gpuservice.instance.gpuCount': 'Количество GPU',
'gpuservice.instance.gpuCount.required': 'Введите количество GPU',
'gpuservice.instance.gpuCount.max': 'Выберите максимум {count} GPU-карт',
+1
View File
@@ -97,6 +97,7 @@ export default {
'gpuservice.instance.templates': 'Örnek Şablonları',
'gpuservice.instance.section.storage': 'Depolama Hacmi',
'gpuservice.instance.type.required': 'Lütfen bir örnek türü seçin',
'gpuservice.instance.type.noAvailable': 'Kullanılabilir örnek türü yok',
'gpuservice.instance.gpuCount': 'GPU Sayısı',
'gpuservice.instance.gpuCount.required': 'Lütfen GPU sayısını girin',
'gpuservice.instance.gpuCount.max': 'En fazla {count} GPU kartı seçin',
+1
View File
@@ -92,6 +92,7 @@ export default {
'gpuservice.instance.templates': '实例模板',
'gpuservice.instance.section.storage': '存储卷',
'gpuservice.instance.type.required': '请选择实例类型',
'gpuservice.instance.type.noAvailable': '无可用的实例类型',
'gpuservice.instance.gpuCount': 'GPU 数量',
'gpuservice.instance.gpuCount.required': '请输入 GPU 数量',
'gpuservice.instance.gpuCount.max': '最多选择 {count} 张卡',
@@ -1,36 +0,0 @@
import { currentOrganizationIdAtom } from '@/atoms/user';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
interface OwnerPrincipalIdFieldProps {
name?: NamePath;
}
// Pins `owner_principal_id` to the Org the caller is currently acting
// under. Cluster ownership is irrelevant here: cluster_access grants
// let one Org schedule on another Org's cluster, but the resource the
// caller creates still belongs to *their* Org, and the backend enforces
// `owner_principal_id == ctx.current_principal_id`.
const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({
name = 'owner_principal_id'
}) => {
const currentOrgId = useAtomValue(currentOrganizationIdAtom);
const form = Form.useFormInstance();
useEffect(() => {
if (currentOrgId != null) {
form.setFieldValue(name, currentOrgId);
}
}, [currentOrgId, name, form]);
return (
<Form.Item name={name} hidden>
<CInput.Input />
</Form.Item>
);
};
export default OwnerPrincipalIdField;
@@ -1,5 +1,6 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import Separator from '@/pages/llmodels/components/separator';
import { SearchOutlined } from '@ant-design/icons';
import {
@@ -10,7 +11,7 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Empty, Input, Typography } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ListItem as TemplateItem } from '../../templates/config/types';
import useQueryTemplates from '../../templates/services/use-query-templates';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
@@ -97,8 +98,58 @@ const AddModal: React.FC<AddModalProps> = ({
} = useQueryInstanceTypes();
const { detailData: templatesData, fetchData: fetchTemplates } =
useQueryTemplates();
const { clusterList, fetchClusterList } = useQueryClusterList();
// Set by the create-scope picker (admin "All" view) via onScopeChange.
// undefined = no picker (org context) → no client-side scoping.
const [scopeOrgId, setScopeOrgId] = useState<number | null | undefined>(
undefined
);
const templateList = templatesData?.items || [];
// A GPU instance is scheduled on the chosen instance type's cluster, and
// its owner is that cluster's owner. So when a platform admin targets an
// org, restrict each instance type's candidates to clusters that org owns
// (dropping tiers/types left with none). Header-independent: filters the
// fetched list client-side, so it doesn't rely on the request scope.
const filterTypesByOwner = (
types: InstanceTypeItem[],
clusters: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null
): InstanceTypeItem[] => {
if (orgId == null) return types;
const owned = new Set(
(clusters || [])
.filter((c) => c.owner_principal_id === orgId)
.map((c) => c.id ?? c.value)
);
return types
.map((it) => ({
...it,
status: {
...it.status,
acceleratorTiers: (it.status?.acceleratorTiers ?? [])
.map((tier: any) => ({
...tier,
candidates: (tier.candidates ?? []).filter((c: any) =>
owned.has(Number(c.cluster))
)
}))
.filter((tier: any) => (tier.candidates ?? []).length > 0)
}
}))
.filter((it) => (it.status?.acceleratorTiers ?? []).length > 0);
};
const ownedInstanceTypes = useMemo(
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId),
[instanceTypeList, clusterList, scopeOrgId]
);
// const readonly = action === PageAction.VIEW;
const readonly = false;
const isRecreate = realAction === PageAction.CREATE;
@@ -177,7 +228,13 @@ const AddModal: React.FC<AddModalProps> = ({
// initial for first
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[]
templates: TemplateItem[],
clusters?: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null
) => {
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
@@ -195,9 +252,24 @@ const AddModal: React.FC<AddModalProps> = ({
return;
}
const first = instanceTypes.find((item) => !item.disabled);
// Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId);
const first = owned.find((item) => !item.disabled);
if (!first) return;
if (!first) {
// The chosen org has no clusters (hence no instance types). Clear any
// prior pick so a stale instance type / cross-org cluster isn't left
// on the form.
setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
form.current?.applyInstanceType?.(undefined);
form.current?.setFieldValue?.('clusterId', null);
form.current?.setFieldValue?.(['spec', 'type'], undefined);
return;
}
// On create, auto-select the first instance type in the list
@@ -209,6 +281,52 @@ const AddModal: React.FC<AddModalProps> = ({
applySelection(first, template);
};
// Fetch the (tenant-scoped) instance types + templates and auto-select.
// The query hook cancels any in-flight request on each new call, so when
// this runs twice in quick succession (drawer open, then the scope
// picker settling on its default) the latest scope's result wins.
const loadCreateResources = (orgId?: number | null) => {
const session = ++sessionRef.current;
Promise.all([
fetchData({ page: -1 }),
fetchTemplates({ page: -1 }),
fetchClusterList({ page: -1 })
]).then(([instanceResItems, templatesRes, clusters]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(
instanceResItems || [],
templatesRes?.items || [],
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [],
orgId
);
});
};
// Platform admin retargeted the create to another org (or Global). The
// instance-type / cluster offerings are tenant-scoped, so drop the
// current pick and reload for the new scope. The request interceptor
// already carries the new org header by the time this fires.
const handleScopeChange = (orgId?: number | null) => {
if (!open || action !== PageAction.CREATE) return;
setScopeOrgId(orgId);
setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
// Also clear the instance-type-derived form state (the selected type
// card + its limits, the cluster, and spec.type). The cluster decides
// where the instance is scheduled, so a stale pick from the previous
// scope must not survive — otherwise an instance owned by the newly
// chosen org could land on the old org's cluster. The reload's
// owner-scoped auto-selection re-fills them from the new org, or leaves
// them empty (blocking submit) when the chosen org has no clusters.
form.current?.applyInstanceType?.(undefined);
form.current?.setFieldValue?.('clusterId', null);
form.current?.setFieldValue?.(['spec', 'type'], undefined);
loadCreateResources(orgId);
};
useEffect(() => {
if (!open) {
sessionRef.current += 1;
@@ -219,25 +337,27 @@ const AddModal: React.FC<AddModalProps> = ({
setTemplateId(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
setScopeOrgId(undefined);
return;
}
if (action === PageAction.CREATE) {
const session = ++sessionRef.current;
Promise.all([fetchData({ page: -1 }), fetchTemplates({ page: -1 })]).then(
([instanceResItems, templatesRes]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(instanceResItems || [], templatesRes?.items || []);
}
);
loadCreateResources();
}
}, [open, shouldAutoSelectResource, action]);
// filter instance types
const filteredInstanceTypes = instanceTypeList.filter((item) =>
// filter instance types (already scoped to the chosen org's clusters)
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
matchKeyword([item.name], instanceKeyword)
);
// No instance types for the chosen org (e.g. it owns no clusters), and not
// mid-fetch — drives the "no available instance type" message in the form.
const noAvailableInstanceTypes =
action === PageAction.CREATE &&
!instanceTypesLoading &&
ownedInstanceTypes.length === 0;
// filter templates based on selection and keyword
const filteredTemplates = templateList.filter((item) => {
if (
@@ -445,8 +565,10 @@ const AddModal: React.FC<AddModalProps> = ({
currentData={data}
disabled={readonly}
onFinish={onFinish}
onScopeChange={handleScopeChange}
open={open}
instanceTypeList={instanceTypeList}
instanceTypeList={ownedInstanceTypes}
noAvailableInstanceTypes={noAvailableInstanceTypes}
/>
</>
</ColumnWrapper>
@@ -1,9 +1,9 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { PageActionType } from '@/config/types';
import { Input as CInput, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
import formStyles from '../styles/instances.module.less';
@@ -18,7 +18,6 @@ const Basic = ({
const { getRuleMessage } = useAppUtils();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
data-field="name"
name="name"
@@ -46,6 +45,7 @@ const Basic = ({
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<div className={formStyles.command}>
<Form.Item<FormData> name="description" hidden>
<CInput.TextArea
@@ -58,7 +58,16 @@ interface InstanceFormProps {
currentData?: ListItem | null;
namespace?: string;
instanceTypeList?: InstanceTypeItem[];
// True when the (org-scoped) instance-type list is empty and not loading —
// surfaces a "no available instance type" message in the scheduling tab.
noAvailableInstanceTypes?: boolean;
disabled?: boolean;
// Fired when the create-scope picker retargets the form to another
// org (or Global). Only emitted on genuine changes — never on the
// initial mount, and never in builds where the picker isn't mounted
// (the watched field stays undefined). Lets the parent re-scope the
// tenant-scoped instance-type / template offerings.
onScopeChange?: (orgId: number | null | undefined) => void;
onFinish: (values: FormData) => Promise<void>;
}
@@ -101,6 +110,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
disabled,
open,
instanceTypeList = [],
noAvailableInstanceTypes,
onScopeChange,
onFinish
} = props;
const intl = useIntl();
@@ -110,6 +121,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const formAction =
realAction === PageAction.CREATE ? PageAction.CREATE : action;
const sshEnabled = Form.useWatch('enable_ssh', form);
// `organization_id` is owned by the create-scope picker slot; it only
// exists/changes when a platform admin retargets the form. Watch it
// so the parent can re-scope offerings (see onScopeChange).
const scopeOrgId = Form.useWatch('organization_id', form);
const scopeInitRef = useRef(true);
// Keep the latest callback in a ref so the scope-change effect can call it
// without depending on its identity (parent may pass a new fn each render).
const onScopeChangeRef = useRef(onScopeChange);
onScopeChangeRef.current = onScopeChange;
const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
const [sshOverlayOpen, setSshOverlayOpen] = useState(false);
const { getScrollElementScrollableHeight } = useWrapperContext();
@@ -133,6 +153,17 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}
}, [open, action]);
// Skip the first run (initial mount value); thereafter notify the
// parent whenever the chosen create scope changes so it can reload
// the tenant-scoped instance-type / template lists.
useEffect(() => {
if (scopeInitRef.current) {
scopeInitRef.current = false;
return;
}
onScopeChangeRef.current?.(scopeOrgId);
}, [scopeOrgId]);
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const hasSSHPort = useMemo(
@@ -248,6 +279,23 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
if (!instanceType) {
setSelectedInstanceType(undefined);
setOnceMaxRequest({ cpu: null, memory: null, localStorage: null });
// Also reset the instance-type-derived form fields (cluster, type and
// the CPU / memory / GPU-count resources) so the previous scope's
// numbers don't linger when the new org has no clusters.
const spec = form.getFieldValue('spec') || {};
form.setFieldsValue({
clusterId: null,
spec: {
...spec,
type: undefined,
resources: {
...spec.resources,
accelerator: undefined,
cpu: undefined,
ram: undefined
}
}
} as any);
return;
}
const candidate = pickCandidateForAccelerator(
@@ -402,7 +450,14 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
form.setFieldsValue(values as any);
},
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType: (instanceType: InstanceTypeItem) => {
applyInstanceType: (instanceType?: InstanceTypeItem) => {
if (!instanceType) {
// Clear the current instance-type selection (e.g. the create scope
// switched to an org with no clusters) so its card/limits don't
// linger.
resolveAndApply(undefined, 0);
return;
}
const count = instanceType.spec?.acceleratable ? 1 : 0;
resolveAndApply(instanceType, count);
}
@@ -501,6 +556,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
noAvailableTypes={noAvailableInstanceTypes}
onGPUCountChange={handleAcceleratorChange}
/>
)
@@ -30,10 +30,12 @@ const SelectedCard = styled.div`
interface InstanceTypePickerProps {
selectedInstanceType?: InstanceTypeItemModel;
noAvailable?: boolean;
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
selectedInstanceType
selectedInstanceType,
noAvailable
}) => {
const intl = useIntl();
return (
@@ -42,7 +44,11 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
<InstanceTypeItem item={selectedInstanceType} showStatus={false} />
) : (
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{intl.formatMessage({ id: 'gpuservice.instance.type.required' })}
{intl.formatMessage({
id: noAvailable
? 'gpuservice.instance.type.noAvailable'
: 'gpuservice.instance.type.required'
})}
</span>
)}
</SelectedCard>
@@ -55,6 +61,10 @@ interface InstanceTypeFormItemProps {
currentData?: ListItem;
selectedInstanceType?: InstanceTypeItemModel;
onceMaxRequest?: BasicResourceMax;
// True when the (org-scoped) instance-type list is empty — e.g. the chosen
// org owns no clusters. Surface a "no available" message instead of the
// "please select" placeholder + empty CPU / memory inputs.
noAvailableTypes?: boolean;
onGPUCountChange?: (value: number) => void;
}
@@ -64,6 +74,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
currentData,
selectedInstanceType,
onceMaxRequest,
noAvailableTypes,
onGPUCountChange
}) => {
const intl = useIntl();
@@ -152,12 +163,15 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
]}
>
{action === PageAction.CREATE && (
<InstanceTypePicker selectedInstanceType={selectedInstanceType} />
<InstanceTypePicker
selectedInstanceType={selectedInstanceType}
noAvailable={noAvailableTypes}
/>
)}
{action === PageAction.EDIT && renderInstanceType()}
</Form.Item>
</FieldBlock>
{isGPU && (
{!noAvailableTypes && isGPU && (
<Form.Item<FormData>
name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT}
@@ -232,32 +246,34 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
/>
</Form.Item>
)}
<Flex gap={12}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'ram']}
normalize={(value) => (value ? `${value}Gi` : null)}
getValueProps={(value) => ({
value: _.toString(value).replace(/Gi$/, '')
})}
>
<InputNumber
disabled={isGPU || disabled || action === PageAction.EDIT}
label={renderMemoryLabel()}
max={onceMaxRequest?.memory ?? undefined}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 'resources', 'cpu']}>
<InputNumber
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
max={onceMaxRequest?.cpu ?? undefined}
disabled={disabled || isGPU || action === PageAction.EDIT}
/>
</Form.Item>
</div>
</Flex>
{!noAvailableTypes && (
<Flex gap={12}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'ram']}
normalize={(value) => (value ? `${value}Gi` : null)}
getValueProps={(value) => ({
value: _.toString(value).replace(/Gi$/, '')
})}
>
<InputNumber
disabled={isGPU || disabled || action === PageAction.EDIT}
label={renderMemoryLabel()}
max={onceMaxRequest?.memory ?? undefined}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 'resources', 'cpu']}>
<InputNumber
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
max={onceMaxRequest?.cpu ?? undefined}
disabled={disabled || isGPU || action === PageAction.EDIT}
/>
</Form.Item>
</div>
</Flex>
)}
</div>
);
};
@@ -1,8 +1,8 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
const Basic = ({ action }: { action: string }) => {
@@ -11,7 +11,6 @@ const Basic = ({ action }: { action: string }) => {
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
@@ -37,6 +36,7 @@ const Basic = ({ action }: { action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
@@ -6,7 +7,6 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { StorageTypeKindOptions } from '../config';
import { FormData } from '../config/types';
@@ -16,7 +16,6 @@ const Basic = ({ action }: { action: string }) => {
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
@@ -42,6 +41,7 @@ const Basic = ({ action }: { action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<Form.Item<FormData>
name="type"
rules={[
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
@@ -8,7 +9,6 @@ import {
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { useContext } from 'react';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormContext } from '../config/form-context';
import { FormData } from '../config/types';
@@ -19,7 +19,6 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
@@ -45,6 +44,7 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
@@ -13,6 +13,7 @@ import theadLogoZH from '@/assets/logo/t-head-zh.png';
import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
import vllmLogo from '@/assets/logo/vllm.png';
import PluginExtraFields from '@/components/plugin-extra-fields';
import {
GPUsConfigs,
manfacturerValueMap
@@ -257,6 +258,10 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
{data.displayName || data.name || '-'}
</AutoTooltip>
{renderManufacturerTag()}
<PluginExtraFields
name="OwnerScopeTag"
context={{ ownerPrincipalId: data.owner_principal_id }}
/>
</CardName>
<InfoItem>
<span>
@@ -35,6 +35,11 @@ export interface FormData {
export interface ListItem extends FormData {
id: number;
status?: string;
// NULL = a shared, admin-managed preset visible to everyone; a
// non-NULL id scopes the template to a single owner. Surfaced via
// the `OwnerScopeTag` slot on the card so the owner is visible at a
// glance. Optional on the wire — absent in single-owner builds.
owner_principal_id?: number | null;
created_at?: string;
updated_at?: string;
}
@@ -1,4 +1,6 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { validateLabelNameRegxFor63 } from '@/config';
import { PageActionType } from '@/config/types';
import {
Input as CInput,
InputNumber,
@@ -28,12 +30,14 @@ export interface BasicResourceMax {
interface BasicProps {
page?: 'template' | 'instance';
action?: PageActionType;
disabled?: boolean;
onceMaxRequest?: BasicResourceMax;
}
const Basic: React.FC<BasicProps> = ({
page = 'template',
action,
onceMaxRequest,
disabled
}) => {
@@ -105,6 +109,10 @@ const Basic: React.FC<BasicProps> = ({
maxLength={63}
/>
</Form.Item>
<PluginExtraFields
name="CreateOrgScopeField"
context={{ action, allowGlobal: true }}
/>
<Form.Item<FormData>
name="manufacturer"
rules={[
@@ -82,7 +82,7 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
}
}}
>
<Basic />
<Basic action={action} />
</Form>
);
}