feat: cloud options

This commit is contained in:
jialin
2025-09-16 11:26:17 +08:00
parent 632f206228
commit 6e5a7035c8
29 changed files with 788 additions and 276 deletions
+4 -4
View File
@@ -251,14 +251,14 @@ const Credentials: React.FC = () => {
data: formdata,
clusterId: addPoolStatus.clusterId
});
setAddPoolStatus({
...addPoolStatus,
open: false
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// error
}
setAddPoolStatus({
...addPoolStatus,
open: false
});
};
useEffect(() => {
@@ -1,19 +1,12 @@
import LabelSelector from '@/components/label-selector';
import ModalFooter from '@/components/modal-footer';
import ScrollerModal from '@/components/scroller-modal';
import SealInputNumber from '@/components/seal-form/input-number';
import SealInput from '@/components/seal-form/seal-input';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useAppUtils from '@/hooks/use-app-utils';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import _ from 'lodash';
import React, { useEffect } from 'react';
import React, { useRef } from 'react';
import {
NodePoolFormData as FormData,
NodePoolListItem as ListItem
} from '../config/types';
import PoolForm from './pool-form';
type AddModalProps = {
title: string;
@@ -24,7 +17,7 @@ type AddModalProps = {
onOk: (values: FormData) => void;
onCancel: () => void;
};
const AddCluster: React.FC<AddModalProps> = ({
const AddPool: React.FC<AddModalProps> = ({
title,
action,
open,
@@ -33,45 +26,21 @@ const AddCluster: React.FC<AddModalProps> = ({
currentData,
onCancel
}) => {
const [form] = Form.useForm();
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const formRef = useRef<any>(null);
const handleSumit = () => {
form.submit();
const handleSubmit = () => {
formRef.current?.submit?.();
};
const handleOnOk = async (data: FormData) => {
const { volumes, ...rest } = data;
await onOk({
...rest,
cloud_options: !_.isEmpty(volumes)
? {
volumes: [
{
...volumes
}
]
}
: {}
});
const handleOnFinish = async (data: FormData) => {
onOk(data);
};
const handleCancel = () => {
form.resetFields();
formRef.current?.reset?.();
onCancel();
};
useEffect(() => {
if (currentData) {
form.setFieldsValue({
...currentData,
volumes: currentData.cloud_options?.volumes?.[0] || {}
});
}
}, [currentData]);
return (
<ScrollerModal
title={title}
@@ -83,144 +52,18 @@ const AddCluster: React.FC<AddModalProps> = ({
keyboard={false}
width={600}
footer={
<ModalFooter onOk={handleSumit} onCancel={onCancel}></ModalFooter>
<ModalFooter onOk={handleSubmit} onCancel={onCancel}></ModalFooter>
}
>
<Form
form={form}
onFinish={handleOnOk}
preserve={false}
initialValues={{
replicas: 1,
batch_size: 1
}}
>
<Form.Item<FormData>
name="instance_type"
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.workerpool.instanceType'
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'clusters.workerpool.instanceType'
})}
required
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="replicas"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.replicas')
}
]}
>
<SealInputNumber
label={intl.formatMessage({ id: 'clusters.workerpool.replicas' })}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="batch_size"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.batchSize')
}
]}
>
<SealInputNumber
label={intl.formatMessage({ id: 'clusters.workerpool.batchSize' })}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="os_image"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.osImage')
}
]}
>
<SealInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'clusters.workerpool.osImage' })}
required
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="labels"
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (_.keys(value).length > 0) {
if (_.some(_.keys(value), (k: string) => !value[k])) {
return Promise.reject(
intl.formatMessage(
{
id: 'common.validate.value'
},
{
name: 'labels'
}
)
);
}
}
return Promise.resolve();
}
})
]}
>
<LabelSelector
label={intl.formatMessage({ id: 'resources.table.labels' })}
labels={form.getFieldValue('labels') || {}}
btnText={intl.formatMessage({ id: 'common.button.addLabel' })}
></LabelSelector>
</Form.Item>
<Form.Item<FormData>
name="volumes"
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (_.keys(value).length > 0) {
if (_.some(_.keys(value), (k: string) => !value[k])) {
return Promise.reject(
intl.formatMessage(
{
id: 'common.validate.value'
},
{
name: 'Volumes'
}
)
);
}
}
return Promise.resolve();
}
})
]}
>
<LabelSelector
label="Volumes"
labels={form.getFieldValue('volumes') || {}}
btnText="Add Option"
></LabelSelector>
</Form.Item>
</Form>
<PoolForm
ref={formRef}
action={action}
provider={provider}
currentData={currentData}
onFinish={handleOnFinish}
></PoolForm>
</ScrollerModal>
);
};
export default AddCluster;
export default AddPool;
@@ -0,0 +1,128 @@
import DropDownActions from '@/components/drop-down-actions';
import ListMap from '@/components/dynamic-form/components/list-map';
import { FieldSchema } from '@/components/dynamic-form/config/types';
import { PlusOutlined } from '@ant-design/icons';
import { useMemoizedFn } from 'ahooks';
import { Button, Form } from 'antd';
import _ from 'lodash';
import React, { forwardRef, useImperativeHandle, useMemo } from 'react';
import styled from 'styled-components';
import { CloudOptionItems } from '../config';
import { fieldConfig } from '../config/cloud-options-config';
const Title = styled.div`
display: flex;
height: 40px;
align-items: center;
gap: 16px;
// background-color: var(--ant-color-fill-secondary);
border-radius: var(--ant-border-radius);
margin-bottom: 22px;
font-weight: 600;
`;
const ButtonWrapper = styled.span`
display: flex;
align-items: center;
height: 100%;
font-weight: 400;
cursor: pointer;
gap: 8px;
&:hover {
color: var(--ant-color-text-secondary);
}
`;
const CloudOptions: React.FC<{
ref?: any;
}> = forwardRef((props, ref) => {
// form instance
const form = Form.useFormInstance();
const [selectedOptions, setSelectedOptions] = React.useState<Set<string>>(
new Set()
);
const [fieldList, setFieldList] = React.useState<FieldSchema[]>([]);
const items = useMemo(() => {
return CloudOptionItems.map((item) => ({
...item,
disabled: selectedOptions.has(item.key)
}));
}, [selectedOptions]);
const handleAddOption = useMemoizedFn((item: { key: string }) => {
const field = fieldConfig[item.key];
setFieldList((prev) => [...prev, { ...field, name: item.key }]);
setSelectedOptions((prev) => new Set(prev).add(item.key));
});
const menu = useMemo(() => {
return {
items: items,
onClick: handleAddOption
};
}, [items, handleAddOption]);
const handleOnChange = (name: string, value: any) => {
form.setFieldValue(['cloud_options', name], value);
if (_.isEmpty(value)) {
setFieldList((prev) => prev.filter((field) => field.name !== name));
setSelectedOptions((prev) => {
const newSelected = new Set(prev);
newSelected.delete(name);
return newSelected;
});
}
};
// init field list by form data
const initFieldList = () => {
const cloudOptions = form.getFieldValue('cloud_options');
if (cloudOptions) {
const fields = Object.entries(cloudOptions)
.filter(([, value]) => {
return !_.isEmpty(value);
})
.map(([key, value]) => {
const field = fieldConfig[key];
return { ...field, name: key };
});
setFieldList(fields);
setSelectedOptions(new Set(Object.keys(cloudOptions)));
}
};
useImperativeHandle(ref, () => ({
initFieldList
}));
return (
<>
<Title>
<DropDownActions menu={menu}>
<Button variant="filled" color="default">
<PlusOutlined />
<span>Add Cloud Options</span>
</Button>
</DropDownActions>
</Title>
{fieldList.length > 0 &&
fieldList.map((field) => (
<Form.Item
key={field.name}
name={['cloud_options', field.name as string]}
>
<ListMap
btnText={'Add Item'}
label={field.title}
dataList={form.getFieldValue(['cloud_options', field.name]) || []}
properties={field.properties || {}}
onChange={(value) => handleOnChange(field.name, value)}
/>
</Form.Item>
))}
</>
);
});
export default CloudOptions;
@@ -0,0 +1,167 @@
import LabelSelector from '@/components/label-selector';
import SealInputNumber from '@/components/seal-form/input-number';
import SealInput from '@/components/seal-form/seal-input';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useAppUtils from '@/hooks/use-app-utils';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import _ from 'lodash';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef
} from 'react';
import {
NodePoolFormData as FormData,
NodePoolListItem as ListItem
} from '../config/types';
import CloudOptions from './cloud-options';
type AddModalProps = {
ref: any;
action: PageActionType;
provider: string; // 'kubernetes' | 'custom' | 'digitalocean';
currentData?: ListItem | null;
onFinish: (values: FormData) => void;
};
const PoolForm: React.FC<AddModalProps> = forwardRef(
({ action, onFinish, currentData }, ref) => {
const cloudOptionsRef = useRef<any>(null);
const [form] = Form.useForm();
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
useEffect(() => {
if (currentData) {
form.setFieldsValue({
...currentData,
volumes: currentData.cloud_options?.volumes?.[0] || {}
});
cloudOptionsRef.current?.initFieldList();
}
}, [currentData]);
useImperativeHandle(ref, () => ({
reset: () => {
form.resetFields();
},
submit: () => {
form.submit();
},
validateFields: async () => {
return await form.validateFields();
}
}));
return (
<Form
form={form}
onFinish={onFinish}
preserve={false}
initialValues={{
replicas: 1,
batch_size: 1
}}
>
<Form.Item<FormData>
name="instance_type"
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.workerpool.instanceType'
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'clusters.workerpool.instanceType'
})}
required
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="replicas"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.replicas')
}
]}
>
<SealInputNumber
label={intl.formatMessage({ id: 'clusters.workerpool.replicas' })}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="batch_size"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.batchSize')
}
]}
>
<SealInputNumber
label={intl.formatMessage({ id: 'clusters.workerpool.batchSize' })}
required
></SealInputNumber>
</Form.Item>
<Form.Item<FormData>
name="os_image"
rules={[
{
required: true,
message: getRuleMessage('input', 'clusters.workerpool.osImage')
}
]}
>
<SealInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'clusters.workerpool.osImage' })}
required
></SealInput.Input>
</Form.Item>
<Form.Item<FormData>
name="labels"
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (_.keys(value).length > 0) {
if (_.some(_.keys(value), (k: string) => !value[k])) {
return Promise.reject(
intl.formatMessage(
{
id: 'common.validate.value'
},
{
name: 'labels'
}
)
);
}
}
return Promise.resolve();
}
})
]}
>
<LabelSelector
label={intl.formatMessage({ id: 'resources.table.labels' })}
labels={form.getFieldValue('labels') || {}}
btnText={intl.formatMessage({ id: 'common.button.addLabel' })}
></LabelSelector>
</Form.Item>
<CloudOptions ref={cloudOptionsRef}></CloudOptions>
</Form>
);
}
);
export default PoolForm;
@@ -116,15 +116,15 @@ const WorkerPools = () => {
id: addPoolStatus.currentData!.id
});
}
setAddPoolStatus({
...addPoolStatus,
open: false
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
handleSearch();
} catch (error) {
// error
}
setAddPoolStatus({
...addPoolStatus,
open: false
});
};
const columns = usePoolsColumns(sortOrder, onSelect);
@@ -0,0 +1,49 @@
import { FieldSchema } from '@/components/dynamic-form/config/types';
export const fields = {
volumes: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
name: { type: 'string' },
size_gb: { type: 'number', unit: 'GB' },
format: { type: 'string' }
},
required: ['name', 'size_gb', 'format']
}
}
};
export const fieldConfig: Record<string, FieldSchema> = {
volumes: {
type: 'array',
title: 'Volumes',
name: 'volumes',
properties: {
name: {
name: 'name',
type: 'string',
title: 'Name',
widget: 'Input'
},
size_gb: {
name: 'size_gb',
type: 'number',
title: 'Size (GB)',
widget: 'InputNumber',
min: 0,
style: { width: 120 }
},
format: {
name: 'format',
type: 'string',
title: 'Format',
widget: 'Select',
enum: ['ext4', 'xfs', 'btrfs'],
style: { width: 150 }
}
}
}
};
@@ -155,3 +155,10 @@ export const regionList: {
{ label: 'Frankfurt', datacenter: 'Datacenter 1', value: 'fra1', icon: '🇩🇪' },
{ label: 'Sydney', datacenter: 'Datacenter 1', value: 'syd1', icon: '🇦🇺' }
];
export const CloudOptionItems = [
{
label: 'Volumes',
key: 'volumes'
}
];
@@ -347,7 +347,7 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
})
: ''
}
btnText="common.button.addParams"
btnText={intl.formatMessage({ id: 'common.button.addParams' })}
label={intl.formatMessage({
id: 'models.form.backend_parameters'
})}
@@ -395,7 +395,7 @@ const AdvanceConfig: React.FC<AdvanceConfigProps> = (props) => {
id: 'models.form.env'
})}
labels={EnviromentVars}
btnText="common.button.vars"
btnText={intl.formatMessage({ id: 'common.button.vars' })}
onBlur={handleEnvSelectorOnBlur}
onDelete={handleDeleteEnvSelector}
onChange={handleEnviromentVarsChange}
+19 -4
View File
@@ -1,14 +1,29 @@
import { downloadFile } from '@/utils/download-stream';
import { request } from '@umijs/max';
import { message } from 'antd';
import { GPUDeviceItem, ListItem, ModelFile } from '../config/types';
export const WORKERS_API = '/workers';
export const GPU_DEVICES_API = '/gpu-devices';
export const MODEL_FILES_API = '/model-files';
export async function downloadWorkerPrivateKey(id: string | number) {
return request(`${WORKERS_API}/${id}/privatekey`, {
method: 'GET'
});
// download stream data and save as a csv file
export async function downloadWorkerPrivateKey({
id,
name
}: {
id: string | number;
name?: string;
}) {
try {
const res = await fetch(`/v1${WORKERS_API}/${id}/privatekey`);
if (res.ok) {
const blob = await res.blob();
downloadFile(blob, `${name}-privatekey.csv`);
}
} catch (error) {
message.error('Download failed');
}
}
export async function queryWorkersList<T extends Record<string, any>>(
@@ -102,7 +102,7 @@ const UpdateLabels: React.FC<ViewModalProps> = (props) => {
id: 'resources.table.labels'
})}
labels={labels}
btnText="common.button.addLabel"
btnText={intl.formatMessage({ id: 'common.button.addLabel' })}
onChange={handleLabelsChange}
></LabelSelector>
</Form.Item>
+4 -1
View File
@@ -146,7 +146,10 @@ const Workers: React.FC = () => {
handleViewDetail(record);
}
if (val === 'download_ssh_key') {
downloadWorkerPrivateKey(record.id);
downloadWorkerPrivateKey({
id: record.id,
name: record.name
});
}
});
+1 -1
View File
@@ -23,7 +23,7 @@ export const status: any = {
[WorkerStatusMap.not_ready]: StatusMaps.error,
[WorkerStatusMap.unreachable]: StatusMaps.error,
[WorkerStatusMap.provisioning]: StatusMaps.transitioning,
[WorkerStatusMap.deleting]: StatusMaps.warning,
[WorkerStatusMap.deleting]: StatusMaps.transitioning,
[WorkerStatusMap.error]: StatusMaps.error
};