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
@@ -0,0 +1,19 @@
import ComponentsMap from '@/components/seal-form/config/components';
import { SealFormItemProps } from '@/components/seal-form/types';
import { Form } from 'antd';
import React from 'react';
interface FieldItemProps extends SealFormItemProps {
widget: keyof typeof ComponentsMap;
name: string;
}
const FieldItem: React.FC<FieldItemProps> = (props) => {
const { name, widget, required = [], ...rest } = props;
const Component = ComponentsMap[widget];
return <Form.Item name={name}></Form.Item>;
};
export default FieldItem;
@@ -0,0 +1,45 @@
import ComponentsMap from '@/components/seal-form/config/components';
import { FormWidgetProps } from '../config/types';
const FormWidget: React.FC<
FormWidgetProps & {
onChange?: (data: any) => void;
}
> = ({
widget,
title: label,
required,
placeholder,
options,
description,
enum: enumValues,
style,
value,
min,
max,
checked,
onChange
}) => {
const Component = ComponentsMap[widget];
const optionList = enumValues?.map((item: string | number) => ({
label: item,
value: item
}));
return Component ? (
<Component
{...{ label, required, placeholder, description, min, max }}
options={options || optionList}
value={value}
checked={checked}
style={{
width: '100%',
...style
}}
onChange={onChange}
/>
) : null;
};
export default FormWidget;
@@ -0,0 +1,136 @@
import Wrapper from '@/components/label-selector/wrapper';
import { MinusOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import FormWidget from './form-widget';
interface ListMapProps {
dataList: any[];
label?: React.ReactNode;
btnText?: string;
properties: Record<string, any>;
onChange?: (data: any) => void;
}
interface ListItemProps {
schemaList: any[];
data: Record<string, any>;
onChange?: (data: any) => void;
}
const RowWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
`;
const WidgetBox = styled.div`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
`;
const ListItem: React.FC<ListItemProps> = ({ schemaList, data, onChange }) => {
const handleValueChange = (name: string, target: any) => {
if (target?.target?.type === 'checkbox') {
const checked = target.target?.checked;
onChange?.({ [name]: checked });
} else {
const value = target?.target ? target.target.value : target;
onChange?.({ [name]: value });
}
};
return (
<>
{schemaList.map((schema: any) => (
<FormWidget
widget={schema.type}
{...schema}
key={schema.name}
value={data?.[schema.name]}
checked={data?.[schema.name]}
onChange={(target) => handleValueChange(schema.name, target)}
/>
))}
</>
);
};
const ListMap: React.FC<ListMapProps> = ({
dataList = [],
label,
btnText,
properties = {},
onChange
}) => {
const [items, setItems] = React.useState(dataList || []);
const schemaList = useMemo(() => {
const list = Object.entries(properties).map(([key, value]) => ({
...value,
name: key
}));
return list;
}, [properties]);
const handleOnAdd = () => {
const keys = Object.keys(properties);
setItems([
...items,
{ ...keys.reduce((acc, key) => ({ ...acc, [key]: '' }), {}) }
]);
};
const handleDelete = (index: number) => {
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
onChange?.(newItems);
};
const handleItemChange = (index: number, data: { [key: string]: any }) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], ...data };
setItems(newItems);
onChange?.(newItems);
};
useEffect(() => {
if (!dataList.length) {
handleOnAdd();
}
}, []);
return (
<Wrapper label={label} btnText={btnText} onAdd={handleOnAdd}>
{items.map((item, index) => (
<RowWrapper key={index}>
<WidgetBox>
<ListItem
schemaList={schemaList}
data={item}
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)}
/>
</RowWrapper>
))}
</Wrapper>
);
};
export default ListMap;
@@ -0,0 +1,34 @@
import React from 'react';
// refer to json schema
export interface FieldSchema {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
title?: string;
name: string;
description?: string;
properties?: Record<string, FieldSchema>;
default?: any;
enum?: string[];
minItems?: number;
maxItems?: number;
items?: FieldSchema[];
widget?: string;
min?: number;
style?: React.CSSProperties;
}
export interface FormWidgetProps {
widget: 'Input' | 'Select' | 'Checkbox' | 'InputNumber';
name: string;
title?: string;
required?: boolean;
placeholder?: string;
options?: { label: string; value: string | number }[];
description?: string;
enum?: (string | number)[];
style?: React.CSSProperties;
value?: any;
checked?: boolean;
min?: number;
max?: number;
}
@@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { FieldSchema } from '../config/types';
interface ParsedField {
name: (string | number)[];
schema: FieldSchema;
}
const parseSchema = (
schema: Record<string, FieldSchema>,
parentName: (string | number)[] = []
): ParsedField[] => {
const fields: ParsedField[] = [];
Object.entries(schema).forEach(([key, fieldSchema]) => {
const currentName = [...parentName, key];
if (fieldSchema.type === 'object' && fieldSchema.properties) {
fields.push(...parseSchema(fieldSchema.properties, currentName));
} else if (fieldSchema.type === 'array' && fieldSchema.items) {
fields.push({ name: currentName, schema: fieldSchema });
} else {
fields.push({ name: currentName, schema: fieldSchema });
}
});
return fields;
};
const useParsedFields = (schema: Record<string, FieldSchema>) => {
return useMemo(() => parseSchema(schema), [schema]);
};
export default useParsedFields;
+26
View File
@@ -0,0 +1,26 @@
import { Form } from 'antd';
import React from 'react';
import { FieldSchema } from './config/types';
interface DynamicFormProps {
schema: FieldSchema;
onSubmit: (values: any) => void;
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit }) => {
const form = Form.useFormInstance();
const handleFinish = (values: any) => {
onSubmit(values);
};
return (
<>
<Form form={form} onFinish={handleFinish}>
{/* Render form fields based on schema */}
</Form>
</>
);
};
export default DynamicForm;
+8 -24
View File
@@ -1,8 +1,6 @@
import { PlusOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import _ from 'lodash';
import React, { useRef } from 'react';
import React from 'react';
import LabelItem from './label-item';
import Wrapper from './wrapper';
interface LabelSelectorProps {
@@ -31,8 +29,6 @@ const Inner: React.FC<LabelSelectorProps> = ({
description
}) => {
const intl = useIntl();
const buttonRef = useRef<HTMLButtonElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const updateLabels = (list: { key: string; value: string }[]) => {
const newLabels = _.reduce(
@@ -75,7 +71,12 @@ const Inner: React.FC<LabelSelectorProps> = ({
};
return (
<Wrapper label={label} description={description}>
<Wrapper
label={label}
description={description}
onAdd={handleAddLabel}
btnText={btnText}
>
<>
{labelList?.map((item: any, index: number) => {
return (
@@ -91,26 +92,9 @@ const Inner: React.FC<LabelSelectorProps> = ({
/>
);
})}
<div className="flex justify-center">
<Button
ref={buttonRef}
type="text"
block
style={{
marginTop: 16,
backgroundColor: 'var(--ant-color-fill-secondary)'
}}
onClick={handleAddLabel}
>
<PlusOutlined className="font-size-14" />{' '}
{intl.formatMessage({
id: btnText || 'common.button.addSelector'
})}
</Button>
</div>
</>
</Wrapper>
);
};
export default React.memo(Inner);
export default Inner;
@@ -1,20 +0,0 @@
.wrapper {
position: relative;
padding: 14px;
padding-top: 34px;
border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base);
display: flex;
width: 100%;
flex-direction: column;
:global {
.label {
position: absolute;
left: 16px;
line-height: 1;
top: 12px;
color: var(--ant-color-text-tertiary);
}
}
}
+56 -8
View File
@@ -1,15 +1,54 @@
import LabelInfo from '@/components/seal-form/components/label-info';
import { PlusOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import React from 'react';
import styles from './styles/wrapper.less';
import styled from 'styled-components';
const Wrapper: React.FC<{
interface WrapperProps {
label?: React.ReactNode;
description?: React.ReactNode;
labelExtra?: React.ReactNode;
children: React.ReactNode;
}> = ({ children, label, description, labelExtra, ...rest }) => {
btnText?: string;
onAdd?: () => void;
button?: React.ReactNode;
}
const Container = styled.div`
position: relative;
padding: 14px;
padding-top: 34px;
border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base);
display: flex;
width: 100%;
flex-direction: column;
.label {
position: absolute;
left: 16px;
line-height: 1;
top: 12px;
color: var(--ant-color-text-tertiary);
}
`;
const ButtonWrapper = styled.div`
margin-top: 16px;
`;
const Wrapper: React.FC<WrapperProps> = ({
children,
label,
description,
labelExtra,
onAdd,
btnText,
button
}) => {
const intl = useIntl();
return (
<div className={styles['wrapper']}>
<Container>
{label && (
<span className="label">
<LabelInfo
@@ -19,10 +58,19 @@ const Wrapper: React.FC<{
></LabelInfo>
</span>
)}
{React.isValidElement(children)
? React.cloneElement(children, { ...rest })
: children}
</div>
{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>
</Container>
);
};
+8 -25
View File
@@ -1,6 +1,4 @@
import { PlusOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import _ from 'lodash';
import React from 'react';
import Wrapper from '../label-selector/wrapper';
@@ -34,7 +32,6 @@ const ListInput: React.FC<ListInputProps> = (props) => {
} = props;
const [list, setList] = React.useState<{ value: string; uid: number }[]>([]);
const countRef = React.useRef(0);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const updateCountRef = () => {
countRef.current = countRef.current + 1;
@@ -65,9 +62,6 @@ const ListInput: React.FC<ListInputProps> = (props) => {
uid: countRef.current
});
setList(values);
// setTimeout(() => {
// buttonRef.current?.scrollIntoView?.({ behavior: 'smooth' });
// }, 100);
};
React.useEffect(() => {
@@ -85,7 +79,13 @@ const ListInput: React.FC<ListInputProps> = (props) => {
}, [dataList]);
return (
<Wrapper label={label} description={description} labelExtra={labelExtra}>
<Wrapper
label={label}
description={description}
labelExtra={labelExtra}
onAdd={handleOnAdd}
btnText={btnText}
>
<>
{_.map(list, (item: any, index: number) => {
return (
@@ -100,26 +100,9 @@ const ListInput: React.FC<ListInputProps> = (props) => {
/>
);
})}
<div className="flex justify-center">
<Button
ref={buttonRef}
variant="filled"
color="default"
block
style={{
marginTop: 16
}}
onClick={handleOnAdd}
>
<PlusOutlined className="font-size-14" />{' '}
{intl.formatMessage({
id: btnText
})}
</Button>
</div>
</>
</Wrapper>
);
};
export default React.memo(ListInput);
export default ListInput;
+1 -1
View File
@@ -85,7 +85,7 @@ export const FilterBar: React.FC<FilterBarProps> = (props) => {
handleDeleteByBatch,
handleClickPrimary,
rowSelection,
actionItems,
actionItems = [],
selectOptions,
showSelect,
buttonText,
+2 -1
View File
@@ -22,6 +22,7 @@ const SealInput: React.FC<InputProps & SealFormItemProps> = (props) => {
trim = true,
loading,
labelExtra,
style,
...rest
} = props;
const [isFocus, setIsFocus] = useState(false);
@@ -73,7 +74,7 @@ const SealInput: React.FC<InputProps & SealFormItemProps> = (props) => {
};
return (
<InputWrapper>
<InputWrapper style={style}>
<Wrapper
status={checkStatus || status}
label={label}
+2 -1
View File
@@ -29,5 +29,6 @@ export default {
'menu.404': '404',
'menu.clusterManagement': 'Cluster Management',
'menu.clusterManagement.clusters': 'Clusters',
'menu.clusterManagement.credentials': 'Credentials'
'menu.clusterManagement.credentials': 'Credentials',
'menu.clusterManagement.clusterDetail': 'Cluster Detail'
};
+3 -1
View File
@@ -29,7 +29,8 @@ export default {
'menu.clusterManagement': 'Cluster Management',
'menu.clusterManagement.clusters': 'Clusters',
'menu.clusterManagement.credentials': 'Credentials',
'menu.models.userModels': 'My Models'
'menu.models.userModels': 'My Models',
'menu.clusterManagement.clusterDetail': 'Cluster Detail'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
@@ -44,4 +45,5 @@ export default {
// 9. 'menu.clusterManagement.clusters': 'Clusters',
// 10. 'menu.clusterManagement.credentials': 'Credentials',
// 11. 'menu.models.userModels': 'My Models'
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail'
// ========== End of To-Do List ==========
+4 -2
View File
@@ -29,7 +29,8 @@ export default {
'menu.clusterManagement': 'Cluster Management',
'menu.clusterManagement.clusters': 'Clusters',
'menu.clusterManagement.credentials': 'Credentials',
'menu.models.userModels': 'My Models'
'menu.models.userModels': 'My Models',
'menu.clusterManagement.clusterDetail': 'Cluster Detail'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
@@ -43,5 +44,6 @@ export default {
// 8. 'menu.clusterManagement': 'Cluster Management',
// 9. 'menu.clusterManagement.clusters': 'Clusters',
// 10. 'menu.clusterManagement.credentials': 'Credentials',
// 11. 'menu.models.userModels': 'My Models'
// 11. 'menu.models.userModels': 'My Models',
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail'
// ========== End of To-Do List ==========
+2 -1
View File
@@ -29,5 +29,6 @@ export default {
'menu.accessControl.users': '用户',
'menu.clusterManagement': '集群管理',
'menu.clusterManagement.clusters': '集群',
'menu.clusterManagement.credentials': '凭证'
'menu.clusterManagement.credentials': '凭证',
'menu.clusterManagement.clusterDetail': '集群详情'
};
+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
};
+5
View File
@@ -0,0 +1,5 @@
import { saveAs } from 'file-saver';
export const downloadFile = (blob: Blob, filename: string) => {
saveAs(blob, filename);
};