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:
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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-карт',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user