From c4672801705e417835492c28f06e8ca29589a6f2 Mon Sep 17 00:00:00 2001 From: jialin Date: Thu, 11 Sep 2025 13:31:11 +0800 Subject: [PATCH] fix: display instance type spec --- src/assets/styles/common.less | 4 + src/atoms/clusters.ts | 40 ++++ src/components/collapse-container/index.tsx | 4 +- src/components/drop-down-buttons/index.tsx | 2 +- .../dynamic-form/components/form-widget.tsx | 11 +- .../dynamic-form/components/list-map.tsx | 45 +++-- src/components/dynamic-form/config/types.ts | 1 + src/components/icon-font/index.tsx | 2 +- src/components/label-selector/index.tsx | 5 +- src/components/label-selector/inner.tsx | 4 + src/components/label-selector/label-item.tsx | 26 ++- src/components/label-selector/wrapper.tsx | 26 +-- src/components/seal-form/seal-cascader.tsx | 2 +- src/components/seal-form/seal-select.tsx | 3 +- src/components/seal-form/simple-select.tsx | 4 +- src/components/seal-form/wrapper/index.tsx | 1 + src/components/seal-form/wrapper/input.ts | 2 + src/components/seal-form/wrapper/select.ts | 4 +- src/hooks/use-table-fetch.ts | 1 + src/locales/en-US/clusters.ts | 9 +- src/locales/ja-JP/clusters.ts | 16 +- src/locales/ru-RU/clusters.ts | 19 +- src/locales/zh-CN/clusters.ts | 13 +- src/pages/cluster-management/apis/index.ts | 49 +++++ .../cluster-management/cluster-create.tsx | 49 +++-- src/pages/cluster-management/clusters.tsx | 5 +- .../components/add-pool.tsx | 2 +- .../components/add-worker-step.tsx | 8 +- .../components/cloud-provider-form.tsx | 108 +++++++++-- .../components/cluster-form.tsx | 2 + .../components/footer-buttons.tsx | 6 +- .../components/instance-type-option.tsx | 22 +++ .../components/pool-form.tsx | 178 ++++++++++++++++- .../components/pool-rows.tsx | 13 +- .../components/provider-catalog.tsx | 4 +- .../components/volumes-config.tsx | 3 + .../components/worker-pools.tsx | 2 +- src/pages/cluster-management/config/index.ts | 21 ++ .../cluster-management/config/providers.ts | 68 +++++++ .../cluster-management/config/region-icons.ts | 17 ++ src/pages/cluster-management/config/types.ts | 4 +- .../hooks/use-cluster-columns.tsx | 48 ++--- .../hooks/use-pools-columns.tsx | 80 ++++++-- .../hooks/use-provider-regions.ts | 183 ++++++++++++++++++ .../step-forms/basic-form.tsx | 8 +- .../step-forms/use-step-list.tsx | 15 +- .../step-forms/worker-pools-form.tsx | 18 +- src/pages/llmodels/components/gpu-card.tsx | 61 ++++-- src/pages/llmodels/components/performance.tsx | 6 +- .../llmodels/hooks/use-models-columns.tsx | 2 +- src/pages/resources/config/index.ts | 8 +- src/pages/resources/config/types.ts | 2 +- .../resources/hooks/use-worker-columns.tsx | 2 +- src/utils/index.ts | 24 +++ 54 files changed, 1058 insertions(+), 204 deletions(-) create mode 100644 src/pages/cluster-management/components/instance-type-option.tsx create mode 100644 src/pages/cluster-management/config/providers.ts create mode 100644 src/pages/cluster-management/config/region-icons.ts create mode 100644 src/pages/cluster-management/hooks/use-provider-regions.ts diff --git a/src/assets/styles/common.less b/src/assets/styles/common.less index 993469d8..5aadef65 100644 --- a/src/assets/styles/common.less +++ b/src/assets/styles/common.less @@ -257,6 +257,10 @@ color: var(--ant-color-text-tertiary); } +.text-quaternary { + color: var(--ant-color-text-quaternary); +} + .tips-desc-list { list-style-type: decimal; padding-left: 16px; diff --git a/src/atoms/clusters.ts b/src/atoms/clusters.ts index dec7b324..6a80eca2 100644 --- a/src/atoms/clusters.ts +++ b/src/atoms/clusters.ts @@ -2,3 +2,43 @@ import { atom } from 'jotai'; // models expand keys: create, update , delete, export const expandKeysAtom = atom([]); + +export const regionListAtom = atom< + { + datacenter: string; + label: string; + value: string; + icon: string; + sizes: string[]; + }[] +>([]); + +export const regionInstanceTypeListAtom = atom< + { + label: string; + value: string; + }[] +>([]); + +export const regionOSImageListAtom = atom< + { + label: string; + value: string; + }[] +>([]); + +export const allRegionOSImageListAtom = atom< + { + label: string; + value: string; + regions: string[]; + }[] +>([]); + +export const allRegionInstanceTypeListAtom = atom< + { + label: string; + value: string; + regions: string[]; + }[] +>([]); diff --git a/src/components/collapse-container/index.tsx b/src/components/collapse-container/index.tsx index 0e4b5477..d9ae15b6 100644 --- a/src/components/collapse-container/index.tsx +++ b/src/components/collapse-container/index.tsx @@ -173,8 +173,8 @@ export default function CollapsibleContainer({ ref={contentRef} style={{ maxHeight: height, - overflow: 'hidden', - transition: collapsible ? 'max-height 0.2s ease' : 'none' + overflow: 'hidden' + // transition: collapsible ? 'max-height 0.2s ease' : 'none' }} >
{children}
diff --git a/src/components/drop-down-buttons/index.tsx b/src/components/drop-down-buttons/index.tsx index 13b53c63..a5017d7d 100644 --- a/src/components/drop-down-buttons/index.tsx +++ b/src/components/drop-down-buttons/index.tsx @@ -76,7 +76,7 @@ const DropdownButtons: React.FC = ({ disabled={disabled} trigger={trigger} type="primary" - dropdownRender={(menus: any) => { + popupRender={(menus: any) => { return ( {_.map(_.tail(items), (item: any) => { diff --git a/src/components/dynamic-form/components/form-widget.tsx b/src/components/dynamic-form/components/form-widget.tsx index 09548ca9..f9531899 100644 --- a/src/components/dynamic-form/components/form-widget.tsx +++ b/src/components/dynamic-form/components/form-widget.tsx @@ -18,6 +18,7 @@ const FormWidget: React.FC< min, max, checked, + readOnly: disabled, onChange }) => { const Component = ComponentsMap[widget]; @@ -29,7 +30,15 @@ const FormWidget: React.FC< return Component ? ( ; + disabled?: boolean; onChange?: (data: any) => void; } interface ListItemProps { schemaList: any[]; data: Record; + disabled?: boolean; onChange?: (data: any) => void; } -const ListItem: React.FC = ({ schemaList, data, onChange }) => { +const ListItem: React.FC = ({ + schemaList, + data, + onChange, + disabled +}) => { const handleValueChange = (name: string, target: any) => { if (target?.target?.type === 'checkbox') { const checked = target.target?.checked; @@ -50,6 +57,7 @@ const ListItem: React.FC = ({ schemaList, data, onChange }) => { = ({ btnText, properties = {}, minItems = 0, + disabled, onChange }) => { const [items, setItems] = React.useState(dataList || []); @@ -110,28 +119,36 @@ const ListMap: React.FC = ({ }, [dataList]); return ( - + {items.map((item, index) => ( handleItemChange(index, value)} /> - + {!disabled && ( + + )} ); }; diff --git a/src/components/label-selector/wrapper.tsx b/src/components/label-selector/wrapper.tsx index 0176fd9a..4ed815b3 100644 --- a/src/components/label-selector/wrapper.tsx +++ b/src/components/label-selector/wrapper.tsx @@ -11,6 +11,7 @@ interface WrapperProps { labelExtra?: React.ReactNode; children: React.ReactNode; btnText?: string; + disabled?: boolean; onAdd?: () => void; button?: React.ReactNode; } @@ -44,6 +45,7 @@ const Wrapper: React.FC = ({ labelExtra, onAdd, btnText, + disabled, button }) => { const intl = useIntl(); @@ -59,17 +61,19 @@ const Wrapper: React.FC = ({ )} {children} - - {button || ( - - )} - + {!disabled && ( + + {button || ( + + )} + + )} ); }; diff --git a/src/components/seal-form/seal-cascader.tsx b/src/components/seal-form/seal-cascader.tsx index 413bd925..3578a86d 100644 --- a/src/components/seal-form/seal-cascader.tsx +++ b/src/components/seal-form/seal-cascader.tsx @@ -185,7 +185,7 @@ const SealCascader: React.FC< onBlur={handleOnBlur} onChange={handleChange} notFoundContent={null} - onDropdownVisibleChange={handleDropdownVisibleChange} + onOpenChange={handleDropdownVisibleChange} > diff --git a/src/components/seal-form/seal-select.tsx b/src/components/seal-form/seal-select.tsx index dad5a012..d3fc9e54 100644 --- a/src/components/seal-form/seal-select.tsx +++ b/src/components/seal-form/seal-select.tsx @@ -18,6 +18,7 @@ const SealSelect: React.FC = (props) => { options, allowNull, isInFormItems = true, + notFoundContent = null, ...rest } = props; const intl = useIntl(); @@ -102,7 +103,7 @@ const SealSelect: React.FC = (props) => { onFocus={handleOnFocus} onBlur={handleOnBlur} onChange={handleChange} - notFoundContent={null} + notFoundContent={notFoundContent} > {children} diff --git a/src/components/seal-form/simple-select.tsx b/src/components/seal-form/simple-select.tsx index d0e560f8..d9a109b1 100644 --- a/src/components/seal-form/simple-select.tsx +++ b/src/components/seal-form/simple-select.tsx @@ -241,7 +241,7 @@ const SimpleSelect: React.FC = (props) => { options={optionsList} maxTagCount={0} defaultActiveFirstOption={false} - dropdownRender={dropdownRender} + popupRender={dropdownRender} optionRender={optionRender} menuItemSelectedIcon={false} onChange={handleOnChange} @@ -250,7 +250,7 @@ const SimpleSelect: React.FC = (props) => { onFocus={handleOnFocus} onSearch={handleOnSearch} filterOption={filterOption} - onDropdownVisibleChange={handleOnOpenChange} + onOpenChange={handleOnOpenChange} > ); diff --git a/src/components/seal-form/wrapper/index.tsx b/src/components/seal-form/wrapper/index.tsx index cb4b2b71..8bfdef33 100644 --- a/src/components/seal-form/wrapper/index.tsx +++ b/src/components/seal-form/wrapper/index.tsx @@ -156,6 +156,7 @@ export const Label = styled.div.attrs<{ // inner const Inner = styled.div` width: 100%; + display: flex; `; const Extra = styled.div` diff --git a/src/components/seal-form/wrapper/input.ts b/src/components/seal-form/wrapper/input.ts index a17021cf..887ad012 100644 --- a/src/components/seal-form/wrapper/input.ts +++ b/src/components/seal-form/wrapper/input.ts @@ -79,6 +79,7 @@ const InputWrapper = styled.div` } .ant-input-number { + flex: 1; position: static; display: flex; align-items: center; @@ -87,6 +88,7 @@ const InputWrapper = styled.div` padding: 0; height: ${INPUTHEIGHT}px !important; background-color: ${BGCOLOR}; + flex: 1; &:hover .ant-input-number-handler-wrap, &-focused .ant-input-number-handler-wrap { diff --git a/src/components/seal-form/wrapper/select.ts b/src/components/seal-form/wrapper/select.ts index 69211c9b..3842276c 100644 --- a/src/components/seal-form/wrapper/select.ts +++ b/src/components/seal-form/wrapper/select.ts @@ -98,8 +98,8 @@ const SelectWrapper = styled.div` height: 54px; .ant-select-selection-search { - top: 20px !important; - inset-inline-start: ${INPUT_INNER_PADDING}px; + // top: 20px !important; + // inset-inline-start: ${INPUT_INNER_PADDING}px; } &.ant-select-multiple.ant-cascader .ant-select-selection-search { top: 0 !important; diff --git a/src/hooks/use-table-fetch.ts b/src/hooks/use-table-fetch.ts index 62f1da17..664268e0 100644 --- a/src/hooks/use-table-fetch.ts +++ b/src/hooks/use-table-fetch.ts @@ -90,6 +90,7 @@ export default function useTableFetch( const updateHandler = (list: any) => { _.each(list, (data: any) => { + console.log('list================:', list); updateChunkedList(data); }); }; diff --git a/src/locales/en-US/clusters.ts b/src/locales/en-US/clusters.ts index f5d81895..a5df979c 100644 --- a/src/locales/en-US/clusters.ts +++ b/src/locales/en-US/clusters.ts @@ -25,5 +25,12 @@ export default { 'clusters.workerpool.size': 'Size (GiB)', 'clusters.workerpool.title': 'Worker Pools', 'clusters.workerpool.cloudOptions': 'Add Cloud Options', - 'clusters.workerpool.volumes.add': 'Add Volume' + 'clusters.workerpool.volumes.add': 'Add Volume', + 'clusters.create.provider.self': 'Self-Hosted', + 'clusters.create.provider.cloud': 'Cloud Provider', + 'clusters.create.selectProvider': 'Select Provider', + 'clusters.create.configBasic': 'Basic Configuration', + 'clusters.create.execCommand': 'Execute Command', + 'clusters.create.supportedGpu': 'Supported GPUs', + 'clusters.create.skipfornow': 'Skip for now' }; diff --git a/src/locales/ja-JP/clusters.ts b/src/locales/ja-JP/clusters.ts index e80127de..b71e39c3 100644 --- a/src/locales/ja-JP/clusters.ts +++ b/src/locales/ja-JP/clusters.ts @@ -25,7 +25,14 @@ export default { 'clusters.workerpool.size': 'Size (GiB)', 'clusters.workerpool.title': 'Worker Pools', 'clusters.workerpool.cloudOptions': 'Add Cloud Options', - 'clusters.workerpool.volumes.add': 'Add Volume' + 'clusters.workerpool.volumes.add': 'Add Volume', + 'clusters.create.provider.self': 'Self-Hosted', + 'clusters.create.provider.cloud': 'Cloud Provider', + 'clusters.create.selectProvider': 'Select Provider', + 'clusters.create.configBasic': 'Basic Configuration', + 'clusters.create.execCommand': 'Execute Command', + 'clusters.create.supportedGpu': 'Supported GPUs', + 'clusters.create.skipfornow': 'Skip for now' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -56,5 +63,12 @@ export default { // 25. 'clusters.workerpool.title': 'Worker Pools', // 26. 'clusters.workerpool.cloudOptions': 'Add Cloud Options', // 27. 'clusters.workerpool.volumes.add': 'Add Volume' +// 28. 'clusters.create.provider.self': 'Self-Hosted', +// 29. 'clusters.create.provider.cloud': 'Cloud Provider', +// 30. 'clusters.create.selectProvider': 'Select Provider', +// 31. 'clusters.create.configBasic': 'Basic Configuration', +// 32. 'clusters.create.execCommand': 'Execute Command', +// 33. 'clusters.create.supportedGpu': 'Supported GPUs', +// 34. 'clusters.create.skipfornow': 'Skip for now' // ========== End of To-Do List ========== diff --git a/src/locales/ru-RU/clusters.ts b/src/locales/ru-RU/clusters.ts index 9aaccdf8..89ebe868 100644 --- a/src/locales/ru-RU/clusters.ts +++ b/src/locales/ru-RU/clusters.ts @@ -25,7 +25,14 @@ export default { 'clusters.workerpool.size': 'Size (GiB)', 'clusters.workerpool.title': 'Worker Pools', 'clusters.workerpool.cloudOptions': 'Add Cloud Options', - 'clusters.workerpool.volumes.add': 'Add Volume' + 'clusters.workerpool.volumes.add': 'Add Volume', + 'clusters.create.provider.self': 'Self-Hosted', + 'clusters.create.provider.cloud': 'Cloud Provider', + 'clusters.create.selectProvider': 'Select Provider', + 'clusters.create.configBasic': 'Basic Configuration', + 'clusters.create.execCommand': 'Execute Command', + 'clusters.create.supportedGpu': 'Supported GPUs', + 'clusters.create.skipfornow': 'Skip for now' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -55,5 +62,13 @@ export default { // 24. 'clusters.workerpool.size': 'Size (GiB)', // 25. 'clusters.workerpool.title': 'Worker Pools', // 26. 'clusters.workerpool.cloudOptions': 'Add Cloud Options', -// 27. 'clusters.workerpool.volumes.add': 'Add Volume' +// 27. 'clusters.workerpool.volumes.add': 'Add Volume', +// 28. 'clusters.create.provider.self': 'Self-Hosted', +// 29. 'clusters.create.provider.cloud': 'Cloud Provider', +// 30. 'clusters.create.selectProvider': 'Select Provider', +// 31. 'clusters.create.configBasic': 'Basic Configuration', +// 32. 'clusters.create.execCommand': 'Execute Command', +// 33. 'clusters.create.supportedGpu': 'Supported GPUs', +// 34. 'clusters.create.skipfornow': 'Skip for now' + // ========== End of To-Do List ========== diff --git a/src/locales/zh-CN/clusters.ts b/src/locales/zh-CN/clusters.ts index 8a7e4098..c81792d6 100644 --- a/src/locales/zh-CN/clusters.ts +++ b/src/locales/zh-CN/clusters.ts @@ -10,7 +10,7 @@ export default { 'clusters.edit.cluster': '编辑 {cluster}', 'clusters.provider.custom': '自定义', 'clusters.button.register': '注册集群', - 'clusters.button.addNodePool': '添加节点池', + 'clusters.button.addNodePool': '添加 Worker Pool', 'clusters.button.add.credential': '添加 {provider} 凭证', 'clusters.credential.title': '凭证', 'clusters.credential.token': '访问令牌', @@ -23,7 +23,14 @@ export default { 'clusters.workerpool.volumes': '存储卷', 'clusters.workerpool.format': '文件系统格式', 'clusters.workerpool.size': '容量(GiB)', - 'clusters.workerpool.title': '节点池', + 'clusters.workerpool.title': 'Worker Pools', 'clusters.workerpool.cloudOptions': '添加云配置', - 'clusters.workerpool.volumes.add': '添加存储卷' + 'clusters.workerpool.volumes.add': '添加存储卷', + 'clusters.create.provider.self': '自建环境', + 'clusters.create.provider.cloud': '云环境', + 'clusters.create.selectProvider': '选择环境', + 'clusters.create.configBasic': '基本配置', + 'clusters.create.execCommand': '执行命令', + 'clusters.create.supportedGpu': '支持的 GPU', + 'clusters.create.skipfornow': '暂时跳过' }; diff --git a/src/pages/cluster-management/apis/index.ts b/src/pages/cluster-management/apis/index.ts index aefbe552..bf446425 100644 --- a/src/pages/cluster-management/apis/index.ts +++ b/src/pages/cluster-management/apis/index.ts @@ -17,8 +17,57 @@ export const WORKER_POOLS_API = '/worker-pools'; export const CLUSTER_TOKEN = 'registration-token'; +export const PROVIDER_PROXY_API = '/provider-proxy'; + +// ============= DigitalOcean start ===================== + +export const REGIONS_API = '/v2/regions'; + +export const INSTANCE_TYPE = '/v2/sizes'; + +export const OS_IMAGE = '/v2/images'; + +// ============= DigitalOcean end ======================= + // ===================== Credentials ===================== +export async function queryDigitalOceanRegions(params: { id: number }) { + return request( + `${CREDENTIALS_API}/${params.id}${PROVIDER_PROXY_API}${REGIONS_API}`, + { + method: 'GET', + params: { + per_page: 200 + } + } + ); +} + +export async function queryDigitalOceanInstanceTypes(params: { id: number }) { + return request( + `${CREDENTIALS_API}/${params.id}${PROVIDER_PROXY_API}${INSTANCE_TYPE}`, + { + method: 'GET', + params: { + per_page: 200 + } + } + ); +} + +export async function queryDigitalOceanOSImages(params: { id: number }) { + return request( + `${CREDENTIALS_API}/${params.id}${PROVIDER_PROXY_API}${OS_IMAGE}`, + { + method: 'GET', + params: { + per_page: 200, + type: 'distribution' + } + } + ); +} + export async function queryCredentialList(params: Global.SearchParams) { return request>( `${CREDENTIALS_API}`, diff --git a/src/pages/cluster-management/cluster-create.tsx b/src/pages/cluster-management/cluster-create.tsx index b7c9ad1f..0c99bcb0 100644 --- a/src/pages/cluster-management/cluster-create.tsx +++ b/src/pages/cluster-management/cluster-create.tsx @@ -9,7 +9,8 @@ import { createCluster, queryClusterToken, queryCredentialList } from './apis'; import ClusterSteps from './components/cluster-steps'; import FooterButtons from './components/footer-buttons'; import ProviderCatalog from './components/provider-catalog'; -import { providerList, ProviderType, ProviderValueMap } from './config'; +import { ProviderType, ProviderValueMap } from './config'; +import providerList from './config/providers'; import { ClusterFormData } from './config/types'; import { moduleMap, moduleRegistry } from './step-forms/module-registry'; import useStepList from './step-forms/use-step-list'; @@ -63,7 +64,7 @@ const ClusterCreate = () => { (searchParams.get('action') as PageActionType) || PageAction.CREATE; const navigate = useNavigate(); const [credentialList, setCredentialList] = useState< - Global.BaseOption[] + Global.BaseOption[] >([]); const [currentStep, setCurrentStep] = useState(startStep); const [registrationInfo, setRegistrationInfo] = useState<{ @@ -87,6 +88,11 @@ const ClusterCreate = () => { [moduleMap.WorkerPoolForm]: useRef(null) }; + const availableCredentials = useMemo( + () => credentialList.filter((item) => item.provider === extraData.provider), + [credentialList, extraData.provider] + ); + const steps = useMemo(() => { if (!extraData.provider) { return stepList.filter((step) => step.defaultShow); @@ -182,6 +188,10 @@ const ClusterCreate = () => { }; const handleSelectProvider = (value: ProviderType) => { + if (value === extraData.provider) { + onNext(); + return; + } setExtraData({ provider: value } as ClusterFormData); @@ -194,7 +204,8 @@ const ClusterCreate = () => { const res = await queryCredentialList({ page: 1, perPage: 100 }); const list = res.items?.map((item) => ({ label: item.name, - value: item.id + value: item.id, + provider: item.provider })); setCredentialList(list); }; @@ -217,7 +228,7 @@ const ClusterCreate = () => { ref={formRefs[key]} action={action} provider={extraData.provider} - credentialList={credentialList} + credentialList={availableCredentials} currentData={formValues[key]} /> ) : null; @@ -279,27 +290,7 @@ const ClusterCreate = () => { /> ]} header={{ - title: ( -
- - -
- ), + title: false, style: { paddingInline: 'var(--layout-content-header-inlinepadding)' }, @@ -308,7 +299,9 @@ const ClusterCreate = () => { pageHeaderRender={() => ( { ); const setDisableExpand = (row: ClusterListItem) => { - return row.provider !== ProviderValueMap.DigitalOcean; + return ( + row.provider !== ProviderValueMap.DigitalOcean || + !allWorkerPoolList.some((item) => item.cluster_id === row.id) + ); }; useEffect(() => { diff --git a/src/pages/cluster-management/components/add-pool.tsx b/src/pages/cluster-management/components/add-pool.tsx index fd65b44a..17cc3e48 100644 --- a/src/pages/cluster-management/components/add-pool.tsx +++ b/src/pages/cluster-management/components/add-pool.tsx @@ -29,7 +29,7 @@ const AddPool: React.FC = ({ }) => { const formRef = useRef(null); - const handleSubmit = () => { + const handleSubmit = async () => { formRef.current?.submit?.(); }; diff --git a/src/pages/cluster-management/components/add-worker-step.tsx b/src/pages/cluster-management/components/add-worker-step.tsx index 0a99776d..28373eca 100644 --- a/src/pages/cluster-management/components/add-worker-step.tsx +++ b/src/pages/cluster-management/components/add-worker-step.tsx @@ -1,3 +1,4 @@ +import { useIntl } from '@umijs/max'; import React from 'react'; import styled from 'styled-components'; import RegisterClusterInner from './register-cluster-inner'; @@ -18,11 +19,14 @@ type AddModalProps = { }; }; const AddWorkerStep: React.FC = ({ registrationInfo }) => { + const intl = useIntl(); return (
- Execute Command + {intl.formatMessage({ id: 'clusters.create.execCommand' })} - Supported GPUs + + {intl.formatMessage({ id: 'clusters.create.supportedGpu' })} +
); diff --git a/src/pages/cluster-management/components/cloud-provider-form.tsx b/src/pages/cluster-management/components/cloud-provider-form.tsx index db041ae9..4eb6de29 100644 --- a/src/pages/cluster-management/components/cloud-provider-form.tsx +++ b/src/pages/cluster-management/components/cloud-provider-form.tsx @@ -1,12 +1,15 @@ import SealSelect from '@/components/seal-form/seal-select'; +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; import useAppUtils from '@/hooks/use-app-utils'; +import { LoadingOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; import { Form } from 'antd'; import _ from 'lodash'; -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; -import { regionList } from '../config'; import { ClusterFormData as FormData } from '../config/types'; +import { useProviderRegions } from '../hooks/use-provider-regions'; type OptionData = { label: string; @@ -37,11 +40,31 @@ const OptionItem = styled.div` } `; +const NoContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding-block: 12px; +`; + interface CloudProviderProps { provider: string; // 'kubernetes' | 'digitalocean'; credentialList: Global.BaseOption[]; + action: PageActionType; + credentialID?: number; } +const NotFoundContent: React.FC<{ loading: boolean }> = ({ loading }) => { + if (loading) { + return ( + + + + ); + } + return No regions available; +}; + const optionRender = ( option: Global.BaseOption< number, @@ -64,27 +87,66 @@ const optionRender = ( ); }; -const labelRender = (props: { - label: string; - value: string; -}): React.ReactNode => { - const data = regionList.find((item) => item.value === props.value); - return ( - - {data?.icon} - {data?.label} - {data?.datacenter}{' '} - - {_.toUpper(data?.value)} - - ); -}; - const CloudProvider: React.FC = (props) => { - const { credentialList } = props; + const { credentialList, action, credentialID } = props; const intl = useIntl(); + + const { + getRegions, + getOSImages, + updateOSImages, + updateInstanceTypes, + getInstanceTypes, + setLoading, + loading, + regions + } = useProviderRegions(); + const { getRuleMessage } = useAppUtils(); + const handleCredentialChange = async (value: number) => { + setLoading(true); + await Promise.all([getOSImages(value), getInstanceTypes(value)]); + await getRegions(value); + }; + + const handleRegionChange = (value: string) => { + updateInstanceTypes(value); + updateOSImages(value); + }; + + useEffect(() => { + if (credentialID && action !== PageAction.CREATE) { + getRegions(credentialID); + } + }, [credentialID]); + + const labelRender = (props: { + label: string; + value: string; + }): React.ReactNode => { + const data = regions.find((item) => item.value === props.value); + if (!data) return props.label; + return ( + + {data?.icon} + {data?.label}{' '} + + {data?.datacenter}{' '} + + {_.toUpper(data?.value)} + + ); + }; + + const filterRegionOption = (inputValue: string, option: any) => { + return ( + option.label.toLowerCase().includes(inputValue.toLowerCase()) || + option.datacenter.toLowerCase().includes(inputValue.toLowerCase()) || + option.value.toLowerCase().includes(inputValue.toLowerCase()) + ); + }; + return ( <> @@ -100,6 +162,7 @@ const CloudProvider: React.FC = (props) => { label={intl.formatMessage({ id: 'clusters.credential.title' })} required options={credentialList} + onChange={handleCredentialChange} > @@ -112,11 +175,16 @@ const CloudProvider: React.FC = (props) => { ]} > } > diff --git a/src/pages/cluster-management/components/cluster-form.tsx b/src/pages/cluster-management/components/cluster-form.tsx index ce0216b9..35b10f67 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -78,6 +78,8 @@ const ClusterForm: React.FC = forwardRef( {provider === ProviderValueMap.DigitalOcean && ( )} diff --git a/src/pages/cluster-management/components/footer-buttons.tsx b/src/pages/cluster-management/components/footer-buttons.tsx index 814a1b0d..cb18ff60 100644 --- a/src/pages/cluster-management/components/footer-buttons.tsx +++ b/src/pages/cluster-management/components/footer-buttons.tsx @@ -32,18 +32,18 @@ const FooterButtons: React.FC = (props) => {
{showButtons.previous && ( )} {showButtons.next && ( )} {showButtons.skip && ( )} {showButtons.save && ( diff --git a/src/pages/cluster-management/components/instance-type-option.tsx b/src/pages/cluster-management/components/instance-type-option.tsx new file mode 100644 index 00000000..31c8554d --- /dev/null +++ b/src/pages/cluster-management/components/instance-type-option.tsx @@ -0,0 +1,22 @@ +import { CardContainer } from '@/pages/llmodels/components/gpu-card'; +import React from 'react'; + +const InstanceTypeOption: React.FC<{ + data: any; + header?: React.ReactNode; + info?: React.ReactNode; +}> = ({ data, header, info }) => { + console.log('InstanceTypeOption render', { + data, + header, + info + }); + return ( + {data.label}} + description={{data.description}} + > + ); +}; + +export default InstanceTypeOption; diff --git a/src/pages/cluster-management/components/pool-form.tsx b/src/pages/cluster-management/components/pool-form.tsx index ff5a4872..850e2231 100644 --- a/src/pages/cluster-management/components/pool-form.tsx +++ b/src/pages/cluster-management/components/pool-form.tsx @@ -1,19 +1,32 @@ +import { + regionInstanceTypeListAtom, + regionOSImageListAtom +} from '@/atoms/clusters'; import CollapsibleContainer, { CollapsibleContainerProps } from '@/components/collapse-container'; +import IconFont from '@/components/icon-font'; import LabelSelector from '@/components/label-selector'; import SealInputNumber from '@/components/seal-form/input-number'; import SealInput from '@/components/seal-form/seal-input'; +import SealSelect from '@/components/seal-form/seal-select'; import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import useAppUtils from '@/hooks/use-app-utils'; +import { CardContainer } from '@/pages/llmodels/components/gpu-card'; import { DeleteOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; import { Button, Form } from 'antd'; +import { useAtom } from 'jotai'; import _ from 'lodash'; -import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react'; import styled from 'styled-components'; -import { ProviderType } from '../config'; +import { ProviderType, instanceTypeFieldMap, vendorIconMap } from '../config'; import { NodePoolFormData as FormData } from '../config/types'; import VolumesConfig from './volumes-config'; @@ -22,6 +35,12 @@ const Container = styled.div` display: grid; grid-template-columns: 1fr 1fr; grid-gap: 0 16px; + .ant-form-item:nth-child(1) { + grid-column: 1 / 3; + } + .ant-form-item:nth-child(2) { + grid-column: 1 / 3; + } .ant-form-item:nth-child(5) { grid-column: 1 / 3; } @@ -34,6 +53,65 @@ const Container = styled.div` } `; +const NoContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding-block: 12px; +`; + +const OptionItem = styled.div.attrs({ + className: 'option-item' +})` + display: flex; + gap: 8px; + align-items: center; +`; + +const DescriptionWrapper = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + font-weight: 400; +`; + +const NotFoundContent = () => { + return No instance type available; +}; + +export const RenderInstanceOption = (option: any) => { + const { data, styles } = option; + + return ( + + + {data.description} + + } + description={ + + {Object.entries(data?.specInfo) + .filter(([key, value]) => value) + .map(([key, value]) => ( + + + {_.get(instanceTypeFieldMap, key, key)}: + + {value as string} + + ))} + + } + /> + ); +}; + type AddModalProps = { ref: any; name?: string; @@ -45,6 +123,23 @@ type AddModalProps = { showDelete?: boolean; collapseProps?: CollapsibleContainerProps; }; + +const InstanceSpecData: React.FC<{ instanceSpec: Record }> = ({ + instanceSpec +}) => { + return ( + <> + {Object.entries(instanceSpec) + .filter(([key, value]) => value) + .map(([key, value]) => ( + + ))} + + ); +}; + const PoolForm: React.FC = forwardRef((props, ref) => { const { action, @@ -55,22 +150,71 @@ const PoolForm: React.FC = forwardRef((props, ref) => { currentData, collapseProps } = props; + const [instanceTypeList] = useAtom(regionInstanceTypeListAtom); + const [osImageList] = useAtom(regionOSImageListAtom); const { collapsible, onToggle, ...restCollapseProps } = collapseProps || {}; const [form] = Form.useForm(); const intl = useIntl(); const { getRuleMessage } = useAppUtils(); const labels = Form.useWatch('labels', form); const title = Form.useWatch('name', form); + const [instanceSpec, setInstanceSpec] = useState>({}); useEffect(() => { if (currentData) { - console.log('currentData===========1=', currentData); form.setFieldsValue({ ...currentData }); + setInstanceSpec({ + ...currentData.instance_spec + }); } }, [currentData]); + const labelRender = (data: { label: string; value: string }) => { + if (action === PageAction.EDIT) { + return currentData?.image_name || currentData?.os_image; + } + return data.label; + }; + + const instanceLabelRender = (data: { label: string; value: string }) => { + if (action === PageAction.EDIT) { + return currentData?.instance_spec?.label || currentData?.instance_type; + } + return data.label; + }; + + const handleOsImageChange = (value: string) => { + form.setFieldsValue({ + image_name: osImageList.find((item) => item.value === value)?.label + }); + }; + + const handleInstanceTypeChange = (value: string, option: any) => { + setInstanceSpec({ + ...option.specInfo, + label: option.label, + vendor: option.vendor, + description: option.description + }); + form.setFieldsValue({ + instance_spec: { + ...option.specInfo, + label: option.label, + vendor: option.vendor, + description: option.description + } + }); + }; + + const filterInstanceOption = (inputValue: string, option: any) => { + return ( + option.label.toLowerCase().includes(inputValue.toLowerCase()) || + option.description.toLowerCase().includes(inputValue.toLowerCase()) + ); + }; + useImperativeHandle(ref, () => ({ resetFields: () => { form.resetFields(); @@ -149,13 +293,20 @@ const PoolForm: React.FC = forwardRef((props, ref) => { } ]} > - } label={intl.formatMessage({ id: 'clusters.workerpool.instanceType' })} required + options={instanceTypeList} disabled={action === PageAction.EDIT} - > + optionRender={RenderInstanceOption} + onChange={handleInstanceTypeChange} + > name="replicas" @@ -192,6 +343,7 @@ const PoolForm: React.FC = forwardRef((props, ref) => { required > + name="os_image" rules={[ @@ -201,13 +353,18 @@ const PoolForm: React.FC = forwardRef((props, ref) => { } ]} > - + > @@ -235,12 +392,17 @@ const PoolForm: React.FC = forwardRef((props, ref) => { ]} > - + + name="image_name" hidden> + + + diff --git a/src/pages/cluster-management/components/pool-rows.tsx b/src/pages/cluster-management/components/pool-rows.tsx index 6ba5fd46..3cd44ecf 100644 --- a/src/pages/cluster-management/components/pool-rows.tsx +++ b/src/pages/cluster-management/components/pool-rows.tsx @@ -7,6 +7,7 @@ import { PageActionType } from '@/config/types'; import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import { Col, message, Row } from 'antd'; +import _ from 'lodash'; import React, { useRef, useState } from 'react'; import { deleteWorkerPool, updateWorkerPool } from '../apis'; import { ProviderType } from '../config'; @@ -78,7 +79,7 @@ const PoolRows: React.FC = ({ action: PageAction.EDIT, title: intl.formatMessage( { id: 'common.button.edit.item' }, - { name: record.instance_type } + { name: record.name } ), provider: provider, currentData: record, @@ -125,20 +126,14 @@ const PoolRows: React.FC = ({ {columns.map((col: Record) => { return ( - {/* {col.render - ? col.render(data[col.dataIndex as string], data) - : data[col.dataIndex as string]} */} - + ); })} diff --git a/src/pages/cluster-management/components/provider-catalog.tsx b/src/pages/cluster-management/components/provider-catalog.tsx index 4556af26..444b6b40 100644 --- a/src/pages/cluster-management/components/provider-catalog.tsx +++ b/src/pages/cluster-management/components/provider-catalog.tsx @@ -63,7 +63,9 @@ const ProviderCatalog: React.FC = ({ {Object.entries(groupList).map(([groupName, items]) => (
- {groupName !== 'default' && {groupName}} + {groupName !== 'default' && ( + {intl.formatMessage({ id: groupName })} + )} {items?.map((action) => ( = forwardRef((props, ref) => { + const { disabled } = props; const intl = useIntl(); const form = Form.useFormInstance(); const volumes = Form.useWatch(['cloud_options', volumeOptions.name], form); @@ -25,6 +27,7 @@ const CloudOptions: React.FC<{ label={intl.formatMessage({ id: 'clusters.workerpool.volumes' })} dataList={volumes || []} properties={volumeOptions.properties || {}} + disabled={disabled} onChange={(value) => handleOnChange(volumeOptions.name, value)} /> diff --git a/src/pages/cluster-management/components/worker-pools.tsx b/src/pages/cluster-management/components/worker-pools.tsx index 82ab7e2f..73869553 100644 --- a/src/pages/cluster-management/components/worker-pools.tsx +++ b/src/pages/cluster-management/components/worker-pools.tsx @@ -73,7 +73,7 @@ const WorkerPools = () => { action: PageAction.EDIT, title: intl.formatMessage( { id: 'common.button.edit.item' }, - { name: record.instance_type } + { name: record.name } ), provider: searchParams.get('provider') as ProviderType, currentData: record, diff --git a/src/pages/cluster-management/config/index.ts b/src/pages/cluster-management/config/index.ts index dbd560c1..544216fa 100644 --- a/src/pages/cluster-management/config/index.ts +++ b/src/pages/cluster-management/config/index.ts @@ -47,6 +47,27 @@ export const generateRegisterCommand = (params: { --header 'Authorization: Bearer ${params.registrationToken}' | kubectl apply -f -`; }; +export const instanceTypeFieldMap = { + vram: 'VRAM', + vcpus: 'vCPUs', + ram: 'RAM', + bootDisk: 'Boot Disk', + scratchDisk: 'Scratch Disk', + minDiskSize: 'Min Disk Size', + size: 'Size' +}; + +export const vendorIconMap = { + amd: 'icon-amd', + nvidia: 'icon-nvidia1', + rockyLinux: 'icon-rocky-linux', + almaLinux: 'icon-alma-linux', + ubuntu: 'icon-ubuntu', + centOs: 'icon-centos', + debian: 'icon-debian', + fedora: 'icon-fedora' +}; + export const providerList = [ { label: 'clusters.provider.custom', diff --git a/src/pages/cluster-management/config/providers.ts b/src/pages/cluster-management/config/providers.ts new file mode 100644 index 00000000..3d28c36c --- /dev/null +++ b/src/pages/cluster-management/config/providers.ts @@ -0,0 +1,68 @@ +import icons from '@/components/icon-font/icons'; +import React from 'react'; +import { ProviderValueMap } from '.'; + +export default [ + { + label: 'clusters.provider.custom', + locale: true, + value: ProviderValueMap.Custom, + key: ProviderValueMap.Custom, + icon: React.cloneElement(icons.Docker, { + style: { color: 'var(--ant-color-primary)' } + }), + group: 'clusters.create.provider.self' + }, + { + label: 'Kubernetes', + locale: false, + value: ProviderValueMap.Kubernetes, + key: ProviderValueMap.Kubernetes, + icon: React.cloneElement(icons.KubernetesOutlined, { + style: { color: 'var(--ant-color-primary)' } + }), + group: 'clusters.create.provider.self' + }, + { + label: 'DigitalOcean', + locale: false, + value: ProviderValueMap.DigitalOcean, + key: ProviderValueMap.DigitalOcean, + icon: React.cloneElement(icons.DigitalOcean, { + style: { + color: 'var(--ant-color-primary)' + } + }), + group: 'clusters.create.provider.cloud' + }, + { + label: 'Huawei Cloud', + locale: false, + disabled: true, + value: ProviderValueMap.HuaweiCloud, + key: ProviderValueMap.HuaweiCloud, + icon: icons.HuaweiCloud, + description: 'Comming soon', + group: 'clusters.create.provider.cloud' + }, + { + label: 'Ali Cloud', + locale: false, + disabled: true, + value: ProviderValueMap.AliCloud, + key: ProviderValueMap.AliCloud, + icon: icons.AliCloud, + description: 'Comming soon', + group: 'clusters.create.provider.cloud' + }, + { + label: 'Tencent Cloud', + locale: false, + disabled: true, + value: ProviderValueMap.TencentCloud, + key: ProviderValueMap.TencentCloud, + icon: icons.TencentCloud, + description: 'Comming soon', + group: 'clusters.create.provider.cloud' + } +]; diff --git a/src/pages/cluster-management/config/region-icons.ts b/src/pages/cluster-management/config/region-icons.ts new file mode 100644 index 00000000..922dd876 --- /dev/null +++ b/src/pages/cluster-management/config/region-icons.ts @@ -0,0 +1,17 @@ +export const RegionIcons: Record = { + nyc1: '🇺🇸', + nyc2: '🇺🇸', + nyc3: '🇺🇸', + tor1: '🇨🇦', + sfo1: '🇺🇸', + sfo2: '🇺🇸', + sfo3: '🇺🇸', + atl1: '🇺🇸', + sgp1: '🇸🇬', + blr1: '🇮🇳', + lon1: '🇬🇧', + ams2: '🇳🇱', + ams3: '🇳🇱', + fra1: '🇩🇪', + syd1: '🇦🇺' +}; diff --git a/src/pages/cluster-management/config/types.ts b/src/pages/cluster-management/config/types.ts index 4dadeb5a..7a183443 100644 --- a/src/pages/cluster-management/config/types.ts +++ b/src/pages/cluster-management/config/types.ts @@ -14,7 +14,7 @@ export type ClusterStatusType = 0 | 1 | 3; export interface CredentialListItem { id: number; name: string; - provider: string; + provider: ProviderType; access_key: string; secret_key: string; description?: string; @@ -26,10 +26,12 @@ export interface NodePoolFormData { name: string; instance_type: string; os_image: string; + image_name: string; replicas: number; batch_size: number; labels: Record; cloud_options: Record; + instance_spec: Record; } export interface NodePoolListItem extends NodePoolFormData { diff --git a/src/pages/cluster-management/hooks/use-cluster-columns.tsx b/src/pages/cluster-management/hooks/use-cluster-columns.tsx index 1d66f5c9..0c6c2fe1 100644 --- a/src/pages/cluster-management/hooks/use-cluster-columns.tsx +++ b/src/pages/cluster-management/hooks/use-cluster-columns.tsx @@ -25,7 +25,7 @@ const setActionsItems = (row: ClusterListItem) => { const useClusterColumns = ( handleSelect: (val: string, record: ClusterListItem) => void -): ColumnsType => { +): ColumnsType => { const intl = useIntl(); return useMemo(() => { @@ -47,13 +47,35 @@ const useClusterColumns = ( { title: intl.formatMessage({ id: 'clusters.table.provider' }), dataIndex: 'provider', - span: 3, + span: 4, render: (value: string) => ( {ProviderLabelMap[value]} ) }, + { + title: 'GPUs', + dataIndex: 'gpus', + span: 2, + render: (value: number) => {value} + }, + { + title: intl.formatMessage({ id: 'clusters.table.deployments' }), + dataIndex: 'models', + span: 2, + render: (value: number) => {value} + }, + { + title: 'Workers', + dataIndex: 'workers', + span: 3, + render: (value: number, record: ClusterListItem) => ( + + {record.ready_workers} / {record.workers} + + ) + }, { title: intl.formatMessage({ id: 'common.table.status' }), dataIndex: 'state', @@ -67,28 +89,6 @@ const useClusterColumns = ( /> ) }, - { - title: 'Workers', - dataIndex: 'workers', - span: 3, - render: (value: number, record: ClusterListItem) => ( - - {record.ready_workers} / {record.workers} - - ) - }, - { - title: 'GPUs', - dataIndex: 'gpus', - span: 3, - render: (value: number) => {value} - }, - { - title: intl.formatMessage({ id: 'clusters.table.deployments' }), - dataIndex: 'models', - span: 2, - render: (value: number) => {value} - }, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: 'created_at', diff --git a/src/pages/cluster-management/hooks/use-pools-columns.tsx b/src/pages/cluster-management/hooks/use-pools-columns.tsx index c64dd387..234275f8 100644 --- a/src/pages/cluster-management/hooks/use-pools-columns.tsx +++ b/src/pages/cluster-management/hooks/use-pools-columns.tsx @@ -5,7 +5,9 @@ import { useIntl } from '@umijs/max'; import { ColumnsType } from 'antd/es/table'; import type { SortOrder } from 'antd/es/table/interface'; import dayjs from 'dayjs'; +import _ from 'lodash'; import { useMemo } from 'react'; +import { RenderInstanceOption } from '../components/pool-form'; import { NodePoolListItem as ListItem } from '../config/types'; const actionItems = [ @@ -24,10 +26,11 @@ const actionItems = [ } } ]; + const usePoolsColumns = ( handleSelect: (val: string, record: ListItem) => void, sortOrder?: SortOrder -): ColumnsType => { +): ColumnsType => { const intl = useIntl(); return useMemo(() => { @@ -39,7 +42,7 @@ const usePoolsColumns = ( ellipsis: { showTitle: false }, - span: 4, + span: 3, style: { paddingInline: 'var(--ant-table-cell-padding-inline)' }, @@ -56,29 +59,64 @@ const usePoolsColumns = ( ellipsis: { showTitle: false }, - span: 3, + span: 4, style: { - paddingLeft: 12 + paddingLeft: 62 }, - render: (text: string) => ( - - {text} + render: (text: string, record: ListItem) => ( + + } + showTitle + ghost + minWidth={20} + > + {record.instance_spec.description || record.instance_spec.label} ) }, { title: intl.formatMessage({ id: 'clusters.workerpool.osImage' }), - dataIndex: 'os_image', - key: 'os_image', - span: 3, + dataIndex: 'image_name', + key: 'image_name', + span: 4, ellipsis: { showTitle: false }, style: { - paddingLeft: 16 + paddingLeft: 56 }, render: (text: string) => ( - + + + {intl.formatMessage({ id: 'clusters.workerpool.osImage' })} + :{' '} + + {text} + + } + showTitle + ghost + minWidth={20} + > {text} ) @@ -86,11 +124,10 @@ const usePoolsColumns = ( { title: 'Workers', dataIndex: 'replicas', - span: 3, + span: 6, key: 'replicas', style: { - // textAlign: 'center' - paddingLeft: 4 + paddingLeft: 50 }, editable: { valueType: 'number', @@ -102,12 +139,13 @@ const usePoolsColumns = ( ) }, - { - title: intl.formatMessage({ id: 'clusters.workerpool.batchSize' }), - dataIndex: 'batch_size', - key: 'batch_size', - span: 4 - }, + + // { + // title: intl.formatMessage({ id: 'clusters.workerpool.batchSize' }), + // dataIndex: 'batch_size', + // key: 'batch_size', + // span: 4 + // }, // { // title: intl.formatMessage({ id: 'resources.table.labels' }), // dataIndex: 'labels', diff --git a/src/pages/cluster-management/hooks/use-provider-regions.ts b/src/pages/cluster-management/hooks/use-provider-regions.ts new file mode 100644 index 00000000..5b815af8 --- /dev/null +++ b/src/pages/cluster-management/hooks/use-provider-regions.ts @@ -0,0 +1,183 @@ +import { + allRegionInstanceTypeListAtom, + allRegionOSImageListAtom, + regionInstanceTypeListAtom, + regionListAtom, + regionOSImageListAtom +} from '@/atoms/clusters'; +import { convertFileSizeByUnit } from '@/utils'; +import { useAtom } from 'jotai'; +import _ from 'lodash'; +import { useState } from 'react'; +import { + queryDigitalOceanInstanceTypes, + queryDigitalOceanOSImages, + queryDigitalOceanRegions +} from '../apis'; +import { RegionIcons } from '../config/region-icons'; + +const parseCityDatacenter = ( + input: string +): { label: string; datacenter: string } => { + const parts = input.trim().split(' '); + const number = parts.pop(); + const city = parts.join(' '); + return { + label: city, + datacenter: `Datacenter ${number}` + }; +}; + +type ParsedSpec = { + vram: string | number; + vcpus: number; + ram: string | number; + bootDisk: string | number; + scratchDisk: string | number; +}; + +export const parseSpec = (obj: any): ParsedSpec => { + const gpuInfo = obj.gpu_info; + const diskInfo = obj.disk_info; + return { + vram: convertFileSizeByUnit({ + sizeInBytes: gpuInfo?.vram?.amount || 0, + defaultUnit: 'GiB' + }), + vcpus: obj.vcpus || 0, + ram: convertFileSizeByUnit({ + sizeInBytes: obj.memory || 0, + defaultUnit: 'MiB' + }), + bootDisk: convertFileSizeByUnit({ + sizeInBytes: + diskInfo?.find((d: any) => d.type === 'local')?.size.amount || 0, + defaultUnit: 'GiB' + }), + scratchDisk: convertFileSizeByUnit({ + sizeInBytes: + diskInfo?.find((d: any) => d.type === 'scratch')?.size.amount || 0, + defaultUnit: 'GiB' + }) + }; +}; + +const formatSpec = (spec: ParsedSpec): string => { + const parts: string[] = []; + + if (spec.vram) parts.push(`${spec.vram} VRAM`); + if (spec.vcpus) parts.push(`${spec.vcpus} vCPUs`); + if (spec.bootDisk) parts.push(`${spec.bootDisk} Boot disk`); + if (spec.ram) parts.push(`${spec.ram} RAM`); + if (spec.scratchDisk) { + parts.push(`${spec.scratchDisk} Scratch disk`); + } + + return parts.join(' / '); +}; + +export const useProviderRegions = () => { + const [regions, setRegions] = useAtom(regionListAtom); + const [, setInstanceTypes] = useAtom(regionInstanceTypeListAtom); + const [, setOSImageList] = useAtom(regionOSImageListAtom); + const [allOSImageList, setAllOSImageList] = useAtom(allRegionOSImageListAtom); + const [allInstanceTypes, setAllInstanceTypes] = useAtom( + allRegionInstanceTypeListAtom + ); + const [loading, setLoading] = useState(false); + + const getRegions = async (credential: number) => { + try { + setLoading(true); + const res = await queryDigitalOceanRegions({ id: credential }); + const list = res?.regions + ?.filter?.( + (sItem: any) => + sItem.sizes.some((size: string) => size.includes('gpu')) && + sItem.available + ) + .map((item: any) => { + return { + ...parseCityDatacenter(item.name), + value: item.slug, + icon: RegionIcons[item.slug], + sizes: item.sizes || [] + }; + }); + setRegions(list); + } catch (error) { + setRegions([]); + } finally { + setLoading(false); + } + }; + + const getInstanceTypes = async (credential: number) => { + try { + const res = await queryDigitalOceanInstanceTypes({ id: credential }); + const list = res?.sizes + ?.filter((sItem: any) => sItem.gpu_info && sItem.available) + .map((item: any) => { + const specInfo = parseSpec(item); + return { + label: formatSpec(specInfo), + value: item.slug, + description: item.description, + specInfo: specInfo, + vendor: _.get(_.split(item.gpu_info?.model, '_'), 0), + available: item.available, + regions: item.regions || [] + }; + }); + setAllInstanceTypes(list); + } catch (error) { + setAllInstanceTypes([]); + } + }; + + const getOSImages = async (credential: number) => { + try { + const res = await queryDigitalOceanOSImages({ id: credential }); + const list = res.images + ?.filter((sItem: any) => sItem.status === 'available') + .map((item: any) => { + return { + label: item.description, + value: item.slug, + name: item.name, + description: item.description, + vendor: _.camelCase(item.distribution), + specInfo: { + size: `${item.size_gigabytes} GiB`, + minDiskSize: `${item.min_disk_size} GiB` + }, + regions: item.regions || [] + }; + }); + setAllOSImageList(list); + } catch (error) {} + }; + + const updateInstanceTypes = (region: string) => { + const sizes = allInstanceTypes.filter((item) => + item.regions.includes(region) + ); + setInstanceTypes(sizes); + }; + + const updateOSImages = (region: string) => { + const list = allOSImageList.filter((item) => item.regions.includes(region)); + setOSImageList(list); + }; + + return { + regions, + loading, + setLoading, + getRegions, + getInstanceTypes, + getOSImages, + updateOSImages, + updateInstanceTypes + }; +}; diff --git a/src/pages/cluster-management/step-forms/basic-form.tsx b/src/pages/cluster-management/step-forms/basic-form.tsx index 142b0f8d..0071e691 100644 --- a/src/pages/cluster-management/step-forms/basic-form.tsx +++ b/src/pages/cluster-management/step-forms/basic-form.tsx @@ -1,5 +1,6 @@ import PageTools from '@/components/page-tools'; import { PageActionType } from '@/config/types'; +import { useIntl } from '@umijs/max'; import { forwardRef, useImperativeHandle, useRef } from 'react'; import styled from 'styled-components'; import ClusterForm from '../components/cluster-form'; @@ -23,6 +24,7 @@ interface BasicFormProps { } const BasicForm = forwardRef((props: BasicFormProps, ref) => { + const intl = useIntl(); const { provider, credentialList, action, currentData } = props; const formRef = useRef(null); @@ -40,7 +42,11 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
Basic Configuration} + left={ + + {intl.formatMessage({ id: 'clusters.create.configBasic' })} + + } marginTop={0} > { @@ -14,7 +15,7 @@ export default function useStepList() { return useMemo( () => [ { - title: 'Select Cloud Provider', + title: intl.formatMessage({ id: 'clusters.create.selectProvider' }), content: '', showButtons: (provider?: ProviderType) => { return { @@ -30,7 +31,7 @@ export default function useStepList() { providers: [] }, { - title: 'Configure Cluster Settings', + title: intl.formatMessage({ id: 'clusters.create.configBasic' }), content: '', showButtons: (provider?: ProviderType) => { return { @@ -46,7 +47,7 @@ export default function useStepList() { providers: [] }, { - title: 'Add Worker Pools', + title: intl.formatMessage({ id: 'clusters.button.addNodePool' }), content: '', showButtons: (provider?: ProviderType) => { return { @@ -63,7 +64,7 @@ export default function useStepList() { beforeNext: handleBack }, { - title: 'Add Worker', + title: intl.formatMessage({ id: 'resources.button.create' }), content: '', showButtons: (provider?: ProviderType) => { return { @@ -79,7 +80,7 @@ export default function useStepList() { providers: [ProviderValueMap.Custom] }, { - title: 'Register Cluster', + title: intl.formatMessage({ id: 'clusters.button.register' }), content: '', showButtons: (provider?: ProviderType) => { return { @@ -95,6 +96,6 @@ export default function useStepList() { providers: [ProviderValueMap.Kubernetes] } ], - [handleBack] + [handleBack, intl] ); } diff --git a/src/pages/cluster-management/step-forms/worker-pools-form.tsx b/src/pages/cluster-management/step-forms/worker-pools-form.tsx index 28f8ed25..7e110f17 100644 --- a/src/pages/cluster-management/step-forms/worker-pools-form.tsx +++ b/src/pages/cluster-management/step-forms/worker-pools-form.tsx @@ -1,6 +1,7 @@ import PageTools from '@/components/page-tools'; import { PageActionType } from '@/config/types'; import { PlusOutlined } from '@ant-design/icons'; +import { useIntl } from '@umijs/max'; import { Button, FormInstance } from 'antd'; import { forwardRef, @@ -42,6 +43,7 @@ interface WorkerPoolsFormProps { } const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { + const intl = useIntl(); const { provider, action, currentData } = props; const countRef = useRef(0); const formRefs = useRef | null>>({}); @@ -73,10 +75,13 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { name: `Pool-${newId + 1}` } as NodePoolFormData) ); - setActiveKey((prev) => new Set([newId])); requestAnimationFrame(() => { - window.scrollTo(0, document.body.scrollHeight); + setActiveKey((prev) => new Set([newId])); + window.scrollTo({ + top: document.body.scrollHeight, + behavior: 'smooth' + }); }); }; @@ -95,6 +100,7 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { const gatherFormValues = (results: PromiseSettledResult[]) => { const resultList = results.map((result: PromiseSettledResult) => { + console.log('gatherFormValues========', results); if (result.status === 'fulfilled') { return result.value; } @@ -103,7 +109,7 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { } return {}; }); - console.log('gatherFormValues========', resultList); + return resultList; }; @@ -177,7 +183,9 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { marginTop={0} left={ - Worker Pools + + {intl.formatMessage({ id: 'clusters.workerpool.title' })} + } right={ @@ -187,7 +195,7 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { color="default" icon={} > - Add Pool + {intl.formatMessage({ id: 'clusters.button.addNodePool' })} } > diff --git a/src/pages/llmodels/components/gpu-card.tsx b/src/pages/llmodels/components/gpu-card.tsx index fb92e9a7..a602e85d 100644 --- a/src/pages/llmodels/components/gpu-card.tsx +++ b/src/pages/llmodels/components/gpu-card.tsx @@ -3,8 +3,44 @@ import { convertFileSize } from '@/utils'; import { useIntl } from '@umijs/max'; import _ from 'lodash'; import React from 'react'; +import styled from 'styled-components'; import '../style/gpu-card.less'; +const CardWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 100%; + border-bottom: 1px solid var(--ant-color-split); + padding-bottom: 10px; +`; + +const Header = styled.div` + width: 100%; + margin-bottom: 10px; +`; + +const Description = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + color: var(--ant-color-text-tertiary); +`; + +export const CardContainer: React.FC<{ + header?: React.ReactNode; + description?: React.ReactNode; +}> = ({ header, description }) => { + return ( + +
{header}
+ {description} +
+ ); +}; + const GPUCard: React.FC<{ data: any; header?: React.ReactNode; @@ -12,17 +48,17 @@ const GPUCard: React.FC<{ }> = ({ data, header, info }) => { const intl = useIntl(); return ( -
-
- {header ?? ( + [{data.index}] {data.label} - )} -
-
- {info ?? ( + ) + } + description={ + info || ( <> {intl.formatMessage({ id: 'resources.table.vram' })}( @@ -37,7 +73,8 @@ const GPUCard: React.FC<{ - {intl.formatMessage({ id: 'resources.table.gpuutilization' })}:{' '} + {intl.formatMessage({ id: 'resources.table.gpuutilization' })} + :{' '} {data?.memory?.used ? _.round(data?.memory?.utilization_rate || 0, 2) @@ -45,10 +82,10 @@ const GPUCard: React.FC<{ % - )} -
-
+ ) + } + > ); }; -export default React.memo(GPUCard); +export default GPUCard; diff --git a/src/pages/llmodels/components/performance.tsx b/src/pages/llmodels/components/performance.tsx index c9b4640b..6f46d6f3 100644 --- a/src/pages/llmodels/components/performance.tsx +++ b/src/pages/llmodels/components/performance.tsx @@ -147,7 +147,11 @@ const Performance: React.FC = () => { multiple={ form.getFieldValue('backend') !== backendOptionsMap.voxBox } - popupClassName="cascader-popup-wrapper gpu-selector" + classNames={{ + popup: { + root: 'cascader-popup-wrapper gpu-selector' + } + }} maxTagCount={1} label={intl.formatMessage({ id: 'models.form.gpuselector' })} options={gpuOptions} diff --git a/src/pages/llmodels/hooks/use-models-columns.tsx b/src/pages/llmodels/hooks/use-models-columns.tsx index 2a3d9066..38526718 100644 --- a/src/pages/llmodels/hooks/use-models-columns.tsx +++ b/src/pages/llmodels/hooks/use-models-columns.tsx @@ -45,7 +45,7 @@ const useModelsColumns = ({ ) }, { - title: 'Cluster', + title: intl.formatMessage({ id: 'clusters.title' }), dataIndex: 'cluster', key: 'cluster', span: 3, diff --git a/src/pages/resources/config/index.ts b/src/pages/resources/config/index.ts index 7567f1d3..ddbb03a2 100644 --- a/src/pages/resources/config/index.ts +++ b/src/pages/resources/config/index.ts @@ -6,7 +6,9 @@ export const WorkerStatusMap = { unreachable: 'unreachable', provisioning: 'provisioning', deleting: 'deleting', - error: 'error' + error: 'error', + pending: 'pending', + provisioned: 'provisioned' }; export const WorkerStatusMapValue = { @@ -24,7 +26,9 @@ export const status: any = { [WorkerStatusMap.unreachable]: StatusMaps.error, [WorkerStatusMap.provisioning]: StatusMaps.transitioning, [WorkerStatusMap.deleting]: StatusMaps.transitioning, - [WorkerStatusMap.error]: StatusMaps.error + [WorkerStatusMap.error]: StatusMaps.error, + [WorkerStatusMap.pending]: StatusMaps.transitioning, + [WorkerStatusMap.provisioned]: StatusMaps.transitioning }; export const addWorkerGuide: Record = { diff --git a/src/pages/resources/config/types.ts b/src/pages/resources/config/types.ts index 62c4f725..10f46340 100644 --- a/src/pages/resources/config/types.ts +++ b/src/pages/resources/config/types.ts @@ -67,7 +67,7 @@ export interface ListItem { cluster_id: number; state_message: string; ssh_key_id: string; - progress: string; + provision_progress: string; status: { cpu: { total: number; diff --git a/src/pages/resources/hooks/use-worker-columns.tsx b/src/pages/resources/hooks/use-worker-columns.tsx index 4c6d976a..c414f734 100644 --- a/src/pages/resources/hooks/use-worker-columns.tsx +++ b/src/pages/resources/hooks/use-worker-columns.tsx @@ -228,7 +228,7 @@ const useWorkerColumns = ({ render: (_, record) => ( { + const { sizeInBytes, allowEmpty = false, defaultUnit = 'B' } = params; + + if (!sizeInBytes) return allowEmpty ? '' : 0; + + const fmt = 1024; + + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const precs = [0, 1, 1, 2, 2]; // precision for each unit + let size = sizeInBytes; + let unitIndex = units.indexOf(defaultUnit); + + while (size >= fmt && unitIndex < units.length - 1) { + size /= fmt; + unitIndex++; + } + + return `${_.round(size, precs[unitIndex])} ${units[unitIndex]}`; +}; + export const platformCall = () => { const platform = navigator.userAgent; const isMac = () => {