diff --git a/src/resources/cluster_api.py b/src/resources/cluster_api.py index 98a4ea99a..03d5d0ed8 100644 --- a/src/resources/cluster_api.py +++ b/src/resources/cluster_api.py @@ -214,24 +214,28 @@ def cluster_create(): """ logger.info("/cluster action=" + r.method) request_debug(r, logger) - if not r.form["name"] or not r.form["host_id"] or \ - not r.form["network_type"]: + if r.content_type.startswith("application/json"): + body = dict(r.get_json(force=True, silent=True)) + else: + body = r.form + if not body["name"] or not body["host_id"] or \ + not body["network_type"]: error_msg = "cluster post without enough data" logger.warning(error_msg) - return make_fail_resp(error=error_msg, data=r.form) + return make_fail_resp(error=error_msg, data=body) name, host_id, network_type, size = \ - r.form['name'], r.form['host_id'],\ - r.form['network_type'], int(r.form['size']) + body['name'], body['host_id'],\ + body['network_type'], int(body['size']) if network_type == NETWORK_TYPE_FABRIC_PRE_V1: # TODO: deprecated soon config = FabricPreNetworkConfig( - consensus_plugin=r.form['consensus_plugin'], - consensus_mode=r.form['consensus_mode'], + consensus_plugin=body['consensus_plugin'], + consensus_mode=body['consensus_mode'], size=size) elif network_type == NETWORK_TYPE_FABRIC_V1: config = FabricV1NetworkConfig( - consensus_plugin=r.form['consensus_plugin'], + consensus_plugin=body['consensus_plugin'], size=size) else: error_msg = "Unknown network_type={}".format(network_type) @@ -308,7 +312,8 @@ def cluster_list(): elif r.method == 'POST': f.update(request_json_body(r)) logger.info(f) - result = cluster_handler.list(filter_data=f) + col_name = f.get("state", "active") + result = cluster_handler.list(filter_data=f, col_name=col_name) logger.error(result) return make_ok_resp(data=result) diff --git a/src/themes/react/static/dashboard/src/common/router.js b/src/themes/react/static/dashboard/src/common/router.js index 0a9f7cf17..a029369e2 100644 --- a/src/themes/react/static/dashboard/src/common/router.js +++ b/src/themes/react/static/dashboard/src/common/router.js @@ -85,7 +85,12 @@ export const getRouterData = app => { component: dynamicWrapper(app, ['host'], () => import('../routes/Host/CreateHost')), }, '/chain': { - component: dynamicWrapper(app, [], () => import('../routes/Chain')), + component: dynamicWrapper(app, ['chain'], () => import('../routes/Chain')), + }, + '/create-chain': { + component: dynamicWrapper(app, ['host', 'chain'], () => + import('../routes/Chain/CreateChain') + ), }, '/user-management': { component: dynamicWrapper(app, [], () => import('../routes/UserManagement')), diff --git a/src/themes/react/static/dashboard/src/locales/en.json b/src/themes/react/static/dashboard/src/locales/en.json index f46168266..fee31ba64 100755 --- a/src/themes/react/static/dashboard/src/locales/en.json +++ b/src/themes/react/static/dashboard/src/locales/en.json @@ -60,5 +60,28 @@ "Host.Create.Validate.Label.Schedulable": "Schedulable", "Host.Create.Validate.Label.Filled": "Auto Filled", "Host.Create.Button.Submit": "Submit", - "Host.Create.Button.Cancel": "Cancel" + "Host.Create.Button.Cancel": "Cancel", + "Chain.Messages.Operate.Success.Restart": "Restart chain {name} successfully.", + "Chain.Messages.Operate.Success.Start": "Start chain {name} successfully.", + "Chain.Messages.Operate.Success.Stop": "Stop chain {name} successfully.", + "Chain.Messages.Operate.Success.Release": "Release chain successfully", + "Chain.Messages.Confirm.DeleteChain": "Do you want to delete chain {name}?", + "Chain.Title": "Chains", + "Chain.Button.Restart": "Restart", + "Chain.Button.Start": "Start", + "Chain.Button.Stop": "Stop", + "Chain.Button.Release": "Release", + "Chain.Button.More": "More", + "Chain.Button.Delete": "Delete", + "Chain.Button.Add": "Add", + "Chain.Radio.Option.Active": "Active", + "Chain.Radio.Option.Released": "Released", + "Chain.Label.NetworkType": "Network Type", + "Chain.Label.ConsensusPlugin": "Consensus Plugin", + "Chain.Label.Owner": "Owner", + "Chain.Label.CreateTime": "Create Time", + "Chain.Create.Title": "Create New Chain", + "Chain.Create.Validate.Required.Host": "Must select a host", + "Chain.Create.Label.Host": "Host", + "Chain.Create.Label.ChainSize": "Chain Size" } diff --git a/src/themes/react/static/dashboard/src/locales/zh.json b/src/themes/react/static/dashboard/src/locales/zh.json index 313f74a67..ff64e10db 100755 --- a/src/themes/react/static/dashboard/src/locales/zh.json +++ b/src/themes/react/static/dashboard/src/locales/zh.json @@ -60,5 +60,28 @@ "Host.Create.Validate.Label.Schedulable": "可调度", "Host.Create.Validate.Label.Filled": "自动填充", "Host.Create.Button.Submit": "提交", - "Host.Create.Button.Cancel": "取消" + "Host.Create.Button.Cancel": "取消", + "Chain.Messages.Operate.Success.Restart": "重启链 {name} 成功。", + "Chain.Messages.Operate.Success.Start": "启动链 {name} 成功。", + "Chain.Messages.Operate.Success.Stop": "停止链 {name} 成功。", + "Chain.Messages.Operate.Success.Release": "释放链成功", + "Chain.Messages.Confirm.DeleteChain": "是否确认删除链 {name}?", + "Chain.Title": "链", + "Chain.Button.Restart": "重启", + "Chain.Button.Start": "启动", + "Chain.Button.Stop": "停止", + "Chain.Button.Release": "释放", + "Chain.Button.More": "更多", + "Chain.Button.Delete": "删除", + "Chain.Button.Add": "添加", + "Chain.Radio.Option.Active": "激活", + "Chain.Radio.Option.Released": "释放", + "Chain.Label.NetworkType": "网络类型", + "Chain.Label.ConsensusPlugin": "共识", + "Chain.Label.Owner": "使用者", + "Chain.Label.CreateTime": "创建时间", + "Chain.Create.Title": "创建新的链", + "Chain.Create.Validate.Required.Host": "必须选择一个主机", + "Chain.Create.Label.Host": "主机", + "Chain.Create.Label.ChainSize": "链大小" } diff --git a/src/themes/react/static/dashboard/src/models/chain.js b/src/themes/react/static/dashboard/src/models/chain.js new file mode 100644 index 000000000..54fb520b9 --- /dev/null +++ b/src/themes/react/static/dashboard/src/models/chain.js @@ -0,0 +1,96 @@ +/* + SPDX-License-Identifier: Apache-2.0 +*/ +import { routerRedux } from 'dva/router'; +import { IntlProvider, defineMessages } from 'react-intl'; +import { message } from 'antd'; +import { queryChains, operateChain, deleteChain, createChain } from '../services/chain'; +import { getLocale } from '../utils/utils'; + +const currentLocale = getLocale(); +const intlProvider = new IntlProvider( + { locale: currentLocale.locale, messages: currentLocale.messages }, + {} +); +const { intl } = intlProvider.getChildContext(); + +const messages = defineMessages({ + operate: { + success: { + restart: { + id: 'Chain.Messages.Operate.Success.Restart', + defaultMessage: '重启链 {name} 成功。', + }, + start: { + id: 'Chain.Messages.Operate.Success.Start', + defaultMessage: '启动链 {name} 成功。', + }, + stop: { + id: 'Chain.Messages.Operate.Success.Stop', + defaultMessage: '停止链 {name} 成功。', + }, + release: { + id: 'Chain.Messages.Operate.Success.Release', + defaultMessage: '释放链成功。', + }, + }, + }, +}); + +export default { + namespace: 'chain', + + state: { + chains: [], + }, + + effects: { + *fetchChains({ payload }, { call, put }) { + const response = yield call(queryChains, payload); + yield put({ + type: 'setChains', + payload: response.data, + }); + }, + *operateChain({ payload }, { call, put }) { + const response = yield call(operateChain, payload); + yield put({ + type: 'fetchChains', + }); + if (response.code === 200) { + const values = { name: payload.name }; + message.success(intl.formatMessage(messages.operate.success[payload.action], values)); + } + }, + *deleteChain({ payload }, { call, put }) { + const response = yield call(deleteChain, payload); + if (response.code === 200) { + message.success(`Delete Chain ${payload.name} successfully`); + } + yield put({ + type: 'fetchChains', + }); + }, + *createChain({ payload }, { call, put }) { + const response = yield call(createChain, payload); + if (response.code === 201) { + message.success('Create Chain successfully'); + yield put( + routerRedux.push({ + pathname: '/chain', + }) + ); + } + yield call(payload.callback); + }, + }, + + reducers: { + setChains(state, action) { + return { + ...state, + chains: action.payload, + }; + }, + }, +}; diff --git a/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.js b/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.js new file mode 100644 index 000000000..2639047de --- /dev/null +++ b/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.js @@ -0,0 +1,242 @@ +/* + SPDX-License-Identifier: Apache-2.0 +*/ +import React, { PureComponent } from 'react'; +import { Card, Form, Input, Button, Select } from 'antd'; +import { routerRedux } from 'dva/router'; +import { connect } from 'dva'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import PageHeaderLayout from '../../../layouts/PageHeaderLayout'; +import styles from './index.less'; + +const FormItem = Form.Item; +const { Option } = Select; + +const messages = defineMessages({ + updateTitle: { + id: 'Host.Create.UpdateTitle', + defaultMessage: 'Update Host', + }, + title: { + id: 'Chain.Create.Title', + defaultMessage: 'Create New Chain', + }, + subTitle: { + id: 'Host.Create.SubTitle', + defaultMessage: 'Here you can create multiple type host, for creating fabric cluster.', + }, + label: { + name: { + id: 'Host.Create.Validate.Label.Name', + defaultMessage: 'Name', + }, + host: { + id: 'Chain.Create.Label.Host', + defaultMessage: 'Host', + }, + networkType: { + id: 'Chain.Label.NetworkType', + defaultMessage: 'Network Type', + }, + consensusPlugin: { + id: 'Chain.Label.ConsensusPlugin', + defaultMessage: 'Consensus Plugin', + }, + chainSize: { + id: 'Chain.Create.Label.ChainSize', + defaultMessage: 'Chain Size', + }, + }, + button: { + submit: { + id: 'Host.Create.Button.Submit', + defaultMessage: 'Submit', + }, + cancel: { + id: 'Host.Create.Button.Cancel', + defaultMessage: 'Cancel', + }, + }, + validate: { + error: { + workerApi: { + id: 'Host.Create.Validate.Error.WorkerApi', + defaultMessage: 'Please input validate worker api.', + }, + }, + required: { + name: { + id: 'Host.Create.Validate.Required.Name', + defaultMessage: 'Please input name.', + }, + host: { + id: 'Chain.Create.Validate.Required.Host', + defaultMessage: 'Must select a host', + }, + }, + }, +}); + +@connect(({ host, loading }) => ({ + host, + loadingHosts: loading.effects['host/fetchHosts'], +})) +@Form.create() +class CreateChain extends PureComponent { + state = { + submitting: false, + }; + componentDidMount() { + this.props.dispatch({ + type: 'host/fetchHosts', + }); + } + submitCallback = () => { + this.setState({ + submitting: false, + }); + }; + clickCancel = () => { + this.props.dispatch( + routerRedux.push({ + pathname: '/chain', + }) + ); + }; + handleSubmit = e => { + e.preventDefault(); + this.props.form.validateFieldsAndScroll((err, values) => { + if (!err) { + this.setState({ + submitting: true, + }); + this.props.dispatch({ + type: 'chain/createChain', + payload: { + ...values, + callback: this.submitCallback, + }, + }); + } + }); + }; + render() { + const { getFieldDecorator } = this.props.form; + const { intl, host } = this.props; + const { submitting } = this.state; + const { hosts } = host; + const availableHosts = hosts.filter(hostItem => hostItem.capacity > hostItem.clusters.length); + const hostOptions = availableHosts.map(hostItem => ( + + )); + const networkTypes = ['fabric-1.0']; + const networkTypeOptions = networkTypes.map(networkType => ( + + )); + const chainSizes = [4]; + const chainSizeOptions = chainSizes.map(chainSize => ( + + )); + const consensusPlugins = ['solo', 'kafka']; + const consensusPluginOptions = consensusPlugins.map(consensusPlugin => ( + + )); + + const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 7 }, + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 10 }, + }, + }; + + const submitFormLayout = { + wrapperCol: { + xs: { span: 24, offset: 0 }, + sm: { span: 10, offset: 7 }, + }, + }; + return ( + + +
+ + {getFieldDecorator('name', { + initialValue: '', + rules: [ + { + required: true, + message: intl.formatMessage(messages.validate.required.name), + }, + ], + })()} + + + {getFieldDecorator('host_id', { + initialValue: availableHosts.length > 0 ? availableHosts[0].id : '', + rules: [ + { + required: true, + message: intl.formatMessage(messages.validate.required.host), + }, + ], + })()} + + + {getFieldDecorator('network_type', { + initialValue: networkTypes[0], + rules: [ + { + required: true, + message: 'Must select network type', + }, + ], + })()} + + + {getFieldDecorator('size', { + initialValue: chainSizes[0], + rules: [ + { + required: true, + message: 'Must select chain size', + }, + ], + })()} + + + {getFieldDecorator('consensus_plugin', { + initialValue: consensusPlugins[0], + rules: [ + { + required: true, + message: 'Must select consensus plugin', + }, + ], + })()} + + + + + +
+
+
+ ); + } +} + +export default injectIntl(CreateChain); diff --git a/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.less b/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.less new file mode 100644 index 000000000..d53faf2bf --- /dev/null +++ b/src/themes/react/static/dashboard/src/routes/Chain/CreateChain/index.less @@ -0,0 +1,11 @@ +@import '~antd/lib/style/themes/default.less'; + +.upperText { + text-transform: uppercase; +} + +.optional { + color: @text-color-secondary; + font-style: normal; + margin-left: 4px; +} diff --git a/src/themes/react/static/dashboard/src/routes/Chain/index.js b/src/themes/react/static/dashboard/src/routes/Chain/index.js index a8ee85cf3..2201c6f97 100644 --- a/src/themes/react/static/dashboard/src/routes/Chain/index.js +++ b/src/themes/react/static/dashboard/src/routes/Chain/index.js @@ -2,15 +2,282 @@ SPDX-License-Identifier: Apache-2.0 */ import React, { PureComponent } from 'react'; -import { Card } from 'antd'; +import { connect } from 'dva'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { List, Card, Button, Dropdown, Menu, Icon, Badge, Modal, Radio } from 'antd'; +import { routerRedux } from 'dva/router'; import PageHeaderLayout from '../../layouts/PageHeaderLayout'; +import styles from './style.less'; -export default class Chain extends PureComponent { +const RadioButton = Radio.Button; +const RadioGroup = Radio.Group; + +const messages = defineMessages({ + title: { + id: 'Chain.Title', + defaultMessage: 'Chains', + }, + button: { + add: { + id: 'Chain.Button.Add', + defaultMessage: 'Add', + }, + more: { + id: 'Chain.Button.More', + defaultMessage: 'More', + }, + restart: { + id: 'Chain.Button.Restart', + defaultMessage: 'Restart', + }, + start: { + id: 'Chain.Button.Start', + defaultMessage: 'Start', + }, + stop: { + id: 'Chain.Button.Stop', + defaultMessage: 'Stop', + }, + release: { + id: 'Chain.Button.Release', + defaultMessage: 'Release', + }, + delete: { + id: 'Chain.Button.Delete', + defaultMessage: 'Delete', + }, + }, + label: { + networkType: { + id: 'Chain.Label.NetworkType', + defaultMessage: 'Network Type', + }, + consensusPlugin: { + id: 'Chain.Label.ConsensusPlugin', + defaultMessage: 'Consensus Plugin', + }, + owner: { + id: 'Chain.Label.Owner', + defaultMessage: 'Owner', + }, + createTime: { + id: 'Chain.Label.CreateTime', + defaultMessage: 'Create Time', + }, + }, + radio: { + option: { + active: { + id: 'Chain.Radio.Option.Active', + defaultMessage: 'Active', + }, + released: { + id: 'Chain.Radio.Option.Released', + defaultMessage: 'Released', + }, + }, + }, + message: { + confirm: { + deleteChain: { + id: 'Chain.Messages.Confirm.DeleteChain', + defaultMessage: 'Do you want to delete chan {name}?', + }, + }, + }, +}); + +@connect(({ chain, loading }) => ({ + chain, + loadingChains: loading.effects['chain/fetchChains'], +})) +class Chain extends PureComponent { + componentDidMount() { + this.props.dispatch({ + type: 'chain/fetchChains', + }); + } + onClickAddChain = () => { + this.props.dispatch( + routerRedux.push({ + pathname: '/create-chain', + }) + ); + }; + changeChainType = e => { + this.props.dispatch({ + type: 'chain/fetchChains', + payload: { + state: e.target.value, + }, + }); + }; + clickMore = ({ item, key }) => { + const { dispatch, intl } = this.props; + const { id, name } = item.props.chain; + const values = { name }; + switch (key) { + case 'delete': + Modal.confirm({ + title: intl.formatMessage(messages.message.confirm.deleteChain, values), + onOk() { + dispatch({ + type: 'chain/deleteChain', + payload: { + id, + col_name: 'active', + }, + }); + }, + }); + break; + case 'start': + case 'stop': + case 'restart': + case 'release': + dispatch({ + type: 'chain/operateChain', + payload: { + cluster_id: id, + action: key, + name, + }, + }); + break; + default: + break; + } + }; render() { + const { loadingChains, chain, intl } = this.props; + const { chains } = chain; + const paginationProps = { + showSizeChanger: true, + showQuickJumper: true, + pageSize: 5, + total: chains.length, + }; + function badgeStatus(status) { + switch (status) { + case 'running': + return 'success'; + case 'error': + return 'error'; + case 'stopped': + return 'default'; + default: + break; + } + } + const ListContent = ({ data: { user_id, create_ts, status } }) => ( +
+
+ + + +

{user_id === '' ? 'Empty' : user_id}

+
+
+ + + +

{create_ts}

+
+
+ +
+
+ ); + const menu = chainItem => ( + + + + + + + + + + + + + + + + + + + + ); + const MoreBtn = ({ chainItem }) => ( + + + + + + ); + const extraContent = ( +
+ + + + + + + + +
+ ); return ( - - Chain Management + +
+ + + ( + ]}> + {item.name}} + description={ +
+

+ : {item.network_type} +

+

+ :{' '} + {item.consensus_plugin} +

+
+ } + /> + +
+ )} + /> +
+
); } } + +export default injectIntl(Chain); diff --git a/src/themes/react/static/dashboard/src/routes/Chain/style.less b/src/themes/react/static/dashboard/src/routes/Chain/style.less index 6b12d3c3e..287586e2f 100644 --- a/src/themes/react/static/dashboard/src/routes/Chain/style.less +++ b/src/themes/react/static/dashboard/src/routes/Chain/style.less @@ -1,90 +1,184 @@ @import '~antd/lib/style/themes/default.less'; +@import '../../utils/utils.less'; -.card { - margin-bottom: 24px; +.status-badge { + text-transform: capitalize; } -.heading { - font-size: 14px; - line-height: 22px; - margin: 0 0 16px 0; +.delete-button { + color: red; } -.steps:global(.ant-steps) { - max-width: 750px; - margin: 16px auto; +.standardList { + :global { + .ant-card-head { + border-bottom: none; + } + .ant-card-head-title { + line-height: 32px; + padding: 24px 0; + } + .ant-card-extra { + padding: 24px 0; + } + .ant-list-pagination { + text-align: right; + margin-top: 24px; + } + .ant-avatar-lg { + width: 48px; + height: 48px; + line-height: 48px; + } + } + .headerInfo { + position: relative; + text-align: center; + & > span { + color: @text-color-secondary; + display: inline-block; + font-size: @font-size-base; + line-height: 22px; + margin-bottom: 4px; + } + & > p { + color: @heading-color; + font-size: 24px; + line-height: 32px; + margin: 0; + } + & > em { + background-color: @border-color-split; + position: absolute; + height: 56px; + width: 1px; + top: 0; + right: 0; + } + } + .listContent { + font-size: 0; + .listContentItem { + color: @text-color-secondary; + display: inline-block; + vertical-align: middle; + font-size: @font-size-base; + margin-left: 40px; + > span { + line-height: 20px; + } + > p { + margin-top: 4px; + margin-bottom: 0; + line-height: 22px; + } + } + } + .extraContentSearch { + margin-left: 16px; + width: 272px; + } } -.errorIcon { - cursor: pointer; - color: @error-color; - margin-right: 24px; - i { - margin-right: 4px; +@media screen and (max-width: @screen-xs) { + .standardList { + :global { + .ant-list-item-content { + display: block; + flex: none; + width: 100%; + } + .ant-list-item-action { + margin-left: 0; + } + } + .listContent { + margin-left: 0; + & > div { + margin-left: 0; + } + } + .listCard { + :global { + .ant-card-head-title { + overflow: visible; + } + } + } } } -.errorPopover { - :global { - .ant-popover-inner-content { - padding: 0; - max-height: 290px; - overflow: auto; - min-width: 256px; +@media screen and (max-width: @screen-sm) { + .standardList { + .extraContentSearch { + margin-left: 0; + width: 100%; + } + .headerInfo { + margin-bottom: 16px; + & > em { + display: none; + } } } } -.errorListItem { - list-style: none; - border-bottom: 1px solid @border-color-split; - padding: 8px 16px; - cursor: pointer; - transition: all 0.3s; - &:hover { - background: @primary-1; - } - &:last-child { - border: 0; - } - .errorIcon { - color: @error-color; - float: left; - margin-top: 4px; - margin-right: 12px; - padding-bottom: 22px; +@media screen and (max-width: @screen-md) { + .standardList { + .listContent { + & > div { + display: block; + } + & > div:last-child { + top: 0; + width: 100%; + } + } } - .errorField { - font-size: 12px; - color: @text-color-secondary; - margin-top: 2px; + .listCard { + :global { + .ant-radio-group { + display: block; + margin-bottom: 8px; + } + } } } -.editable { - td { - padding-top: 13px !important; - padding-bottom: 12.5px !important; +@media screen and (max-width: @screen-lg) and (min-width: @screen-md) { + .standardList { + .listContent { + & > div { + display: block; + } + & > div:last-child { + top: 0; + width: 100%; + } + } } } -// custom footer for fixed footer toolbar -.advancedForm + div { - padding-bottom: 64px; -} - -.advancedForm { - :global { - .ant-form .ant-row:last-child .ant-form-item { - margin-bottom: 24px; - } - .ant-table td { - transition: none !important; +@media screen and (max-width: @screen-xl) { + .standardList { + .listContent { + & > div { + margin-left: 24px; + } + & > div:last-child { + top: 0; + } } } } -.optional { - color: @text-color-secondary; - font-style: normal; +@media screen and (max-width: 1400px) { + .standardList { + .listContent { + text-align: right; + & > div:last-child { + top: 0; + } + } + } } diff --git a/src/themes/react/static/dashboard/src/services/chain.js b/src/themes/react/static/dashboard/src/services/chain.js new file mode 100644 index 000000000..bbd5f3978 --- /dev/null +++ b/src/themes/react/static/dashboard/src/services/chain.js @@ -0,0 +1,29 @@ +/* + SPDX-License-Identifier: Apache-2.0 +*/ +import { stringify } from 'qs'; +import request from '../utils/request'; +import config from '../utils/config'; + +const { urls } = config; +export async function queryChains(params) { + return request(`${urls.cluster.list}?${stringify(params)}`); +} + +export async function operateChain(params) { + return request(`${urls.cluster.operate}?${stringify(params)}`); +} + +export async function deleteChain(params) { + return request(urls.cluster.crud, { + method: 'DELETE', + body: JSON.stringify(params), + }) +} + +export async function createChain(params) { + return request(urls.cluster.crud, { + method: 'POST', + body: params, + }); +}