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.templates': 'Instance Templates',
|
||||||
'gpuservice.instance.section.storage': 'Storage',
|
'gpuservice.instance.section.storage': 'Storage',
|
||||||
'gpuservice.instance.type.required': 'Please select an instance type',
|
'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': 'GPU Count',
|
||||||
'gpuservice.instance.gpuCount.required': 'Please enter the GPU count',
|
'gpuservice.instance.gpuCount.required': 'Please enter the GPU count',
|
||||||
'gpuservice.instance.gpuCount.max':
|
'gpuservice.instance.gpuCount.max':
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export default {
|
|||||||
'gpuservice.instance.templates': 'インスタンステンプレート',
|
'gpuservice.instance.templates': 'インスタンステンプレート',
|
||||||
'gpuservice.instance.section.storage': 'ストレージボリューム',
|
'gpuservice.instance.section.storage': 'ストレージボリューム',
|
||||||
'gpuservice.instance.type.required': 'インスタンスタイプを選択してください',
|
'gpuservice.instance.type.required': 'インスタンスタイプを選択してください',
|
||||||
|
'gpuservice.instance.type.noAvailable':
|
||||||
|
'利用可能なインスタンスタイプがありません',
|
||||||
'gpuservice.instance.gpuCount': 'GPU 数',
|
'gpuservice.instance.gpuCount': 'GPU 数',
|
||||||
'gpuservice.instance.gpuCount.required': 'GPU 数を入力してください',
|
'gpuservice.instance.gpuCount.required': 'GPU 数を入力してください',
|
||||||
'gpuservice.instance.gpuCount.max':
|
'gpuservice.instance.gpuCount.max':
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default {
|
|||||||
'gpuservice.instance.templates': 'Шаблоны экземпляров',
|
'gpuservice.instance.templates': 'Шаблоны экземпляров',
|
||||||
'gpuservice.instance.section.storage': 'Том хранилища',
|
'gpuservice.instance.section.storage': 'Том хранилища',
|
||||||
'gpuservice.instance.type.required': 'Выберите тип экземпляра',
|
'gpuservice.instance.type.required': 'Выберите тип экземпляра',
|
||||||
|
'gpuservice.instance.type.noAvailable': 'Нет доступных типов экземпляров',
|
||||||
'gpuservice.instance.gpuCount': 'Количество GPU',
|
'gpuservice.instance.gpuCount': 'Количество GPU',
|
||||||
'gpuservice.instance.gpuCount.required': 'Введите количество GPU',
|
'gpuservice.instance.gpuCount.required': 'Введите количество GPU',
|
||||||
'gpuservice.instance.gpuCount.max': 'Выберите максимум {count} GPU-карт',
|
'gpuservice.instance.gpuCount.max': 'Выберите максимум {count} GPU-карт',
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export default {
|
|||||||
'gpuservice.instance.templates': 'Örnek Şablonları',
|
'gpuservice.instance.templates': 'Örnek Şablonları',
|
||||||
'gpuservice.instance.section.storage': 'Depolama Hacmi',
|
'gpuservice.instance.section.storage': 'Depolama Hacmi',
|
||||||
'gpuservice.instance.type.required': 'Lütfen bir örnek türü seçin',
|
'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': 'GPU Sayısı',
|
||||||
'gpuservice.instance.gpuCount.required': 'Lütfen GPU sayısını girin',
|
'gpuservice.instance.gpuCount.required': 'Lütfen GPU sayısını girin',
|
||||||
'gpuservice.instance.gpuCount.max': 'En fazla {count} GPU kartı seçin',
|
'gpuservice.instance.gpuCount.max': 'En fazla {count} GPU kartı seçin',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export default {
|
|||||||
'gpuservice.instance.templates': '实例模板',
|
'gpuservice.instance.templates': '实例模板',
|
||||||
'gpuservice.instance.section.storage': '存储卷',
|
'gpuservice.instance.section.storage': '存储卷',
|
||||||
'gpuservice.instance.type.required': '请选择实例类型',
|
'gpuservice.instance.type.required': '请选择实例类型',
|
||||||
|
'gpuservice.instance.type.noAvailable': '无可用的实例类型',
|
||||||
'gpuservice.instance.gpuCount': 'GPU 数量',
|
'gpuservice.instance.gpuCount': 'GPU 数量',
|
||||||
'gpuservice.instance.gpuCount.required': '请输入 GPU 数量',
|
'gpuservice.instance.gpuCount.required': '请输入 GPU 数量',
|
||||||
'gpuservice.instance.gpuCount.max': '最多选择 {count} 张卡',
|
'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 { PageAction } from '@/config';
|
||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
|
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||||
import Separator from '@/pages/llmodels/components/separator';
|
import Separator from '@/pages/llmodels/components/separator';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Empty, Input, Typography } from 'antd';
|
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 { ListItem as TemplateItem } from '../../templates/config/types';
|
||||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||||
@@ -97,8 +98,58 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
} = useQueryInstanceTypes();
|
} = useQueryInstanceTypes();
|
||||||
const { detailData: templatesData, fetchData: fetchTemplates } =
|
const { detailData: templatesData, fetchData: fetchTemplates } =
|
||||||
useQueryTemplates();
|
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 || [];
|
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 = action === PageAction.VIEW;
|
||||||
const readonly = false;
|
const readonly = false;
|
||||||
const isRecreate = realAction === PageAction.CREATE;
|
const isRecreate = realAction === PageAction.CREATE;
|
||||||
@@ -177,7 +228,13 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
// initial for first
|
// initial for first
|
||||||
const applyAutoSelection = (
|
const applyAutoSelection = (
|
||||||
instanceTypes: InstanceTypeItem[],
|
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.
|
// On edit / view, surface the persisted selection in the card list.
|
||||||
if (!shouldAutoSelectResource) {
|
if (!shouldAutoSelectResource) {
|
||||||
@@ -195,9 +252,24 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
return;
|
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
|
// On create, auto-select the first instance type in the list
|
||||||
|
|
||||||
@@ -209,6 +281,52 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
applySelection(first, template);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
sessionRef.current += 1;
|
sessionRef.current += 1;
|
||||||
@@ -219,25 +337,27 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
setTemplateId(undefined);
|
setTemplateId(undefined);
|
||||||
setInstanceKeyword('');
|
setInstanceKeyword('');
|
||||||
setTemplateKeyword('');
|
setTemplateKeyword('');
|
||||||
|
setScopeOrgId(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === PageAction.CREATE) {
|
if (action === PageAction.CREATE) {
|
||||||
const session = ++sessionRef.current;
|
loadCreateResources();
|
||||||
Promise.all([fetchData({ page: -1 }), fetchTemplates({ page: -1 })]).then(
|
|
||||||
([instanceResItems, templatesRes]) => {
|
|
||||||
if (sessionRef.current !== session) return;
|
|
||||||
applyAutoSelection(instanceResItems || [], templatesRes?.items || []);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [open, shouldAutoSelectResource, action]);
|
}, [open, shouldAutoSelectResource, action]);
|
||||||
|
|
||||||
// filter instance types
|
// filter instance types (already scoped to the chosen org's clusters)
|
||||||
const filteredInstanceTypes = instanceTypeList.filter((item) =>
|
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
|
||||||
matchKeyword([item.name], instanceKeyword)
|
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
|
// filter templates based on selection and keyword
|
||||||
const filteredTemplates = templateList.filter((item) => {
|
const filteredTemplates = templateList.filter((item) => {
|
||||||
if (
|
if (
|
||||||
@@ -445,8 +565,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
currentData={data}
|
currentData={data}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
|
onScopeChange={handleScopeChange}
|
||||||
open={open}
|
open={open}
|
||||||
instanceTypeList={instanceTypeList}
|
instanceTypeList={ownedInstanceTypes}
|
||||||
|
noAvailableInstanceTypes={noAvailableInstanceTypes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
</ColumnWrapper>
|
</ColumnWrapper>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import { Input as CInput, useAppUtils } from '@gpustack/core-ui';
|
import { Input as CInput, useAppUtils } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
|
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
import formStyles from '../styles/instances.module.less';
|
import formStyles from '../styles/instances.module.less';
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ const Basic = ({
|
|||||||
const { getRuleMessage } = useAppUtils();
|
const { getRuleMessage } = useAppUtils();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OwnerPrincipalIdField name="owner_principal_id" />
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
data-field="name"
|
data-field="name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -46,6 +45,7 @@ const Basic = ({
|
|||||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
<div className={formStyles.command}>
|
<div className={formStyles.command}>
|
||||||
<Form.Item<FormData> name="description" hidden>
|
<Form.Item<FormData> name="description" hidden>
|
||||||
<CInput.TextArea
|
<CInput.TextArea
|
||||||
|
|||||||
@@ -58,7 +58,16 @@ interface InstanceFormProps {
|
|||||||
currentData?: ListItem | null;
|
currentData?: ListItem | null;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
instanceTypeList?: InstanceTypeItem[];
|
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;
|
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>;
|
onFinish: (values: FormData) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +110,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
disabled,
|
disabled,
|
||||||
open,
|
open,
|
||||||
instanceTypeList = [],
|
instanceTypeList = [],
|
||||||
|
noAvailableInstanceTypes,
|
||||||
|
onScopeChange,
|
||||||
onFinish
|
onFinish
|
||||||
} = props;
|
} = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -110,6 +121,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
const formAction =
|
const formAction =
|
||||||
realAction === PageAction.CREATE ? PageAction.CREATE : action;
|
realAction === PageAction.CREATE ? PageAction.CREATE : action;
|
||||||
const sshEnabled = Form.useWatch('enable_ssh', form);
|
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 { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
|
||||||
const [sshOverlayOpen, setSshOverlayOpen] = useState(false);
|
const [sshOverlayOpen, setSshOverlayOpen] = useState(false);
|
||||||
const { getScrollElementScrollableHeight } = useWrapperContext();
|
const { getScrollElementScrollableHeight } = useWrapperContext();
|
||||||
@@ -133,6 +153,17 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [open, action]);
|
}, [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 ports = Form.useWatch(['spec', 'ports'], form) || [];
|
||||||
|
|
||||||
const hasSSHPort = useMemo(
|
const hasSSHPort = useMemo(
|
||||||
@@ -248,6 +279,23 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
if (!instanceType) {
|
if (!instanceType) {
|
||||||
setSelectedInstanceType(undefined);
|
setSelectedInstanceType(undefined);
|
||||||
setOnceMaxRequest({ cpu: null, memory: null, localStorage: null });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const candidate = pickCandidateForAccelerator(
|
const candidate = pickCandidateForAccelerator(
|
||||||
@@ -402,7 +450,14 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
form.setFieldsValue(values as any);
|
form.setFieldsValue(values as any);
|
||||||
},
|
},
|
||||||
getFieldsValue: () => form.getFieldsValue(),
|
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;
|
const count = instanceType.spec?.acceleratable ? 1 : 0;
|
||||||
resolveAndApply(instanceType, count);
|
resolveAndApply(instanceType, count);
|
||||||
}
|
}
|
||||||
@@ -501,6 +556,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
selectedInstanceType={selectedInstanceType}
|
selectedInstanceType={selectedInstanceType}
|
||||||
currentData={currentData as any}
|
currentData={currentData as any}
|
||||||
onceMaxRequest={onceMaxRequest}
|
onceMaxRequest={onceMaxRequest}
|
||||||
|
noAvailableTypes={noAvailableInstanceTypes}
|
||||||
onGPUCountChange={handleAcceleratorChange}
|
onGPUCountChange={handleAcceleratorChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ const SelectedCard = styled.div`
|
|||||||
|
|
||||||
interface InstanceTypePickerProps {
|
interface InstanceTypePickerProps {
|
||||||
selectedInstanceType?: InstanceTypeItemModel;
|
selectedInstanceType?: InstanceTypeItemModel;
|
||||||
|
noAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
||||||
selectedInstanceType
|
selectedInstanceType,
|
||||||
|
noAvailable
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,11 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
|||||||
<InstanceTypeItem item={selectedInstanceType} showStatus={false} />
|
<InstanceTypeItem item={selectedInstanceType} showStatus={false} />
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</SelectedCard>
|
</SelectedCard>
|
||||||
@@ -55,6 +61,10 @@ interface InstanceTypeFormItemProps {
|
|||||||
currentData?: ListItem;
|
currentData?: ListItem;
|
||||||
selectedInstanceType?: InstanceTypeItemModel;
|
selectedInstanceType?: InstanceTypeItemModel;
|
||||||
onceMaxRequest?: BasicResourceMax;
|
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;
|
onGPUCountChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +74,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
currentData,
|
currentData,
|
||||||
selectedInstanceType,
|
selectedInstanceType,
|
||||||
onceMaxRequest,
|
onceMaxRequest,
|
||||||
|
noAvailableTypes,
|
||||||
onGPUCountChange
|
onGPUCountChange
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -152,12 +163,15 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{action === PageAction.CREATE && (
|
{action === PageAction.CREATE && (
|
||||||
<InstanceTypePicker selectedInstanceType={selectedInstanceType} />
|
<InstanceTypePicker
|
||||||
|
selectedInstanceType={selectedInstanceType}
|
||||||
|
noAvailable={noAvailableTypes}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{action === PageAction.EDIT && renderInstanceType()}
|
{action === PageAction.EDIT && renderInstanceType()}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
{isGPU && (
|
{!noAvailableTypes && isGPU && (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name={['spec', 'resources', 'accelerator']}
|
name={['spec', 'resources', 'accelerator']}
|
||||||
hidden={action === PageAction.EDIT}
|
hidden={action === PageAction.EDIT}
|
||||||
@@ -232,32 +246,34 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Flex gap={12}>
|
{!noAvailableTypes && (
|
||||||
<div style={{ flex: 1 }}>
|
<Flex gap={12}>
|
||||||
<Form.Item<FormData>
|
<div style={{ flex: 1 }}>
|
||||||
name={['spec', 'resources', 'ram']}
|
<Form.Item<FormData>
|
||||||
normalize={(value) => (value ? `${value}Gi` : null)}
|
name={['spec', 'resources', 'ram']}
|
||||||
getValueProps={(value) => ({
|
normalize={(value) => (value ? `${value}Gi` : null)}
|
||||||
value: _.toString(value).replace(/Gi$/, '')
|
getValueProps={(value) => ({
|
||||||
})}
|
value: _.toString(value).replace(/Gi$/, '')
|
||||||
>
|
})}
|
||||||
<InputNumber
|
>
|
||||||
disabled={isGPU || disabled || action === PageAction.EDIT}
|
<InputNumber
|
||||||
label={renderMemoryLabel()}
|
disabled={isGPU || disabled || action === PageAction.EDIT}
|
||||||
max={onceMaxRequest?.memory ?? undefined}
|
label={renderMemoryLabel()}
|
||||||
/>
|
max={onceMaxRequest?.memory ?? undefined}
|
||||||
</Form.Item>
|
/>
|
||||||
</div>
|
</Form.Item>
|
||||||
<div style={{ flex: 1 }}>
|
</div>
|
||||||
<Form.Item<FormData> name={['spec', 'resources', 'cpu']}>
|
<div style={{ flex: 1 }}>
|
||||||
<InputNumber
|
<Form.Item<FormData> name={['spec', 'resources', 'cpu']}>
|
||||||
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
|
<InputNumber
|
||||||
max={onceMaxRequest?.cpu ?? undefined}
|
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
|
||||||
disabled={disabled || isGPU || action === PageAction.EDIT}
|
max={onceMaxRequest?.cpu ?? undefined}
|
||||||
/>
|
disabled={disabled || isGPU || action === PageAction.EDIT}
|
||||||
</Form.Item>
|
/>
|
||||||
</div>
|
</Form.Item>
|
||||||
</Flex>
|
</div>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
||||||
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
|
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
|
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
|
|
||||||
const Basic = ({ action }: { action: string }) => {
|
const Basic = ({ action }: { action: string }) => {
|
||||||
@@ -11,7 +11,6 @@ const Basic = ({ action }: { action: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OwnerPrincipalIdField name="owner_principal_id" />
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="name"
|
name="name"
|
||||||
rules={[
|
rules={[
|
||||||
@@ -37,6 +36,7 @@ const Basic = ({ action }: { action: string }) => {
|
|||||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name={['spec', 'data']}
|
name={['spec', 'data']}
|
||||||
rules={[
|
rules={[
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
||||||
import {
|
import {
|
||||||
Input as CInput,
|
Input as CInput,
|
||||||
@@ -6,7 +7,6 @@ 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 OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
|
|
||||||
import { StorageTypeKindOptions } from '../config';
|
import { StorageTypeKindOptions } from '../config';
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ const Basic = ({ action }: { action: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OwnerPrincipalIdField name="owner_principal_id" />
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="name"
|
name="name"
|
||||||
rules={[
|
rules={[
|
||||||
@@ -42,6 +41,7 @@ const Basic = ({ action }: { action: string }) => {
|
|||||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="type"
|
name="type"
|
||||||
rules={[
|
rules={[
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
||||||
import {
|
import {
|
||||||
Input as CInput,
|
Input as CInput,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Flex, Form } from 'antd';
|
import { Flex, Form } from 'antd';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
|
|
||||||
import { FormContext } from '../config/form-context';
|
import { FormContext } from '../config/form-context';
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OwnerPrincipalIdField name="owner_principal_id" />
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="name"
|
name="name"
|
||||||
rules={[
|
rules={[
|
||||||
@@ -45,6 +44,7 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
|||||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||||
<Flex gap={16}>
|
<Flex gap={16}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import theadLogoZH from '@/assets/logo/t-head-zh.png';
|
|||||||
import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
|
import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
|
||||||
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
|
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
|
||||||
import vllmLogo from '@/assets/logo/vllm.png';
|
import vllmLogo from '@/assets/logo/vllm.png';
|
||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import {
|
import {
|
||||||
GPUsConfigs,
|
GPUsConfigs,
|
||||||
manfacturerValueMap
|
manfacturerValueMap
|
||||||
@@ -257,6 +258,10 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
{data.displayName || data.name || '-'}
|
{data.displayName || data.name || '-'}
|
||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
{renderManufacturerTag()}
|
{renderManufacturerTag()}
|
||||||
|
<PluginExtraFields
|
||||||
|
name="OwnerScopeTag"
|
||||||
|
context={{ ownerPrincipalId: data.owner_principal_id }}
|
||||||
|
/>
|
||||||
</CardName>
|
</CardName>
|
||||||
<InfoItem>
|
<InfoItem>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface FormData {
|
|||||||
export interface ListItem extends FormData {
|
export interface ListItem extends FormData {
|
||||||
id: number;
|
id: number;
|
||||||
status?: string;
|
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;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import { validateLabelNameRegxFor63 } from '@/config';
|
import { validateLabelNameRegxFor63 } from '@/config';
|
||||||
|
import { PageActionType } from '@/config/types';
|
||||||
import {
|
import {
|
||||||
Input as CInput,
|
Input as CInput,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
@@ -28,12 +30,14 @@ export interface BasicResourceMax {
|
|||||||
|
|
||||||
interface BasicProps {
|
interface BasicProps {
|
||||||
page?: 'template' | 'instance';
|
page?: 'template' | 'instance';
|
||||||
|
action?: PageActionType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onceMaxRequest?: BasicResourceMax;
|
onceMaxRequest?: BasicResourceMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Basic: React.FC<BasicProps> = ({
|
const Basic: React.FC<BasicProps> = ({
|
||||||
page = 'template',
|
page = 'template',
|
||||||
|
action,
|
||||||
onceMaxRequest,
|
onceMaxRequest,
|
||||||
disabled
|
disabled
|
||||||
}) => {
|
}) => {
|
||||||
@@ -105,6 +109,10 @@ const Basic: React.FC<BasicProps> = ({
|
|||||||
maxLength={63}
|
maxLength={63}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<PluginExtraFields
|
||||||
|
name="CreateOrgScopeField"
|
||||||
|
context={{ action, allowGlobal: true }}
|
||||||
|
/>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="manufacturer"
|
name="manufacturer"
|
||||||
rules={[
|
rules={[
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Basic />
|
<Basic action={action} />
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user