fix: display instance type spec

This commit is contained in:
jialin
2025-09-16 11:26:17 +08:00
parent deeee70807
commit c467280170
54 changed files with 1058 additions and 204 deletions
+4
View File
@@ -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;
+40
View File
@@ -2,3 +2,43 @@ import { atom } from 'jotai';
// models expand keys: create, update , delete,
export const expandKeysAtom = atom<string[]>([]);
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[];
}[]
>([]);
+2 -2
View File
@@ -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'
}}
>
<div style={{ paddingTop: 8 }}>{children}</div>
+1 -1
View File
@@ -76,7 +76,7 @@ const DropdownButtons: React.FC<DropdownButtonsProps> = ({
disabled={disabled}
trigger={trigger}
type="primary"
dropdownRender={(menus: any) => {
popupRender={(menus: any) => {
return (
<DropdownWrapper>
{_.map(_.tail(items), (item: any) => {
@@ -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 ? (
<Component
{...{ label, required, placeholder, description, min, max }}
{...{
label,
required,
placeholder,
description,
min,
max
}}
disabled={disabled}
options={options || optionList}
value={value}
checked={checked}
@@ -24,16 +24,23 @@ interface ListMapProps {
label?: React.ReactNode;
btnText?: string;
properties: Record<string, any>;
disabled?: boolean;
onChange?: (data: any) => void;
}
interface ListItemProps {
schemaList: any[];
data: Record<string, any>;
disabled?: boolean;
onChange?: (data: any) => void;
}
const ListItem: React.FC<ListItemProps> = ({ schemaList, data, onChange }) => {
const ListItem: React.FC<ListItemProps> = ({
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<ListItemProps> = ({ schemaList, data, onChange }) => {
<FormWidget
widget={schema.type}
{...schema}
disabled={disabled}
key={schema.name}
value={data?.[schema.name]}
checked={data?.[schema.name]}
@@ -66,6 +74,7 @@ const ListMap: React.FC<ListMapProps> = ({
btnText,
properties = {},
minItems = 0,
disabled,
onChange
}) => {
const [items, setItems] = React.useState(dataList || []);
@@ -110,28 +119,36 @@ const ListMap: React.FC<ListMapProps> = ({
}, [dataList]);
return (
<Wrapper label={label} btnText={btnText} onAdd={handleOnAdd}>
<Wrapper
label={label}
btnText={btnText}
onAdd={handleOnAdd}
disabled={disabled}
>
{items.map((item, index) => (
<RowWrapper key={index}>
<WidgetBox>
<ListItem
schemaList={schemaList}
data={item}
disabled={disabled}
onChange={(value) => handleItemChange(index, value)}
/>
</WidgetBox>
<Button
size="small"
type="default"
shape="circle"
style={{
width: 24,
marginLeft: 10,
flex: 'none'
}}
icon={<MinusOutlined />}
onClick={() => handleDelete(index)}
/>
{!disabled && (
<Button
size="small"
type="default"
shape="circle"
style={{
width: 24,
marginLeft: 10,
flex: 'none'
}}
icon={<MinusOutlined />}
onClick={() => handleDelete(index)}
/>
)}
</RowWrapper>
))}
</Wrapper>
@@ -23,6 +23,7 @@ export interface FormWidgetProps {
title?: string;
required?: boolean;
placeholder?: string;
readOnly?: boolean;
options?: { label: string; value: string | number }[];
description?: string;
enum?: (string | number)[];
+1 -1
View File
@@ -2,7 +2,7 @@ import { createFromIconfontCN } from '@ant-design/icons';
// import './iconfont/iconfont.js';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_4613488_l0y0uhurkz8.js'
scriptUrl: '//at.alicdn.com/t/c/font_4613488_5jrvj6qs39.js'
});
export default IconFont;
+4 -1
View File
@@ -8,6 +8,7 @@ interface LabelSelectorProps {
label?: string;
btnText?: string;
description?: React.ReactNode;
disabled?: boolean;
onChange?: (labels: Record<string, any>) => void;
onBlur?: (e: any, type: string, index: number) => void;
onDelete?: (index: number) => void;
@@ -18,6 +19,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
onChange,
onBlur,
onDelete,
disabled,
label,
btnText,
description
@@ -81,6 +83,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
return (
<Inner
disabled={disabled}
label={label}
btnText={btnText}
description={
@@ -97,4 +100,4 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
);
};
export default React.memo(LabelSelector);
export default LabelSelector;
+4
View File
@@ -14,6 +14,7 @@ interface LabelSelectorProps {
onBlur?: (e: any, type: string, index: number) => void;
onDelete?: (index: number) => void;
description?: React.ReactNode;
disabled?: boolean;
}
const Inner: React.FC<LabelSelectorProps> = ({
@@ -24,6 +25,7 @@ const Inner: React.FC<LabelSelectorProps> = ({
onPaste,
onBlur,
onDelete,
disabled,
label,
btnText,
description
@@ -75,12 +77,14 @@ const Inner: React.FC<LabelSelectorProps> = ({
label={label}
description={description}
onAdd={handleAddLabel}
disabled={disabled}
btnText={btnText}
>
<>
{labelList?.map((item: any, index: number) => {
return (
<LabelItem
disabled={disabled}
key={index}
label={item}
seperator=":"
+16 -10
View File
@@ -15,8 +15,9 @@ interface LabelItemProps {
keyAddon?: React.ReactNode;
valueAddon?: React.ReactNode;
seperator?: string;
onDelete?: () => void;
labelList: { key: string; value: string }[];
disabled?: boolean;
onDelete?: () => void;
onChange?: (params: { key: string; value: string }) => void;
onPaste?: (e: any) => void;
onBlur?: (e: any, type: string) => void;
@@ -27,6 +28,7 @@ const LabelItem: React.FC<LabelItemProps> = ({
seperator,
keyAddon,
valueAddon,
disabled,
onChange,
onDelete,
onPaste,
@@ -82,6 +84,7 @@ const LabelItem: React.FC<LabelItemProps> = ({
title={intl.formatMessage({ id: 'resources.table.key.tips' })}
>
<SealInput.Input
disabled={disabled}
checkStatus="success"
label={intl.formatMessage({ id: 'common.input.key' })}
value={label.key}
@@ -96,6 +99,7 @@ const LabelItem: React.FC<LabelItemProps> = ({
<div className="label-value">
{valueAddon ?? (
<SealInput.Input
disabled={disabled}
checkStatus={label.value ? 'success' : ''}
label={intl.formatMessage({ id: 'common.input.value' })}
value={label.value}
@@ -104,15 +108,17 @@ const LabelItem: React.FC<LabelItemProps> = ({
></SealInput.Input>
)}
</div>
<Button
size="small"
className="btn"
type="default"
shape="circle"
onClick={onDelete}
>
<MinusOutlined />
</Button>
{!disabled && (
<Button
size="small"
className="btn"
type="default"
shape="circle"
onClick={onDelete}
>
<MinusOutlined />
</Button>
)}
</div>
);
};
+15 -11
View File
@@ -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<WrapperProps> = ({
labelExtra,
onAdd,
btnText,
disabled,
button
}) => {
const intl = useIntl();
@@ -59,17 +61,19 @@ const Wrapper: React.FC<WrapperProps> = ({
</span>
)}
{children}
<ButtonWrapper>
{button || (
<Button variant="filled" color="default" block onClick={onAdd}>
<PlusOutlined className="font-size-14" />
{btnText ||
intl.formatMessage({
id: 'common.button.addSelector'
})}
</Button>
)}
</ButtonWrapper>
{!disabled && (
<ButtonWrapper>
{button || (
<Button variant="filled" color="default" block onClick={onAdd}>
<PlusOutlined className="font-size-14" />
{btnText ||
intl.formatMessage({
id: 'common.button.addSelector'
})}
</Button>
)}
</ButtonWrapper>
)}
</Container>
);
};
+1 -1
View File
@@ -185,7 +185,7 @@ const SealCascader: React.FC<
onBlur={handleOnBlur}
onChange={handleChange}
notFoundContent={null}
onDropdownVisibleChange={handleDropdownVisibleChange}
onOpenChange={handleDropdownVisibleChange}
></Cascader>
</Wrapper>
</SelectWrapper>
+2 -1
View File
@@ -18,6 +18,7 @@ const SealSelect: React.FC<SelectProps & SealFormItemProps> = (props) => {
options,
allowNull,
isInFormItems = true,
notFoundContent = null,
...rest
} = props;
const intl = useIntl();
@@ -102,7 +103,7 @@ const SealSelect: React.FC<SelectProps & SealFormItemProps> = (props) => {
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onChange={handleChange}
notFoundContent={null}
notFoundContent={notFoundContent}
>
{children}
</Select>
+2 -2
View File
@@ -241,7 +241,7 @@ const SimpleSelect: React.FC<SelectProps> = (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<SelectProps> = (props) => {
onFocus={handleOnFocus}
onSearch={handleOnSearch}
filterOption={filterOption}
onDropdownVisibleChange={handleOnOpenChange}
onOpenChange={handleOnOpenChange}
></Select>
</div>
);
@@ -156,6 +156,7 @@ export const Label = styled.div.attrs<{
// inner
const Inner = styled.div`
width: 100%;
display: flex;
`;
const Extra = styled.div`
@@ -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 {
+2 -2
View File
@@ -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;
+1
View File
@@ -90,6 +90,7 @@ export default function useTableFetch<T>(
const updateHandler = (list: any) => {
_.each(list, (data: any) => {
console.log('list================:', list);
updateChunkedList(data);
});
};
+8 -1
View File
@@ -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'
};
+15 -1
View File
@@ -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 ==========
+17 -2
View File
@@ -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 ==========
+10 -3
View File
@@ -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': '暂时跳过'
};
@@ -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<Global.PageResponse<CredentialListItem>>(
`${CREDENTIALS_API}`,
+22 -27
View File
@@ -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<number>[]
Global.BaseOption<number, { provider: ProviderType }>[]
>([]);
const [currentStep, setCurrentStep] = useState<number>(startStep);
const [registrationInfo, setRegistrationInfo] = useState<{
@@ -87,6 +88,11 @@ const ClusterCreate = () => {
[moduleMap.WorkerPoolForm]: useRef<any>(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: (
<div>
<Nav>
<span className="level-1">Cluster</span>
<span
style={{
marginInline: 20,
color: 'var(--ant-color-split)'
}}
>
/
</span>
<span className="level-2">create</span>
</Nav>
<ClusterSteps
steps={steps}
currentStep={currentStep}
onChange={handleStepChange}
></ClusterSteps>
</div>
),
title: false,
style: {
paddingInline: 'var(--layout-content-header-inlinepadding)'
},
@@ -308,7 +299,9 @@ const ClusterCreate = () => {
pageHeaderRender={() => (
<HeaderContainer>
<Nav>
<span className="level-1">Cluster</span>
<span className="level-1">
{intl.formatMessage({ id: 'clusters.title' })}
</span>
<span
style={{
marginInline: 20,
@@ -317,7 +310,9 @@ const ClusterCreate = () => {
>
/
</span>
<span className="level-2">create</span>
<span className="level-2">
{intl.formatMessage({ id: 'common.button.create' })}
</span>
</Nav>
<ClusterSteps
steps={steps}
+4 -1
View File
@@ -307,7 +307,10 @@ const Credentials: React.FC = () => {
);
const setDisableExpand = (row: ClusterListItem) => {
return row.provider !== ProviderValueMap.DigitalOcean;
return (
row.provider !== ProviderValueMap.DigitalOcean ||
!allWorkerPoolList.some((item) => item.cluster_id === row.id)
);
};
useEffect(() => {
@@ -29,7 +29,7 @@ const AddPool: React.FC<AddModalProps> = ({
}) => {
const formRef = useRef<any>(null);
const handleSubmit = () => {
const handleSubmit = async () => {
formRef.current?.submit?.();
};
@@ -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<AddModalProps> = ({ registrationInfo }) => {
const intl = useIntl();
return (
<div>
<Title>Execute Command</Title>
<Title>{intl.formatMessage({ id: 'clusters.create.execCommand' })}</Title>
<RegisterClusterInner registrationInfo={registrationInfo} />
<Title style={{ marginTop: 32 }}>Supported GPUs</Title>
<Title style={{ marginTop: 32 }}>
{intl.formatMessage({ id: 'clusters.create.supportedGpu' })}
</Title>
<SupportedHardware />
</div>
);
@@ -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<number>[];
action: PageActionType;
credentialID?: number;
}
const NotFoundContent: React.FC<{ loading: boolean }> = ({ loading }) => {
if (loading) {
return (
<NoContent>
<LoadingOutlined />
</NoContent>
);
}
return <NoContent>No regions available</NoContent>;
};
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 (
<OptionItem className="flex-center">
<span className="icon">{data?.icon}</span>
<span className="label">{data?.label}</span> <span className="dot"></span>
<span className="datacenter">{data?.datacenter}</span>{' '}
<span className="dot"></span>
<span className="value">{_.toUpper(data?.value)}</span>
</OptionItem>
);
};
const CloudProvider: React.FC<CloudProviderProps> = (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 (
<OptionItem className="flex-center">
<span className="icon">{data?.icon}</span>
<span className="label">{data?.label}</span>{' '}
<span className="dot"></span>
<span className="datacenter">{data?.datacenter}</span>{' '}
<span className="dot"></span>
<span className="value">{_.toUpper(data?.value)}</span>
</OptionItem>
);
};
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 (
<>
<Form.Item<FormData>
@@ -100,6 +162,7 @@ const CloudProvider: React.FC<CloudProviderProps> = (props) => {
label={intl.formatMessage({ id: 'clusters.credential.title' })}
required
options={credentialList}
onChange={handleCredentialChange}
></SealSelect>
</Form.Item>
<Form.Item<FormData>
@@ -112,11 +175,16 @@ const CloudProvider: React.FC<CloudProviderProps> = (props) => {
]}
>
<SealSelect
showSearch
label={intl.formatMessage({ id: 'clusters.workerpool.region' })}
required
options={regionList}
options={regions}
loading={loading}
filterOption={filterRegionOption}
labelRender={labelRender}
optionRender={optionRender}
onChange={handleRegionChange}
notFoundContent={<NotFoundContent loading={loading} />}
></SealSelect>
</Form.Item>
</>
@@ -78,6 +78,8 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
{provider === ProviderValueMap.DigitalOcean && (
<CloudProvider
provider={provider}
action={action}
credentialID={currentData?.credential_id}
credentialList={credentialList}
></CloudProvider>
)}
@@ -32,18 +32,18 @@ const FooterButtons: React.FC<FooterButtonsProps> = (props) => {
<div className="flex-center gap-16">
{showButtons.previous && (
<Button type="link" icon={<LeftOutlined />} onClick={onPrevious}>
Previous
{intl.formatMessage({ id: 'common.button.prev' })}
</Button>
)}
{showButtons.next && (
<Button type="link" onClick={handleOnNext}>
Next
{intl.formatMessage({ id: 'common.button.next' })}
<RightOutlined />
</Button>
)}
{showButtons.skip && (
<Button type="primary" onClick={handleCancel}>
Skip for now
{intl.formatMessage({ id: 'clusters.create.skipfornow' })}
</Button>
)}
{showButtons.save && (
@@ -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 (
<CardContainer
header={<span>{data.label}</span>}
description={<span>{data.description}</span>}
></CardContainer>
);
};
export default InstanceTypeOption;
@@ -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 <NoContent>No instance type available</NoContent>;
};
export const RenderInstanceOption = (option: any) => {
const { data, styles } = option;
return (
<CardContainer
key={data.value}
header={
<span>
<IconFont
type={_.get(vendorIconMap, data.vendor, 'icon-gpu1')}
className="m-r-8"
></IconFont>
{data.description}
</span>
}
description={
<DescriptionWrapper style={{ ...(styles?.description || {}) }}>
{Object.entries(data?.specInfo)
.filter(([key, value]) => value)
.map(([key, value]) => (
<OptionItem key={key}>
<span className="label">
{_.get(instanceTypeFieldMap, key, key)}:
</span>
<span className="value">{value as string}</span>
</OptionItem>
))}
</DescriptionWrapper>
}
/>
);
};
type AddModalProps = {
ref: any;
name?: string;
@@ -45,6 +123,23 @@ type AddModalProps = {
showDelete?: boolean;
collapseProps?: CollapsibleContainerProps;
};
const InstanceSpecData: React.FC<{ instanceSpec: Record<string, any> }> = ({
instanceSpec
}) => {
return (
<>
{Object.entries(instanceSpec)
.filter(([key, value]) => value)
.map(([key, value]) => (
<Form.Item key={key} name={['instance_spec', key]} hidden>
<SealInput.Input />
</Form.Item>
))}
</>
);
};
const PoolForm: React.FC<AddModalProps> = forwardRef((props, ref) => {
const {
action,
@@ -55,22 +150,71 @@ const PoolForm: React.FC<AddModalProps> = 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<Record<string, any>>({});
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<AddModalProps> = forwardRef((props, ref) => {
}
]}
>
<SealInput.Input
<SealSelect
showSearch
filterOption={filterInstanceOption}
labelRender={instanceLabelRender}
notFoundContent={<NotFoundContent />}
label={intl.formatMessage({
id: 'clusters.workerpool.instanceType'
})}
required
options={instanceTypeList}
disabled={action === PageAction.EDIT}
></SealInput.Input>
optionRender={RenderInstanceOption}
onChange={handleInstanceTypeChange}
></SealSelect>
</Form.Item>
<Form.Item<FormData>
name="replicas"
@@ -192,6 +343,7 @@ const PoolForm: React.FC<AddModalProps> = forwardRef((props, ref) => {
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="os_image"
rules={[
@@ -201,13 +353,18 @@ const PoolForm: React.FC<AddModalProps> = forwardRef((props, ref) => {
}
]}
>
<SealInput.Input
<SealSelect
showSearch
onChange={handleOsImageChange}
optionRender={RenderInstanceOption}
labelRender={labelRender}
options={osImageList}
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'clusters.workerpool.osImage'
})}
required
></SealInput.Input>
></SealSelect>
</Form.Item>
<Form.Item<FormData>
@@ -235,12 +392,17 @@ const PoolForm: React.FC<AddModalProps> = forwardRef((props, ref) => {
]}
>
<LabelSelector
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'resources.table.labels' })}
labels={labels || {}}
btnText={intl.formatMessage({ id: 'common.button.addLabel' })}
></LabelSelector>
</Form.Item>
<VolumesConfig></VolumesConfig>
<VolumesConfig disabled={action === PageAction.EDIT}></VolumesConfig>
<Form.Item<FormData> name="image_name" hidden>
<SealInput.Input></SealInput.Input>
</Form.Item>
<InstanceSpecData instanceSpec={instanceSpec} />
</Container>
</Form>
</CollapsibleContainer>
@@ -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<PoolRowsProps> = ({
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<PoolRowsProps> = ({
{columns.map((col: Record<string, any>) => {
return (
<Col
key={col.dataIndex as string}
key={col.dataIndex || col.key}
span={col.span}
style={{
paddingInline: 0,
...(col.style || {})
}}
>
{/* {col.render
? col.render(data[col.dataIndex as string], data)
: data[col.dataIndex as string]} */}
<CellContent
{...col}
dataIndex={col.dataIndex}
></CellContent>
<CellContent {..._.omit(col, ['key'])}></CellContent>
</Col>
);
})}
@@ -63,7 +63,9 @@ const ProviderCatalog: React.FC<ProviderCatalogProps> = ({
<Container>
{Object.entries(groupList).map(([groupName, items]) => (
<div key={groupName}>
{groupName !== 'default' && <Title>{groupName}</Title>}
{groupName !== 'default' && (
<Title>{intl.formatMessage({ id: groupName })}</Title>
)}
<Wrapper>
{items?.map((action) => (
<Card
@@ -8,7 +8,9 @@ const volumeOptions = fieldConfig.volumes;
const CloudOptions: React.FC<{
ref?: any;
disabled?: boolean;
}> = 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)}
/>
</Form.Item>
@@ -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,
@@ -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',
@@ -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'
}
];
@@ -0,0 +1,17 @@
export const RegionIcons: Record<string, string> = {
nyc1: '🇺🇸',
nyc2: '🇺🇸',
nyc3: '🇺🇸',
tor1: '🇨🇦',
sfo1: '🇺🇸',
sfo2: '🇺🇸',
sfo3: '🇺🇸',
atl1: '🇺🇸',
sgp1: '🇸🇬',
blr1: '🇮🇳',
lon1: '🇬🇧',
ams2: '🇳🇱',
ams3: '🇳🇱',
fra1: '🇩🇪',
syd1: '🇦🇺'
};
+3 -1
View File
@@ -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<string, string>;
cloud_options: Record<string, any>;
instance_spec: Record<string, any>;
}
export interface NodePoolListItem extends NodePoolFormData {
@@ -25,7 +25,7 @@ const setActionsItems = (row: ClusterListItem) => {
const useClusterColumns = (
handleSelect: (val: string, record: ClusterListItem) => void
): ColumnsType<ClusterListItem> => {
): ColumnsType<ClusterListItem & { dataIndex: string; span: number }> => {
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) => (
<AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]}
</AutoTooltip>
)
},
{
title: 'GPUs',
dataIndex: 'gpus',
span: 2,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models',
span: 2,
render: (value: number) => <span>{value}</span>
},
{
title: 'Workers',
dataIndex: 'workers',
span: 3,
render: (value: number, record: ClusterListItem) => (
<span>
{record.ready_workers} / {record.workers}
</span>
)
},
{
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) => (
<span>
{record.ready_workers} / {record.workers}
</span>
)
},
{
title: 'GPUs',
dataIndex: 'gpus',
span: 3,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models',
span: 2,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -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<ListItem> => {
): ColumnsType<ListItem & { dataIndex: string }> => {
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) => (
<AutoTooltip title={text} ghost minWidth={20}>
{text}
render: (text: string, record: ListItem) => (
<AutoTooltip
title={
<RenderInstanceOption
styles={{
description: {
color: 'var(--color-white-quaternary)'
}
}}
data={{
vendor: record.instance_spec.vendor,
description: record.instance_spec.description,
specInfo: _.omit(record.instance_spec, [
'label',
'vendor',
'description'
])
}}
/>
}
showTitle
ghost
minWidth={20}
>
{record.instance_spec.description || record.instance_spec.label}
</AutoTooltip>
)
},
{
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) => (
<AutoTooltip title={text} ghost minWidth={20}>
<AutoTooltip
title={
<span className="flex-column">
<span className="text-tertiary">
{intl.formatMessage({ id: 'clusters.workerpool.osImage' })}
:{' '}
</span>
{text}
</span>
}
showTitle
ghost
minWidth={20}
>
{text}
</AutoTooltip>
)
@@ -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 = (
</span>
)
},
{
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',
@@ -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
};
};
@@ -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<any>(null);
@@ -40,7 +42,11 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
<div>
<PageTools
marginBottom={26}
left={<Title>Basic Configuration</Title>}
left={
<Title>
{intl.formatMessage({ id: 'clusters.create.configBasic' })}
</Title>
}
marginTop={0}
></PageTools>
<ClusterForm
@@ -1,10 +1,11 @@
import { useNavigate } from '@umijs/max';
import { useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { useMemo } from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import { moduleMap } from './module-registry';
export default function useStepList() {
const intl = useIntl();
const navigate = useNavigate();
const handleBack = useMemoizedFn(() => {
@@ -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]
);
}
@@ -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<Record<number, FormInstance<any> | 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<any>[]) => {
const resultList = results.map((result: PromiseSettledResult<any>) => {
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={
<span className="flex-center gap-16">
<Title>Worker Pools</Title>
<Title>
{intl.formatMessage({ id: 'clusters.workerpool.title' })}
</Title>
</span>
}
right={
@@ -187,7 +195,7 @@ const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => {
color="default"
icon={<PlusOutlined />}
>
Add Pool
{intl.formatMessage({ id: 'clusters.button.addNodePool' })}
</Button>
}
></PageTools>
+49 -12
View File
@@ -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 (
<CardWrapper>
<Header>{header}</Header>
<Description>{description}</Description>
</CardWrapper>
);
};
const GPUCard: React.FC<{
data: any;
header?: React.ReactNode;
@@ -12,17 +48,17 @@ const GPUCard: React.FC<{
}> = ({ data, header, info }) => {
const intl = useIntl();
return (
<div className="gpu-card">
<div className="header" style={{ width: '100%' }}>
{header ?? (
<CardContainer
header={
header || (
<AutoTooltip ghost>
<span className="font-700">[{data.index}] </span>
{data.label}
</AutoTooltip>
)}
</div>
<div className="info">
{info ?? (
)
}
description={
info || (
<>
<span>
{intl.formatMessage({ id: 'resources.table.vram' })}(
@@ -37,7 +73,8 @@ const GPUCard: React.FC<{
</span>
<span>
<span>
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}:{' '}
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}
:{' '}
</span>
{data?.memory?.used
? _.round(data?.memory?.utilization_rate || 0, 2)
@@ -45,10 +82,10 @@ const GPUCard: React.FC<{
%
</span>
</>
)}
</div>
</div>
)
}
></CardContainer>
);
};
export default React.memo(GPUCard);
export default GPUCard;
@@ -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}
@@ -45,7 +45,7 @@ const useModelsColumns = ({
)
},
{
title: 'Cluster',
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'cluster',
key: 'cluster',
span: 3,
+6 -2
View File
@@ -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<string, any> = {
+1 -1
View File
@@ -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;
@@ -228,7 +228,7 @@ const useWorkerColumns = ({
render: (_, record) => (
<StatusTag
maxTooltipWidth={400}
suffix={record.progress}
suffix={record.provision_progress}
statusValue={{
status: status[record.state] as any,
text: WorkerStatusMapValue[record.state],
+24
View File
@@ -44,6 +44,30 @@ export const convertFileSize = (
return `${_.round(size, precs[unitIndex])} ${units[unitIndex]}`;
};
export const convertFileSizeByUnit = (params: {
sizeInBytes: number;
defaultUnit?: 'B' | 'KiB' | 'MiB' | 'GiB' | 'TiB';
allowEmpty?: boolean;
}): string | number => {
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 = () => {